feat(frontend): useCursorSender hook with letterbox mapping and 50 Hz throttle

This commit is contained in:
meelstorm
2026-07-17 17:55:35 +00:00
committed by EugeneTes
parent dc35a3b1b4
commit ab5b22b857

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react';
/**
* Convert a viewport point to world coords in [0,1] × [0,1] using
* letterbox mapping (preserves world aspect ratio 1:1).
*/
export function viewportToWorld(
viewportPx: { x: number; y: number },
viewportSize: { w: number; h: number },
): { x: number; y: number } {
// World is 1×1. Fit inside viewport with black bars on the wider axis.
const scale = Math.min(viewportSize.w, viewportSize.h);
const offsetX = (viewportSize.w - scale) / 2;
const offsetY = (viewportSize.h - scale) / 2;
return {
x: Math.max(0, Math.min(1, (viewportPx.x - offsetX) / scale)),
y: Math.max(0, Math.min(1, (viewportPx.y - offsetY) / scale)),
};
}
const SEND_INTERVAL_MS = 20; // 50 Hz cap
export function useCursorSender(
targetRef: React.RefObject<HTMLElement>,
onSend: (x: number, y: number) => void,
) {
const lastCursorRef = useRef<{ x: number; y: number } | null>(null);
const lastSentAtRef = useRef<number>(0);
const rafRef = useRef<number | null>(null);
useEffect(() => {
const el = targetRef.current;
if (!el) return;
const handleMove = (ev: MouseEvent) => {
const rect = el.getBoundingClientRect();
const world = viewportToWorld(
{ x: ev.clientX - rect.left, y: ev.clientY - rect.top },
{ w: rect.width, h: rect.height },
);
lastCursorRef.current = world;
};
const tick = (now: number) => {
const c = lastCursorRef.current;
if (c && now - lastSentAtRef.current >= SEND_INTERVAL_MS) {
onSend(c.x, c.y);
lastSentAtRef.current = now;
}
rafRef.current = requestAnimationFrame(tick);
};
el.addEventListener('mousemove', handleMove);
rafRef.current = requestAnimationFrame(tick);
return () => {
el.removeEventListener('mousemove', handleMove);
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [targetRef, onSend]);
}