Download humanize.html from iuseonly/bots: direct link, hf CLI and curl.
- Browser
- Download file 35.2 kB
-
https://huggingface.co/spaces/iuseonly/bots/resolve/main/humanize.html
- Command line
-
hf download hf://spaces/iuseonly/bots/humanize.html
-
curl -L -o humanize.html https://huggingface.co/spaces/iuseonly/bots/resolve/main/humanize.html
35.2 kB
| <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-left) ---- */ | |
| #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 humanize panel (top-center) ---- */ | |
| #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-right) ---- */ | |
| #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; | |
| } | |
| /* ---- graphs (bottom-left) ---- */ | |
| #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 (bottom-right) ---- */ | |
| #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 ---- */ | |
| .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(); | |
| // --------------------------------------------------------------- | |
| // State | |
| // --------------------------------------------------------------- | |
| const IDLE_GAP_MS = 300; // gap that separates two distinct "runs" | |
| const MAX_RUNS_KEPT = 30; | |
| const FADE_MS = 25000; // slow fade so a screenshot has time to catch the full trail | |
| let runs = []; | |
| let currentRun = null; | |
| let lastPoint = null; | |
| let runIdCounter = 1; | |
| let globalMaxSpeed = 0; | |
| let selectedRun = null; // run picked from history to inspect | |
| let fadeEnabled = false; // off by default: trails stay on screen until cleared | |
| let markersEnabled = true; | |
| let refLineEnabled = true; | |
| const targetClicks = {}; // id -> [{dx,dy,halfW,halfH}] | |
| 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); | |
| // --------------------------------------------------------------- | |
| // Targets | |
| // --------------------------------------------------------------- | |
| 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(); | |
| // --------------------------------------------------------------- | |
| // Speed -> color | |
| // --------------------------------------------------------------- | |
| function getSpeedColor(speed, max, alpha) { | |
| const normalized = Math.min(speed / (max || 2.5), 1); | |
| const hue = (1 - normalized) * 240; // blue (slow) -> red (fast) | |
| return `hsla(${hue}, 100%, 50%, ${alpha})`; | |
| } | |
| // --------------------------------------------------------------- | |
| // Run management | |
| // --------------------------------------------------------------- | |
| 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; | |
| // angular change relative to the previous segment (0 = straight line) | |
| 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 }; | |
| } | |
| // --------------------------------------------------------------- | |
| // Mouse tracking | |
| // --------------------------------------------------------------- | |
| 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; // live-follow the active run | |
| renderHistoryList(); | |
| updateStatsPanel(currentRun); | |
| drawKinematicsGraphs(currentRun); | |
| }); | |
| // --------------------------------------------------------------- | |
| // Click tracking (offset from target center) | |
| // --------------------------------------------------------------- | |
| 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)})`; | |
| } | |
| // attach to the run that this click belongs to (same idle-gap logic) | |
| if (currentRun && (now - currentRun.endTime) < IDLE_GAP_MS) { | |
| currentRun.click = clickInfo; | |
| } else { | |
| // click with no preceding movement burst -> its own zero-length run | |
| const p = { x: clickX, y: clickY, t: now, speed: 0, turn: 0, dt: 0 }; | |
| startNewRun(p); | |
| currentRun.click = clickInfo; | |
| } | |
| renderHistoryList(); | |
| drawKinematicsGraphs(currentRun); | |
| }); | |
| // --------------------------------------------------------------- | |
| // Per-target click heatmap | |
| // --------------------------------------------------------------- | |
| 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); // -1..1 roughly | |
| 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(); | |
| }); | |
| } | |
| // --------------------------------------------------------------- | |
| // Trajectory canvas (persistent rAF loop -> allows trail fade) | |
| // --------------------------------------------------------------- | |
| 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(); | |
| } | |
| } | |
| // reference straight line start -> end, to expose curvature at a glance | |
| 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); | |
| // --------------------------------------------------------------- | |
| // Kinematics graphs (speed & direction-change vs. real elapsed time) | |
| // --------------------------------------------------------------- | |
| function drawKinematicsGraphs(run) { | |
| if (!run || run.points.length < 2) return; | |
| const pts = run.points; | |
| const duration = Math.max(run.endTime - run.startTime, 40); | |
| // -- speed graph -- | |
| 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([]); | |
| } | |
| // -- direction-change graph -- | |
| 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(); | |
| } | |
| // --------------------------------------------------------------- | |
| // Stats panel | |
| // --------------------------------------------------------------- | |
| 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'; | |
| } | |
| // --------------------------------------------------------------- | |
| // History list | |
| // --------------------------------------------------------------- | |
| 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); | |
| } | |
| } | |
| // --------------------------------------------------------------- | |
| // Controls: clear / export | |
| // --------------------------------------------------------------- | |
| 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(); | |
| // --------------------------------------------------------------- | |
| // Keyboard tracking: dwell (keydown->keyup) & flight (gap before a key) | |
| // --------------------------------------------------------------- | |
| 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; // pause that starts a fresh typing "run" | |
| let kbEvents = []; // events of the current typing run | |
| let kbRunStart = null; | |
| const pendingKeyDowns = new Map(); // e.code -> {t, key} | |
| 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); | |
| // flight time: gap before each keystroke -> the typing rhythm | |
| 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(); | |
| // dwell time: how long each key was physically held down | |
| 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> | |