bots / humanize.html
rectangleworm's picture
Update humanize.html
33887ac verified
Raw
History Blame Contribute Delete
35.2 kB
<!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-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 &nbsp; <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>