| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Mouse Humanize Visualizer</title> |
| <style> |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| :root { |
| --bg: #0f111a; |
| --panel: rgba(22, 27, 34, 0.92); |
| --border: #30363d; |
| --text: #e6edf3; |
| --muted: #8b949e; |
| --blue: #58a6ff; |
| --red: #f85149; |
| --green: #3fb950; |
| --yellow: #d29922; |
| } |
| body { |
| background: var(--bg); |
| color: var(--text); |
| font-family: 'SFMono-Regular', Consolas, monospace; |
| overflow: hidden; |
| width: 100vw; |
| height: 100vh; |
| position: relative; |
| font-size: 13px; |
| } |
| #canvas { |
| position: absolute; |
| top: 0; |
| left: 0; |
| z-index: 1; |
| pointer-events: none; |
| } |
| .panel { |
| position: absolute; |
| background: var(--panel); |
| border: 1px solid var(--border); |
| border-radius: 6px; |
| padding: 10px 12px; |
| z-index: 10; |
| backdrop-filter: blur(2px); |
| } |
| .panel h3 { |
| font-size: 11px; |
| text-transform: uppercase; |
| letter-spacing: 0.04em; |
| color: var(--muted); |
| margin-bottom: 6px; |
| font-weight: normal; |
| } |
| |
| #stats { |
| top: 14px; |
| left: 14px; |
| width: 260px; |
| line-height: 1.55; |
| } |
| .panel .row { display: flex; justify-content: space-between; gap: 8px; } |
| .panel .row span:last-child { color: var(--blue); font-weight: bold; } |
| .panel .sep { border-top: 1px solid var(--border); margin: 6px 0; } |
| |
| |
| #keyboard-panel { |
| top: 14px; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 380px; |
| } |
| #keyboard-panel .row { margin-bottom: 2px; } |
| #keyboard-panel .typing-row { display: flex; gap: 6px; margin-bottom: 8px; } |
| #typing-input { |
| flex: 1; |
| background: #0d1117; |
| border: 1px solid var(--border); |
| border-radius: 4px; |
| color: var(--text); |
| padding: 6px 8px; |
| font-family: inherit; |
| font-size: 13px; |
| } |
| #typing-input:focus { outline: 1px solid var(--blue); } |
| #btn-kb-clear { |
| background: #21262d; |
| border: 1px solid var(--border); |
| color: var(--muted); |
| border-radius: 4px; |
| cursor: pointer; |
| padding: 0 12px; |
| font-family: inherit; |
| } |
| #btn-kb-clear:hover { background: #30363d; } |
| |
| |
| #controls { |
| top: 14px; |
| right: 14px; |
| width: 210px; |
| } |
| #controls button { |
| width: 100%; |
| margin-bottom: 6px; |
| padding: 6px 8px; |
| background: #21262d; |
| color: var(--text); |
| border: 1px solid var(--border); |
| border-radius: 4px; |
| cursor: pointer; |
| font-family: inherit; |
| font-size: 12px; |
| } |
| #controls button:hover { background: #30363d; } |
| #controls label { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| margin-bottom: 4px; |
| color: var(--muted); |
| font-size: 12px; |
| cursor: pointer; |
| } |
| |
| |
| #graph-container { |
| bottom: 14px; |
| left: 14px; |
| width: 480px; |
| } |
| .graph-label { |
| font-size: 11px; |
| color: var(--muted); |
| margin: 6px 0 3px; |
| display: flex; |
| justify-content: space-between; |
| } |
| canvas.graph { display: block; width: 100%; border-bottom: 1px solid #21262d; } |
| |
| |
| #history-panel { |
| bottom: 14px; |
| right: 14px; |
| width: 320px; |
| max-height: 300px; |
| display: flex; |
| flex-direction: column; |
| } |
| #history-list { |
| overflow-y: auto; |
| flex: 1; |
| margin-top: 4px; |
| } |
| .run-entry { |
| padding: 5px 6px; |
| border-radius: 4px; |
| border: 1px solid transparent; |
| cursor: pointer; |
| font-size: 11px; |
| color: var(--muted); |
| line-height: 1.4; |
| } |
| .run-entry:hover { background: #1c2128; } |
| .run-entry.selected { border-color: var(--blue); background: #17253a; color: var(--text); } |
| .run-entry b { color: var(--text); } |
| |
| |
| .targets-container { |
| position: absolute; |
| width: 100%; |
| height: 100%; |
| z-index: 2; |
| } |
| .target-btn { |
| position: absolute; |
| padding: 22px 32px; |
| background: #1f242c; |
| border: 2px dashed var(--blue); |
| border-radius: 8px; |
| color: var(--blue); |
| font-weight: bold; |
| font-family: inherit; |
| cursor: pointer; |
| user-select: none; |
| } |
| .target-btn::after { |
| content: ''; |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| width: 6px; |
| height: 6px; |
| background: var(--red); |
| border-radius: 50%; |
| transform: translate(-50%, -50%); |
| } |
| .heatmap { |
| position: absolute; |
| top: -46px; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 44px; |
| height: 44px; |
| background: rgba(13, 17, 23, 0.9); |
| border: 1px solid var(--border); |
| border-radius: 4px; |
| pointer-events: none; |
| } |
| </style> |
| </head> |
| <body> |
|
|
| <canvas id="canvas"></canvas> |
|
|
| <div class="panel" id="stats"> |
| <h3>Π’Π΅ΠΊΡΡΠΈΠΉ run</h3> |
| <div class="row"><span>Π’ΠΎΡΠ΅ΠΊ</span><span id="s-pts">0</span></div> |
| <div class="row"><span>ΠΠ»ΠΈΡΠ΅Π»ΡΠ½ΠΎΡΡΡ</span><span id="s-dur">0 ms</span></div> |
| <div class="row"><span>ΠΡΡΡ / ΠΏΡΡΠΌΠ°Ρ</span><span id="s-path">0 / 0 px</span></div> |
| <div class="row"><span>ΠΠΈΠ½Π΅ΠΉΠ½ΠΎΡΡΡ</span><span id="s-eff">β</span></div> |
| <div class="row"><span>Avg / Max V</span><span id="s-v">0 / 0 px/ms</span></div> |
| <div class="row"><span>ΠΠΆΠΈΡΡΠ΅Ρ dt (Ο)</span><span id="s-jitter">β</span></div> |
| <div class="sep"></div> |
| <div class="row"><span>Max V (Π²ΡΠ΅Π³ΠΎ)</span><span id="s-maxv-all">0</span></div> |
| <div class="row"><span>ΠΠ»ΠΈΠΊ Ξ</span><span id="s-click">β</span></div> |
| </div> |
|
|
| <div class="panel" id="controls"> |
| <h3>Π£ΠΏΡΠ°Π²Π»Π΅Π½ΠΈΠ΅</h3> |
| <button id="btn-clear">ΠΡΠΈΡΡΠΈΡΡ Π²ΡΡ</button> |
| <button id="btn-fade-toggle">ΠΠ°ΡΡΡ
Π°Π½ΠΈΠ΅ ΡΠ»Π΅Π΄Π°: Π²ΡΠΊΠ»</button> |
| <button id="btn-shuffle">ΠΠ΅ΡΠ΅ΠΌΠ΅ΡΠ°ΡΡ ΠΌΠΈΡΠ΅Π½ΠΈ</button> |
| <button id="btn-export">ΠΠΊΡΠΏΠΎΡΡ JSON</button> |
| <label><input type="checkbox" id="chk-markers" checked> Π’ΠΎΡΠΊΠΈ Π΄ΠΈΡΠΊΡΠ΅ΡΠΈΠ·Π°ΡΠΈΠΈ</label> |
| <label><input type="checkbox" id="chk-ref" checked> ΠΠΏΠΎΡΠ½Π°Ρ ΠΏΡΡΠΌΠ°Ρ (startβend)</label> |
| </div> |
|
|
| <div class="panel" id="keyboard-panel"> |
| <h3>ΠΠ»Π°Π²ΠΈΠ°ΡΡΡΠ° (humanize)</h3> |
| <div class="typing-row"> |
| <input type="text" id="typing-input" placeholder="ΠΠ΅ΡΠ°ΡΠ°ΠΉΡΠ΅ Π·Π΄Π΅ΡΡ / ΡΠΎΠΊΡΡ Π΄Π»Ρ ΠΈΠ½ΡΠ΅ΠΊΡΠΈΠΈ..."> |
| <button id="btn-kb-clear" title="ΠΡΠΈΡΡΠΈΡΡ">β</button> |
| </div> |
| <div class="row"><span>Π‘ΠΈΠΌΠ²ΠΎΠ»ΠΎΠ²</span><span id="kb-count">0</span></div> |
| <div class="row"><span>ΠΠ»ΠΈΡΠ΅Π»ΡΠ½ΠΎΡΡΡ</span><span id="kb-dur">0 ms</span></div> |
| <div class="row"><span>Π‘ΠΊΠΎΡΠΎΡΡΡ</span><span id="kb-speed">0 CPM</span></div> |
| <div class="row"><span>Flight avg / Ο</span><span id="kb-flight">β / β</span></div> |
| <div class="row"><span>Dwell avg / Ο</span><span id="kb-dwell">β / β</span></div> |
| <div class="graph-label"><span>Flight time (ΠΏΠ°ΡΠ·Π° ΠΏΠ΅ΡΠ΅Π΄ ΠΊΠ»Π°Π²ΠΈΡΠ΅ΠΉ), ΠΌΡ</span></div> |
| <canvas class="graph" id="flightGraph" width="360" height="55"></canvas> |
| <div class="graph-label"><span>Dwell time (ΡΠ΄Π΅ΡΠΆΠ°Π½ΠΈΠ΅ ΠΊΠ»Π°Π²ΠΈΡΠΈ), ΠΌΡ</span></div> |
| <canvas class="graph" id="dwellGraph" width="360" height="55"></canvas> |
| </div> |
|
|
| <div class="targets-container" id="targets"></div> |
|
|
| <div class="panel" id="graph-container"> |
| <h3>ΠΠΈΠ½Π΅ΠΌΠ°ΡΠΈΠΊΠ° Π΄Π²ΠΈΠΆΠ΅Π½ΠΈΡ V(t)</h3> |
| <div class="graph-label"> |
| <span>Π‘ΠΊΠΎΡΠΎΡΡΡ, px/ms</span> |
| <span><span style="color:var(--blue)">β </span> speed <span style="color:var(--red)">β</span> ΠΊΠ»ΠΈΠΊ</span> |
| </div> |
| <canvas class="graph" id="vGraph" width="460" height="90"></canvas> |
| <div class="graph-label"> |
| <span>ΠΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ Π½Π°ΠΏΡΠ°Π²Π»Π΅Π½ΠΈΡ, Β°</span> |
| <span>0Β° = ΠΏΡΡΠΌΠ°Ρ, 180Β° = ΡΠ°Π·Π²ΠΎΡΠΎΡ</span> |
| </div> |
| <canvas class="graph" id="turnGraph" width="460" height="60"></canvas> |
| </div> |
|
|
| <div class="panel" id="history-panel"> |
| <h3>ΠΡΡΠΎΡΠΈΡ runs (ΠΊΠ»ΠΈΠΊ β ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΈΡΡ)</h3> |
| <div id="history-list"></div> |
| </div> |
|
|
| <script> |
| (function () { |
| const canvas = document.getElementById('canvas'); |
| const ctx = canvas.getContext('2d'); |
| const vCanvas = document.getElementById('vGraph'); |
| const vCtx = vCanvas.getContext('2d'); |
| const tCanvas = document.getElementById('turnGraph'); |
| const tCtx = tCanvas.getContext('2d'); |
| |
| function resize() { |
| canvas.width = window.innerWidth; |
| canvas.height = window.innerHeight; |
| } |
| window.addEventListener('resize', resize); |
| resize(); |
| |
| |
| |
| |
| const IDLE_GAP_MS = 300; |
| const MAX_RUNS_KEPT = 30; |
| const FADE_MS = 25000; |
| |
| let runs = []; |
| let currentRun = null; |
| let lastPoint = null; |
| let runIdCounter = 1; |
| let globalMaxSpeed = 0; |
| let selectedRun = null; |
| let fadeEnabled = false; |
| let markersEnabled = true; |
| let refLineEnabled = true; |
| |
| const targetClicks = {}; |
| |
| document.getElementById('btn-fade-toggle').addEventListener('click', (e) => { |
| fadeEnabled = !fadeEnabled; |
| e.target.textContent = 'ΠΠ°ΡΡΡ
Π°Π½ΠΈΠ΅ ΡΠ»Π΅Π΄Π°: ' + (fadeEnabled ? 'Π²ΠΊΠ»' : 'Π²ΡΠΊΠ»'); |
| }); |
| document.getElementById('chk-markers').addEventListener('change', e => markersEnabled = e.target.checked); |
| document.getElementById('chk-ref').addEventListener('change', e => refLineEnabled = e.target.checked); |
| |
| |
| |
| |
| const DEFAULT_TARGETS = [ |
| { id: 'btn-start', label: 'Start Point' }, |
| { id: 'btn-target', label: 'Target Button A' }, |
| { id: 'btn-submit', label: 'Submit Form B' }, |
| ]; |
| |
| const targetsContainer = document.getElementById('targets'); |
| |
| function buildTargets() { |
| targetsContainer.innerHTML = ''; |
| DEFAULT_TARGETS.forEach((t, i) => { |
| const btn = document.createElement('button'); |
| btn.className = 'target-btn'; |
| btn.id = t.id; |
| btn.textContent = t.label; |
| const heat = document.createElement('canvas'); |
| heat.className = 'heatmap'; |
| heat.width = 44; heat.height = 44; |
| btn.appendChild(heat); |
| targetsContainer.appendChild(btn); |
| if (!targetClicks[t.id]) targetClicks[t.id] = []; |
| }); |
| layoutTargetsDefault(); |
| } |
| |
| function layoutTargetsDefault() { |
| const btns = [...targetsContainer.querySelectorAll('.target-btn')]; |
| const n = btns.length; |
| btns.forEach((b, i) => { |
| b.style.left = (window.innerWidth * (i + 1) / (n + 1) - 70) + 'px'; |
| b.style.top = (window.innerHeight / 2 - 30) + 'px'; |
| b.style.padding = '22px 32px'; |
| }); |
| } |
| |
| function shuffleTargets() { |
| const btns = [...targetsContainer.querySelectorAll('.target-btn')]; |
| const margin = 120; |
| btns.forEach(b => { |
| const padV = 14 + Math.random() * 26; |
| const padH = 18 + Math.random() * 40; |
| b.style.padding = padV.toFixed(0) + 'px ' + padH.toFixed(0) + 'px'; |
| const w = 90 + padH * 2, h = 40 + padV * 2; |
| const x = margin + Math.random() * (window.innerWidth - margin * 2 - w); |
| const y = margin + Math.random() * (window.innerHeight - margin * 2 - h); |
| b.style.left = x + 'px'; |
| b.style.top = y + 'px'; |
| }); |
| } |
| |
| document.getElementById('btn-shuffle').addEventListener('click', shuffleTargets); |
| buildTargets(); |
| |
| |
| |
| |
| function getSpeedColor(speed, max, alpha) { |
| const normalized = Math.min(speed / (max || 2.5), 1); |
| const hue = (1 - normalized) * 240; |
| return `hsla(${hue}, 100%, 50%, ${alpha})`; |
| } |
| |
| |
| |
| |
| function startNewRun(firstPoint) { |
| currentRun = { |
| id: runIdCounter++, |
| points: [firstPoint], |
| startTime: firstPoint.t, |
| endTime: firstPoint.t, |
| maxSpeed: 0, |
| click: null, |
| }; |
| runs.push(currentRun); |
| if (runs.length > MAX_RUNS_KEPT) runs.shift(); |
| } |
| |
| function addPoint(run, p) { |
| const prev = run.points[run.points.length - 1]; |
| const dt = p.t - prev.t; |
| const dist = Math.hypot(p.x - prev.x, p.y - prev.y); |
| p.speed = dt > 0 ? dist / dt : 0; |
| p.dt = dt; |
| |
| |
| if (run.points.length >= 2) { |
| const prev2 = run.points[run.points.length - 2]; |
| const prevAngle = Math.atan2(prev.y - prev2.y, prev.x - prev2.x); |
| const curAngle = Math.atan2(p.y - prev.y, p.x - prev.x); |
| let diff = curAngle - prevAngle; |
| while (diff > Math.PI) diff -= 2 * Math.PI; |
| while (diff < -Math.PI) diff += 2 * Math.PI; |
| p.turn = Math.abs(diff) * 180 / Math.PI; |
| } else { |
| p.turn = 0; |
| } |
| |
| run.points.push(p); |
| run.endTime = p.t; |
| if (p.speed > run.maxSpeed) run.maxSpeed = p.speed; |
| if (p.speed > globalMaxSpeed) globalMaxSpeed = p.speed; |
| } |
| |
| function runMetrics(run) { |
| const pts = run.points; |
| let pathLen = 0; |
| const dts = []; |
| for (let i = 1; i < pts.length; i++) { |
| pathLen += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); |
| dts.push(pts[i].dt); |
| } |
| const straight = Math.hypot(pts[pts.length - 1].x - pts[0].x, pts[pts.length - 1].y - pts[0].y); |
| const duration = run.endTime - run.startTime; |
| const efficiency = pathLen > 0 ? (straight / pathLen) : 1; |
| const avgSpeed = pathLen > 0 && duration > 0 ? pathLen / duration : 0; |
| const meanDt = dts.length ? dts.reduce((a, b) => a + b, 0) / dts.length : 0; |
| const variance = dts.length ? dts.reduce((a, b) => a + (b - meanDt) ** 2, 0) / dts.length : 0; |
| const jitter = Math.sqrt(variance); |
| return { pathLen, straight, duration, efficiency, avgSpeed, meanDt, jitter, points: pts.length }; |
| } |
| |
| |
| |
| |
| window.addEventListener('mousemove', (e) => { |
| const now = performance.now(); |
| const p = { x: e.clientX, y: e.clientY, t: now }; |
| |
| if (!currentRun || !lastPoint || (now - lastPoint.t) > IDLE_GAP_MS) { |
| startNewRun(p); |
| } else { |
| addPoint(currentRun, p); |
| } |
| lastPoint = p; |
| |
| selectedRun = currentRun; |
| renderHistoryList(); |
| updateStatsPanel(currentRun); |
| drawKinematicsGraphs(currentRun); |
| }); |
| |
| |
| |
| |
| window.addEventListener('click', (e) => { |
| const target = e.target.closest('.target-btn'); |
| const clickX = e.clientX; |
| const clickY = e.clientY; |
| const now = performance.now(); |
| |
| let clickInfo = { x: clickX, y: clickY, t: now, target: null, dx: null, dy: null, dist: null }; |
| |
| if (target) { |
| const rect = target.getBoundingClientRect(); |
| const centerX = rect.left + rect.width / 2; |
| const centerY = rect.top + rect.height / 2; |
| const dx = clickX - centerX; |
| const dy = clickY - centerY; |
| const dist = Math.hypot(dx, dy); |
| |
| clickInfo.target = target.id; |
| clickInfo.dx = dx; clickInfo.dy = dy; clickInfo.dist = dist; |
| |
| targetClicks[target.id].push({ dx, dy, halfW: rect.width / 2, halfH: rect.height / 2 }); |
| drawHeatmap(target); |
| |
| document.getElementById('s-click').textContent = |
| `${dist.toFixed(1)}px (x:${dx > 0 ? '+' : ''}${dx.toFixed(0)}, y:${dy > 0 ? '+' : ''}${dy.toFixed(0)})`; |
| } |
| |
| |
| if (currentRun && (now - currentRun.endTime) < IDLE_GAP_MS) { |
| currentRun.click = clickInfo; |
| } else { |
| |
| const p = { x: clickX, y: clickY, t: now, speed: 0, turn: 0, dt: 0 }; |
| startNewRun(p); |
| currentRun.click = clickInfo; |
| } |
| |
| renderHistoryList(); |
| drawKinematicsGraphs(currentRun); |
| }); |
| |
| |
| |
| |
| function drawHeatmap(targetEl) { |
| const heat = targetEl.querySelector('.heatmap'); |
| const hctx = heat.getContext('2d'); |
| const w = heat.width, h = heat.height; |
| hctx.clearRect(0, 0, w, h); |
| hctx.strokeStyle = '#30363d'; |
| hctx.beginPath(); |
| hctx.moveTo(w / 2, 0); hctx.lineTo(w / 2, h); |
| hctx.moveTo(0, h / 2); hctx.lineTo(w, h / 2); |
| hctx.stroke(); |
| |
| const history = targetClicks[targetEl.id]; |
| history.forEach((c, i) => { |
| const nx = (c.dx / c.halfW); |
| const ny = (c.dy / c.halfH); |
| const px = w / 2 + nx * (w / 2 - 3); |
| const py = h / 2 + ny * (h / 2 - 3); |
| const isLast = i === history.length - 1; |
| hctx.beginPath(); |
| hctx.arc(px, py, isLast ? 2.5 : 1.6, 0, Math.PI * 2); |
| hctx.fillStyle = isLast ? '#f85149' : 'rgba(88,166,255,0.7)'; |
| hctx.fill(); |
| }); |
| } |
| |
| |
| |
| |
| function drawScene() { |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| const now = performance.now(); |
| |
| runs.forEach(run => { |
| const isSelected = run === selectedRun; |
| const pts = run.points; |
| |
| for (let i = 1; i < pts.length; i++) { |
| const age = now - pts[i].t; |
| if (fadeEnabled && age > FADE_MS) continue; |
| const alpha = fadeEnabled ? Math.max(0.08, 1 - age / FADE_MS) : (isSelected || !selectedRun ? 1 : 0.15); |
| |
| ctx.beginPath(); |
| ctx.moveTo(pts[i - 1].x, pts[i - 1].y); |
| ctx.lineTo(pts[i].x, pts[i].y); |
| ctx.strokeStyle = getSpeedColor(pts[i].speed || 0, globalMaxSpeed, alpha); |
| ctx.lineWidth = isSelected ? 3 : 2; |
| ctx.stroke(); |
| |
| if (markersEnabled) { |
| ctx.beginPath(); |
| ctx.arc(pts[i].x, pts[i].y, 1.5, 0, Math.PI * 2); |
| ctx.fillStyle = `rgba(255,255,255,${alpha})`; |
| ctx.fill(); |
| } |
| } |
| |
| |
| if (refLineEnabled && pts.length > 2 && (isSelected || !fadeEnabled)) { |
| const first = pts[0], last = pts[pts.length - 1]; |
| const age = now - last.t; |
| if (!fadeEnabled || age < FADE_MS) { |
| const alpha = fadeEnabled ? Math.max(0.08, 1 - age / FADE_MS) * 0.5 : 0.35; |
| ctx.beginPath(); |
| ctx.setLineDash([4, 4]); |
| ctx.moveTo(first.x, first.y); |
| ctx.lineTo(last.x, last.y); |
| ctx.strokeStyle = `rgba(210,153,34,${alpha})`; |
| ctx.lineWidth = 1; |
| ctx.stroke(); |
| ctx.setLineDash([]); |
| } |
| } |
| |
| if (run.click) { |
| const age = now - run.click.t; |
| if (!fadeEnabled || age < FADE_MS * 2) { |
| const alpha = fadeEnabled ? Math.max(0.15, 1 - age / (FADE_MS * 2)) : 1; |
| ctx.beginPath(); |
| ctx.arc(run.click.x, run.click.y, 8, 0, Math.PI * 2); |
| ctx.strokeStyle = `rgba(255,255,255,${alpha})`; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| ctx.beginPath(); |
| ctx.arc(run.click.x, run.click.y, 3, 0, Math.PI * 2); |
| ctx.fillStyle = `rgba(248,81,73,${alpha})`; |
| ctx.fill(); |
| |
| if (run.click.target) { |
| const el = document.getElementById(run.click.target); |
| if (el) { |
| const rect = el.getBoundingClientRect(); |
| const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2; |
| ctx.beginPath(); |
| ctx.setLineDash([3, 3]); |
| ctx.moveTo(cx, cy); |
| ctx.lineTo(run.click.x, run.click.y); |
| ctx.strokeStyle = `rgba(255,123,114,${alpha})`; |
| ctx.stroke(); |
| ctx.setLineDash([]); |
| } |
| ctx.font = '11px monospace'; |
| ctx.fillStyle = `rgba(255,123,114,${alpha})`; |
| ctx.fillText(`Ξ: ${run.click.dist.toFixed(1)}px`, run.click.x + 12, run.click.y + 4); |
| } |
| } |
| } |
| }); |
| |
| requestAnimationFrame(drawScene); |
| } |
| requestAnimationFrame(drawScene); |
| |
| |
| |
| |
| function drawKinematicsGraphs(run) { |
| if (!run || run.points.length < 2) return; |
| const pts = run.points; |
| const duration = Math.max(run.endTime - run.startTime, 40); |
| |
| |
| vCtx.clearRect(0, 0, vCanvas.width, vCanvas.height); |
| const w = vCanvas.width, h = vCanvas.height; |
| const maxV = Math.max(run.maxSpeed, 0.3); |
| |
| vCtx.strokeStyle = '#21262d'; |
| for (let g = 1; g < 4; g++) { |
| const gy = h - (g / 4) * (h - 10) - 5; |
| vCtx.beginPath(); vCtx.moveTo(0, gy); vCtx.lineTo(w, gy); vCtx.stroke(); |
| } |
| |
| vCtx.beginPath(); |
| vCtx.strokeStyle = '#58a6ff'; |
| vCtx.lineWidth = 1.5; |
| pts.forEach((p, i) => { |
| const x = ((p.t - run.startTime) / duration) * w; |
| const y = h - ((p.speed || 0) / maxV) * (h - 10) - 5; |
| if (i === 0) vCtx.moveTo(x, y); else vCtx.lineTo(x, y); |
| }); |
| vCtx.stroke(); |
| |
| if (run.click) { |
| const cx = Math.min(w, ((run.click.t - run.startTime) / duration) * w); |
| vCtx.beginPath(); |
| vCtx.strokeStyle = '#f85149'; |
| vCtx.setLineDash([3, 3]); |
| vCtx.moveTo(cx, 0); vCtx.lineTo(cx, h); |
| vCtx.stroke(); |
| vCtx.setLineDash([]); |
| } |
| |
| |
| tCtx.clearRect(0, 0, tCanvas.width, tCanvas.height); |
| const tw = tCanvas.width, th = tCanvas.height; |
| tCtx.strokeStyle = '#21262d'; |
| const midY = th - (90 / 180) * (th - 6) - 3; |
| tCtx.beginPath(); tCtx.moveTo(0, midY); tCtx.lineTo(tw, midY); tCtx.stroke(); |
| |
| tCtx.beginPath(); |
| tCtx.strokeStyle = '#3fb950'; |
| tCtx.lineWidth = 1.5; |
| pts.forEach((p, i) => { |
| const x = ((p.t - run.startTime) / duration) * tw; |
| const y = th - ((p.turn || 0) / 180) * (th - 6) - 3; |
| if (i === 0) tCtx.moveTo(x, y); else tCtx.lineTo(x, y); |
| }); |
| tCtx.stroke(); |
| } |
| |
| |
| |
| |
| function updateStatsPanel(run) { |
| const m = runMetrics(run); |
| document.getElementById('s-pts').textContent = m.points; |
| document.getElementById('s-dur').textContent = m.duration.toFixed(0) + ' ms'; |
| document.getElementById('s-path').textContent = `${m.pathLen.toFixed(0)} / ${m.straight.toFixed(0)} px`; |
| document.getElementById('s-eff').textContent = |
| (m.efficiency * 100).toFixed(0) + '% ' + (m.efficiency > 0.97 ? '(ΠΏΠΎΡΡΠΈ ΠΏΡΡΠΌΠ°Ρ)' : m.efficiency > 0.8 ? '(Π»ΡΠ³ΠΊΠ°Ρ ΠΊΡΠΈΠ²ΠΈΠ·Π½Π°)' : '(Π²ΡΡΠ°ΠΆΠ΅Π½Π½Π°Ρ ΠΊΡΠΈΠ²ΠΈΠ·Π½Π°)'); |
| document.getElementById('s-v').textContent = `${m.avgSpeed.toFixed(2)} / ${run.maxSpeed.toFixed(2)} px/ms`; |
| document.getElementById('s-jitter').textContent = |
| m.jitter > 0 ? `${m.jitter.toFixed(2)} ms (${m.jitter < 1 ? 'ΡΠ°Π²Π½ΠΎΠΌΠ΅ΡΠ½ΠΎ, ΠΏΠΎΡ
ΠΎΠΆΠ΅ Π½Π° ΡΠΎΠ±ΠΎΡΠ°' : 'Π²Π°ΡΠΈΠ°ΡΠΈΠ²Π½ΠΎ'})` : 'β'; |
| document.getElementById('s-maxv-all').textContent = globalMaxSpeed.toFixed(2) + ' px/ms'; |
| } |
| |
| |
| |
| |
| function renderHistoryList() { |
| const list = document.getElementById('history-list'); |
| list.innerHTML = ''; |
| for (let i = runs.length - 1; i >= 0; i--) { |
| const run = runs[i]; |
| const m = runMetrics(run); |
| const div = document.createElement('div'); |
| div.className = 'run-entry' + (run === selectedRun ? ' selected' : ''); |
| const clickTxt = run.click && run.click.dist != null ? `, ΠΊΠ»ΠΈΠΊ Ξ${run.click.dist.toFixed(0)}px` : (run.click ? ', ΠΊΠ»ΠΈΠΊ ΠΌΠΈΠΌΠΎ ΠΌΠΈΡΠ΅Π½ΠΈ' : ''); |
| div.innerHTML = `<b>Run #${run.id}</b> β ${m.duration.toFixed(0)}ms, ${m.points}pt, Π»ΠΈΠ½. ${(m.efficiency * 100).toFixed(0)}%${clickTxt}`; |
| div.addEventListener('click', () => { |
| selectedRun = run; |
| renderHistoryList(); |
| updateStatsPanel(run); |
| drawKinematicsGraphs(run); |
| }); |
| list.appendChild(div); |
| } |
| } |
| |
| |
| |
| |
| document.getElementById('btn-clear').addEventListener('click', () => { |
| runs = []; |
| currentRun = null; |
| lastPoint = null; |
| selectedRun = null; |
| globalMaxSpeed = 0; |
| Object.keys(targetClicks).forEach(k => targetClicks[k] = []); |
| document.querySelectorAll('.heatmap').forEach(h => h.getContext('2d').clearRect(0, 0, h.width, h.height)); |
| renderHistoryList(); |
| vCtx.clearRect(0, 0, vCanvas.width, vCanvas.height); |
| tCtx.clearRect(0, 0, tCanvas.width, tCanvas.height); |
| document.getElementById('s-click').textContent = 'β'; |
| document.getElementById('s-maxv-all').textContent = '0'; |
| }); |
| |
| document.getElementById('btn-export').addEventListener('click', () => { |
| const data = { |
| exportedAt: new Date().toISOString(), |
| runs: runs.map(r => ({ |
| id: r.id, |
| startTime: r.startTime, |
| endTime: r.endTime, |
| click: r.click, |
| points: r.points.map(p => ({ x: p.x, y: p.y, t: p.t, speed: p.speed || 0, turn: p.turn || 0, dt: p.dt || 0 })), |
| metrics: runMetrics(r), |
| })), |
| targetClicks, |
| }; |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `humanize-trace-${Date.now()}.json`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| }); |
| |
| renderHistoryList(); |
| |
| |
| |
| |
| const typingInput = document.getElementById('typing-input'); |
| const flightCanvas = document.getElementById('flightGraph'); |
| const flightCtx = flightCanvas.getContext('2d'); |
| const dwellCanvas = document.getElementById('dwellGraph'); |
| const dwellCtx = dwellCanvas.getContext('2d'); |
| |
| const KB_IDLE_GAP_MS = 2000; |
| let kbEvents = []; |
| let kbRunStart = null; |
| const pendingKeyDowns = new Map(); |
| let lastKeyUpTime = null; |
| |
| function isTrackedKey(e) { |
| return e.key.length === 1 || e.key === 'Backspace' || e.key === 'Enter'; |
| } |
| |
| function stdDev(arr) { |
| if (!arr.length) return 0; |
| const mean = arr.reduce((a, b) => a + b, 0) / arr.length; |
| return Math.sqrt(arr.reduce((a, b) => a + (b - mean) ** 2, 0) / arr.length); |
| } |
| |
| typingInput.addEventListener('keydown', (e) => { |
| if (!isTrackedKey(e) || e.repeat) return; |
| const now = performance.now(); |
| const lastEventTime = kbEvents.length ? kbEvents[kbEvents.length - 1].upT : kbRunStart; |
| if (!kbRunStart || (now - lastEventTime) > KB_IDLE_GAP_MS) { |
| kbEvents = []; |
| kbRunStart = now; |
| lastKeyUpTime = null; |
| } |
| pendingKeyDowns.set(e.code, { t: now, key: e.key }); |
| }); |
| |
| typingInput.addEventListener('keyup', (e) => { |
| const down = pendingKeyDowns.get(e.code); |
| if (!down) return; |
| pendingKeyDowns.delete(e.code); |
| const now = performance.now(); |
| const dwell = now - down.t; |
| const flight = lastKeyUpTime !== null ? down.t - lastKeyUpTime : null; |
| kbEvents.push({ key: down.key, downT: down.t, upT: now, dwell, flight }); |
| lastKeyUpTime = now; |
| updateKeyboardStats(); |
| drawKeyboardGraphs(); |
| }); |
| |
| document.getElementById('btn-kb-clear').addEventListener('click', () => { |
| typingInput.value = ''; |
| kbEvents = []; |
| kbRunStart = null; |
| lastKeyUpTime = null; |
| pendingKeyDowns.clear(); |
| updateKeyboardStats(); |
| flightCtx.clearRect(0, 0, flightCanvas.width, flightCanvas.height); |
| dwellCtx.clearRect(0, 0, dwellCanvas.width, dwellCanvas.height); |
| }); |
| |
| function updateKeyboardStats() { |
| const n = kbEvents.length; |
| document.getElementById('kb-count').textContent = n; |
| if (!n) { |
| document.getElementById('kb-dur').textContent = '0 ms'; |
| document.getElementById('kb-speed').textContent = '0 CPM'; |
| document.getElementById('kb-flight').textContent = 'β / β'; |
| document.getElementById('kb-dwell').textContent = 'β / β'; |
| return; |
| } |
| const duration = Math.max(kbEvents[n - 1].upT - kbRunStart, 1); |
| const flights = kbEvents.map(k => k.flight).filter(v => v !== null); |
| const dwells = kbEvents.map(k => k.dwell); |
| const avgFlight = flights.length ? flights.reduce((a, b) => a + b, 0) / flights.length : 0; |
| const avgDwell = dwells.reduce((a, b) => a + b, 0) / dwells.length; |
| const cpm = (n / duration) * 60000; |
| |
| document.getElementById('kb-dur').textContent = duration.toFixed(0) + ' ms'; |
| document.getElementById('kb-speed').textContent = `${cpm.toFixed(0)} CPM (~${(cpm / 5).toFixed(0)} WPM)`; |
| document.getElementById('kb-flight').textContent = |
| flights.length ? `${avgFlight.toFixed(0)} / ${stdDev(flights).toFixed(0)} ms` : 'β / β'; |
| document.getElementById('kb-dwell').textContent = `${avgDwell.toFixed(0)} / ${stdDev(dwells).toFixed(0)} ms`; |
| } |
| |
| function drawKeyboardGraphs() { |
| const n = kbEvents.length; |
| if (!n) return; |
| const duration = Math.max(kbEvents[n - 1].upT - kbRunStart, 40); |
| |
| |
| flightCtx.clearRect(0, 0, flightCanvas.width, flightCanvas.height); |
| const fw = flightCanvas.width, fh = flightCanvas.height; |
| const flightVals = kbEvents.map(k => k.flight).filter(v => v !== null); |
| const maxFlight = Math.max(...flightVals, 60); |
| flightCtx.beginPath(); |
| flightCtx.strokeStyle = '#58a6ff'; |
| flightCtx.lineWidth = 1.5; |
| let started = false; |
| kbEvents.forEach((k) => { |
| if (k.flight === null) return; |
| const x = ((k.downT - kbRunStart) / duration) * fw; |
| const y = fh - (Math.min(k.flight, maxFlight) / maxFlight) * (fh - 10) - 5; |
| if (!started) { flightCtx.moveTo(x, y); started = true; } else flightCtx.lineTo(x, y); |
| flightCtx.fillRect(x - 1.5, y - 1.5, 3, 3); |
| }); |
| flightCtx.stroke(); |
| |
| |
| dwellCtx.clearRect(0, 0, dwellCanvas.width, dwellCanvas.height); |
| const dw = dwellCanvas.width, dh = dwellCanvas.height; |
| const maxDwell = Math.max(...kbEvents.map(k => k.dwell), 60); |
| dwellCtx.beginPath(); |
| dwellCtx.strokeStyle = '#3fb950'; |
| dwellCtx.lineWidth = 1.5; |
| kbEvents.forEach((k, i) => { |
| const x = ((k.downT - kbRunStart) / duration) * dw; |
| const y = dh - (Math.min(k.dwell, maxDwell) / maxDwell) * (dh - 10) - 5; |
| if (i === 0) dwellCtx.moveTo(x, y); else dwellCtx.lineTo(x, y); |
| dwellCtx.fillRect(x - 1.5, y - 1.5, 3, 3); |
| }); |
| dwellCtx.stroke(); |
| } |
| })(); |
| </script> |
| </body> |
| </html> |
|
|