Leg-Aware Camera Movement in Google Timeline Visualizer: How It Works Under the Hood
Leg-aware camera movement detects transport transfers (e.g., walking to train) and dynamically adjusts zoom and padding to preserve context during mode changes, creating smoother journey animations.
The Google Timeline Visualizer animates your Google Maps Timeline data with intelligent camera behavior that adapts to how you actually traveled. Unlike simple map following, its leg-aware dynamic following mode recognizes when you switch between transport methods and adjusts the viewport accordingly. This article explains exactly how this system works, directly from the mahlernim/google-timeline-visualizer source code.
What Is Leg-Aware Camera Movement?
A leg in this context is a continuous travel segment between two transfers. A transfer occurs when there's a significant distance gap—calculated using a dynamic transferThreshold based on your journey's scale (camera.ts lines 19-28).
The visualizer offers three camera modes defined in CameraMovementProfile:
- Fixed zoom – Static camera, no following
- Steady following – Smooth tracking without leg awareness
- Dynamic following – Leg-aware mode with adaptive context and padding (
camera.tslines 59-68)
Only dynamic following sets legAware: true, enabling the transfer-detection logic that produces more natural animations.
How Leg Detection Works
The system builds a leg structure before generating any camera frames.
Step 1: Calculate Transfer Threshold
// Simplified from camera.ts lines 19-28
const totalKm = journey.worldPoints[journey.worldPoints.length - 1].distance;
const transferThreshold = totalKm * TRANSFER_THRESHOLD_FACTOR + MIN_TRANSFER_KM;
This ensures transfer detection scales appropriately whether your trip spans 5 kilometers or 500.
Step 2: Build Leg Segments
The buildLegs function (camera.ts lines 31-48) scans your journey and splits it into legs whenever consecutive points exceed transferThreshold. Each leg stores:
startKmandendKm— distance boundsisTransfer— true if this leg represents a gap (wait time, mode change)
Step 3: Find Active Leg at Any Position
// camera.ts lines 50-58 — the legAt function
function legAt(legs: Leg[], distanceKm: number): Leg {
return legs.find(l => l.startKm <= distanceKm && l.endKm > distanceKm)!;
}
This lookup runs for every sampled frame during track generation.
Adjusting Viewport Context for Transfers
When legAware is enabled, rawViewport (camera.ts lines 21-27) modifies its behavior:
| Condition | Context Used | Padding Used |
|---|---|---|
leg.isTransfer === true |
Exact leg length (endKm - startKm) |
TRANSFER_PADDING (larger) |
| Normal movement | proportionalContextKm from profile |
Standard padding |
This difference is subtle but critical. During a transfer—say, arriving at a train station and boarding—the camera zooms out to show more surrounding geography, holds that broader view for the duration of the wait/travel gap, then resumes tighter following once continuous movement resumes.
Generating the Camera Track
The buildCameraTrack function (camera.ts lines 78-85) samples your journey 480 times by default. For each sample:
- Calculate progress (0.0 to 1.0)
- Map to distance along journey
- Call
legAtto find current leg - Call
rawViewportwith leg awareness - Store resulting viewport in track array
The resulting CameraTrack is a pre-computed animation path that can be queried efficiently during rendering.
Rendering and UI Integration
The movement mode is selected via a standard HTML <select> (index.html lines 129-133):
<select id="camera-movement">
<option value="fixed">Fixed zoom</option>
<option value="steady">Steady following</option>
<option value="dynamic" selected>Dynamic following</option>
</select>
Each frame, the renderer (renderer.ts lines 6-9) queries the computed track:
import { buildCameraTrack, cameraViewportAt } from './camera';
import type { Journey, RenderSize } from './types';
const size: RenderSize = { width: 1080, height: 1920 };
const cameraTrack = buildCameraTrack(journey, size, 'dynamic'); // leg-aware
const progress = 0.42; // 42% through trip
const viewport = cameraViewportAt(cameraTrack, progress);
// viewport: { minX, maxX, minY, maxY, zoom }
Why Leg-Aware Movement Matters
Without leg awareness, the camera treats a 2-hour flight the same as 2 hours of walking—either both cramped or both zoomed too far out. The leg-aware approach delivers:
- Context preservation — Airports, stations, and transfer points remain visible
- Natural pacing — The "camera" behaves like a human narrator, pausing appropriately
- Multi-modal clarity — Clear visual separation between walking, driving, and transit segments
Summary
- Three camera modes exist, but only dynamic following is leg-aware (
camera.tslines 59-68) - Legs and transfers are detected via
buildLegsusing a scaledtransferThreshold(lines 19-28, 31-48) - Viewport calculation adapts context and padding when
leg.isTransferis true (rawViewport, lines 21-27) - Pre-computed tracks with 480 samples enable smooth 60fps playback (
buildCameraTrack, lines 78-85) - UI selection in
index.htmlpropagates throughmain.tsto trigger leg-aware rendering
Frequently Asked Questions
What triggers a "transfer" in the leg-aware system?
A transfer is detected when the distance between two consecutive Timeline points exceeds the transferThreshold, calculated as totalJourneyKm * FACTOR + MIN_TRANSFER_KM. This scales with trip length so short walks and long-haul flights are both handled appropriately (camera.ts lines 19-28).
Can I use leg-aware movement without the dynamic following mode?
No. Leg awareness is hardcoded to the dynamic camera profile via legAware: true in its CameraMovementProfile. The fixed and steady modes set this flag to false, disabling leg detection entirely (camera.ts lines 59-68).
How does the camera behave differently during a transfer?
During transfers, the camera uses the exact leg length as its viewing context and applies larger TRANSFER_PADDING. This produces a zoomed-out, sustained view of the transfer location—useful for showing airport terminals, train stations, or extended waits—rather than treating the gap as rapid movement (camera.ts lines 21-27).
Where is the camera track actually used for rendering?
The renderer.ts module calls cameraViewportAt each animation frame, passing the current playback progress to retrieve the pre-calculated viewport. This viewport's bounds and zoom level then drive tile fetching and map composition (renderer.ts lines 6-9).
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →