#!/usr/bin/env node /** * Plot the virtual cursor path used in codex-rs/browser/src/page.rs. * * It mirrors the current algorithm: distance-based duration, optional curved midpoint, * rotation-driven overshoot timing (translation holds final after ~82%), and easing. * * Usage: * node scripts/plot_cursor_path.js --from 100,100 --to 900,520 --out /tmp/cursor_path.svg * node scripts/plot_cursor_path.js --from 200,300 --to 400,350 --no-curve --pxPerSec 120 --min 600 --max 4200 */ const fs = require('fs'); function parsePoint(s) { const [x, y] = String(s).split(',').map(Number); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`Invalid point: ${s}`); return { x, y }; } function parseArgs(argv) { const args = { from: { x: 120, y: 90 }, to: { x: 860, y: 520 }, out: 'cursor_path.svg', fps: 60, // Mirror MOTION constants from page.rs (current tuning) pxPerSec: 120, min: 600, max: 4200, easing: 'cubic-bezier(0.18, 0.9, 0.18, 1)', arrowScale: 1.50, badgeScale: 1.70, badgeDelay: 40, jitter: 0, rotateMaxDeg: 16, arrowTilt: 0.85, badgeTilt: 0.60, overshootDeg: 4.0, arrowOvershootScale: 0.5, badgeOvershootScale: 0.85, overshootAt: 0.7, curveFactor: 0.25, curveMaxPx: 70, curveAlternate: true, tipX: 0, tipY: 0, badgeOffX: 12, badgeOffY: 4, noCurve: false, }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; const next = argv[i + 1]; const setNum = (k, v) => { if (v === undefined) throw new Error(`Missing value for ${k}`); args[k] = Number(v); i++; }; switch (a) { case '--from': args.from = parsePoint(next); i++; break; case '--to': args.to = parsePoint(next); i++; break; case '--out': args.out = next; i++; break; case '--fps': setNum('fps', next); break; case '--pxPerSec': setNum('pxPerSec', next); break; case '--min': setNum('min', next); break; case '--max': setNum('max', next); break; case '--no-curve': args.noCurve = true; break; default: if (a.startsWith('--')) { const k = a.slice(2); if (k in args) { const val = next; if (val === undefined) throw new Error(`Missing value for ${a}`); const n = Number(val); args[k] = Number.isFinite(n) ? n : val; i++; } } } } return args; } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function dist(x0, y0, x1, y1) { const dx = x1 - x0, dy = y1 - y0; return Math.hypot(dx, dy); } function lerp(a, b, t) { return a + (b - a) * t; } function lerp2(a, b, t) { return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) }; } // Evaluate cubic-bezier easing at t; return y for x=t mapping function cubicBezierAt(p1x, p1y, p2x, p2y, t) { // Borrowed from https://github.com/gre/bezier-easing (MIT) inlined minimal. function A(aA1, aA2) { return 1.0 - 3.0 * aA2 + 3.0 * aA1; } function B(aA1, aA2) { return 3.0 * aA2 - 6.0 * aA1; } function C(aA1) { return 3.0 * aA1; } function calcBezier(aT, aA1, aA2) { return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT; } function getSlope(aT, aA1, aA2) { return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1); } function getTforX(aX) { // Newton-Raphson let aT = aX; for (let i = 0; i < 4; i++) { const slope = getSlope(aT, p1x, p2x); if (slope === 0) return aT; const x = calcBezier(aT, p1x, p2x) - aX; aT -= x / slope; } return aT; } const x = getTforX(t); const y = calcBezier(x, p1y, p2y); return y; } function durationForDistance(d, motion) { let ms = (d / Math.max(1, motion.pxPerSec)) * 1000; if (motion.jitter > 0) { const j = (Math.random() * 2 - 1) * motion.jitter; ms = ms * (1 + j); } ms = Math.min(motion.max, Math.max(motion.min, ms)); return Math.round(ms); } function computePath(args) { const o = { ...args }; const ax0 = Math.round(args.from.x - o.tipX); const ay0 = Math.round(args.from.y - o.tipY); const ax1 = Math.round(args.to.x - o.tipX); const ay1 = Math.round(args.to.y - o.tipY); const bx0 = ax0 + o.badgeOffX, by0 = ay0 + o.badgeOffY; const bx1 = ax1 + o.badgeOffX, by1 = ay1 + o.badgeOffY; const d = dist(ax0, ay0, ax1, ay1); const base = durationForDistance(d, o); const aDur = Math.round(base * o.arrowScale); const bDur = Math.round(base * o.badgeScale); // Curve midpoint let mx = (ax0 + ax1) / 2; let my = (ay0 + ay1) / 2; if (!o.noCurve) { const dx = ax1 - ax0, dy = ay1 - ay0; const len = Math.hypot(dx, dy) || 1; const nqx = -dy / len, nqy = dx / len; const curveMag = Math.min(o.curveMaxPx || 0, Math.max(0, d * (o.curveFactor || 0))); const curveSign = 1; // stateless plot (no alternation) mx = Math.round(mx + nqx * curveMag * curveSign); my = Math.round(my + nqy * curveMag * curveSign); } const offsets = [0.0, 0.5, 0.82, 0.90, 1.0]; for (let i = 1; i < offsets.length; i++) { if (!(offsets[i] > offsets[i - 1])) throw new Error('Non-increasing keyframe offsets'); } const pts = [ {x: ax0, y: ay0}, {x: mx, y: my}, {x: ax1, y: ay1} ]; const easing = o.easing; const m = easing.match(/cubic-bezier\(([^)]+)\)/); const bez = m ? m[1].split(',').map(Number).map(v => clamp(v, -10, 10)) : [0.25, 1, 0.5, 1]; const samples = []; const totalMs = aDur; const step = Math.max(1, Math.round(1000 / o.fps)); for (let tms = 0; tms <= totalMs; tms += step) { const t = tms / totalMs; // 0..1 let segStart = 0, segEnd = 0.5, A = pts[0], B = pts[1]; if (t >= 0.5 && t < 0.82) { segStart = 0.5; segEnd = 0.82; A = pts[1]; B = pts[2]; } else if (t >= 0.82) { segStart = 0.82; segEnd = 1.0; A = pts[2]; B = pts[2]; } const local = (t - segStart) / Math.max(1e-6, (segEnd - segStart)); const eased = cubicBezierAt(bez[0], bez[1], bez[2], bez[3], clamp(local, 0, 1)); const P = lerp2(A, B, eased); samples.push({ tms, x: P.x, y: P.y }); } const fallback = []; for (let tms = 0; tms <= totalMs; tms += step) { const t = tms / totalMs; const eased = cubicBezierAt(0.25, 1, 0.5, 1, t); const P = lerp2({x: ax0, y: ay0}, {x: ax1, y: ay1}, eased); fallback.push({ tms, x: P.x, y: P.y }); } return { aDur, bDur, pts: { start: {x: ax0, y: ay0}, mid: {x: mx, y: my}, end: {x: ax1, y: ay1} }, samples, fallback }; } function toSVG(data, width, height, margin = 20) { // Fit points into the viewport const xs = data.samples.map(p => p.x).concat([data.pts.start.x, data.pts.mid.x, data.pts.end.x]); const ys = data.samples.map(p => p.y).concat([data.pts.start.y, data.pts.mid.y, data.pts.end.y]); const minX = Math.min(...xs), maxX = Math.max(...xs); const minY = Math.min(...ys), maxY = Math.max(...ys); const spanX = Math.max(1, maxX - minX); const spanY = Math.max(1, maxY - minY); const sx = (x) => margin + (x - minX) * (width - 2*margin) / spanX; const sy = (y) => margin + (y - minY) * (height - 2*margin) / spanY; const path = data.samples.map((p, i) => `${i===0?'M':'L'} ${sx(p.x).toFixed(1)} ${sy(p.y).toFixed(1)}`).join(' '); const fbPath = data.fallback.map((p, i) => `${i===0?'M':'L'} ${sx(p.x).toFixed(1)} ${sy(p.y).toFixed(1)}`).join(' '); const dots = [data.pts.start, data.pts.mid, data.pts.end] .map((p, i) => ``).join('\n '); return ` ${Array.from({length: 10}).map((_,i)=>``).join('\n ')} ${dots} curved (blue) vs fallback (gray), duration: ${data.aDur}ms `; } function main() { const args = parseArgs(process.argv); const data = computePath(args); const svg = toSVG(data, 900, 480); fs.writeFileSync(args.out, svg, 'utf8'); console.log(`Wrote ${args.out}`); console.log(`Duration (arrow): ${data.aDur} ms | samples: ${data.samples.length}`); console.log('Key points:', data.pts); console.log('First 5 samples:', data.samples.slice(0,5)); } if (require.main === module) { try { main(); } catch (e) { console.error('Error:', e.message); process.exit(1); } }