/** * 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 }, }; }