feat(frontend): useCursorSender hook with letterbox mapping and 50 Hz throttle
This commit is contained in:
61
Frontend/src/hooks/useCursorSender.ts
Normal file
61
Frontend/src/hooks/useCursorSender.ts
Normal 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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user