193 lines
6.6 KiB
TypeScript
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 },
|
|
};
|
|
}
|