MotorSignal-App/website/lib/telemetry-camera-follow.ts

193 lines
6.6 KiB
TypeScript

/**
* Telemetry Camera Follow Engine
*
* Provides smooth, continuous camera tracking for motorcycle telemetry replay.
* Features:
* - Dead zone threshold: small central radius where minor vehicle movements / GPS jitter
* do not cause camera movement, preventing motion sickness and camera oscillation.
* - Smooth exponential damping (lerp): camera smoothly accelerates, follows at vehicle speed,
* and decelerates to a gentle stop without overshoot.
* - Tile-protection clamping: limits maximum camera velocity per frame to prevent the camera
* from outrunning tile rasterization on mobile networks.
* - Leaflet integration: works directly with container pixel coordinates and falls back
* cleanly to LatLng interpolation when container projection is unavailable.
*/
export interface CameraFollowOptions {
/** Dead zone radius in screen pixels around map center (default: 32px) */
deadZoneRadiusPx?: number;
/** Maximum camera pan displacement in pixels per frame to prevent outrunning tiles (default: 12px) */
maxStepPxPerFrame?: number;
/** Damping coefficient for exponential smoothing (default: 4.5) */
dampingLambda?: number;
/** Minimum pixel movement threshold to avoid sub-pixel jitter (default: 0.5px) */
minPanThresholdPx?: number;
}
export interface CameraFollowResult {
/** Whether the map camera should be panned this frame */
shouldPan: boolean;
/** The calculated target center coordinates */
targetLatLng: { lat: number; lng: number };
/** Distance from motorcycle marker to map center in pixels */
distanceFromCenterPx: number;
/** Excess distance beyond dead zone */
excessPx: number;
/** Calculated screen pan delta in pixels */
panDeltaPx: { x: number; y: number };
}
/**
* Computes the next smooth camera pan step towards the motorcycle marker position.
*
* @param map Leaflet map instance (or mock)
* @param markerLatLng Current interpolated motorcycle coordinates { lat, lng }
* @param frameDeltaMs Time elapsed since previous animation frame in milliseconds
* @param options Tunable camera parameters
*/
export function computeCameraFollowStep(
map: any,
markerLatLng: { lat: number; lng: number },
frameDeltaMs = 16.67,
options: CameraFollowOptions = {}
): CameraFollowResult {
if (!map || !Number.isFinite(markerLatLng?.lat) || !Number.isFinite(markerLatLng?.lng)) {
return {
shouldPan: false,
targetLatLng: { lat: markerLatLng?.lat || 0, lng: markerLatLng?.lng || 0 },
distanceFromCenterPx: 0,
excessPx: 0,
panDeltaPx: { x: 0, y: 0 },
};
}
const deadZone = options.deadZoneRadiusPx ?? 32;
const maxStep = options.maxStepPxPerFrame ?? 12;
const lambda = options.dampingLambda ?? 4.5;
const minThreshold = options.minPanThresholdPx ?? 0.5;
// Retrieve current map center
let currentCenter = { lat: markerLatLng.lat, lng: markerLatLng.lng };
if (typeof map.getCenter === 'function') {
const c = map.getCenter();
if (c && Number.isFinite(c.lat) && Number.isFinite(c.lng)) {
currentCenter = { lat: c.lat, lng: c.lng };
}
}
// Frame-rate independent exponential smoothing factor: 1 - e^(-lambda * dt)
const dt = Math.max(0.001, Math.min(frameDeltaMs, 100)) / 1000;
const smoothFactor = 1 - Math.exp(-lambda * dt);
// Preferred Path: Screen pixel projection using Leaflet container points
if (
typeof map.latLngToContainerPoint === 'function' &&
typeof map.containerPointToLatLng === 'function'
) {
try {
const size = typeof map.getSize === 'function' ? map.getSize() : { x: 600, y: 400 };
const centerPx = { x: (size?.x || 600) / 2, y: (size?.y || 400) / 2 };
const markerPx = map.latLngToContainerPoint([markerLatLng.lat, markerLatLng.lng]);
if (
markerPx &&
Number.isFinite(markerPx.x) &&
Number.isFinite(markerPx.y)
) {
const dx = markerPx.x - centerPx.x;
const dy = markerPx.y - centerPx.y;
const dist = Math.hypot(dx, dy);
// Within dead zone: camera remains stationary to avoid micro-jitter
if (dist <= deadZone) {
return {
shouldPan: false,
targetLatLng: currentCenter,
distanceFromCenterPx: dist,
excessPx: 0,
panDeltaPx: { x: 0, y: 0 },
};
}
const excess = dist - deadZone;
let stepPx = excess * smoothFactor;
// Tile protection clamp: prevents camera from outrunning tile download / rendering
if (stepPx > maxStep) {
stepPx = maxStep;
}
if (stepPx < minThreshold) {
return {
shouldPan: false,
targetLatLng: currentCenter,
distanceFromCenterPx: dist,
excessPx: excess,
panDeltaPx: { x: 0, y: 0 },
};
}
const unitX = dx / dist;
const unitY = dy / dist;
const panX = unitX * stepPx;
const panY = unitY * stepPx;
const newCenterPx = {
x: centerPx.x + panX,
y: centerPx.y + panY,
};
const targetLatLngRaw = map.containerPointToLatLng(newCenterPx);
if (
targetLatLngRaw &&
Number.isFinite(targetLatLngRaw.lat) &&
Number.isFinite(targetLatLngRaw.lng)
) {
return {
shouldPan: true,
targetLatLng: { lat: targetLatLngRaw.lat, lng: targetLatLngRaw.lng },
distanceFromCenterPx: dist,
excessPx: excess,
panDeltaPx: { x: panX, y: panY },
};
}
}
} catch {
// Fall through to LatLng degree interpolation if container conversion fails
}
}
// Fallback Path: Direct LatLng degree interpolation (e.g. test environments without container geometry)
const dLat = markerLatLng.lat - currentCenter.lat;
const dLng = markerLatLng.lng - currentCenter.lng;
const distDeg = Math.hypot(dLat, dLng);
// Approximate dead zone in degrees (~0.0003 deg is ~30m at mid-latitudes)
const deadZoneDeg = 0.0003;
if (distDeg <= deadZoneDeg) {
return {
shouldPan: false,
targetLatLng: currentCenter,
distanceFromCenterPx: 0,
excessPx: 0,
panDeltaPx: { x: 0, y: 0 },
};
}
const excessDeg = distDeg - deadZoneDeg;
const stepDeg = excessDeg * smoothFactor;
const unitLat = dLat / distDeg;
const unitLng = dLng / distDeg;
return {
shouldPan: true,
targetLatLng: {
lat: currentCenter.lat + unitLat * stepDeg,
lng: currentCenter.lng + unitLng * stepDeg,
},
distanceFromCenterPx: distDeg * 100000,
excessPx: excessDeg * 100000,
panDeltaPx: { x: unitLng * stepDeg * 100000, y: unitLat * stepDeg * 100000 },
};
}