maplibre-camera
Use when implementing map camera animations (flyTo, easeTo, jumpTo), handling zoom transitions, or managing bearing/pitch. Load for useMapCamera composable patterns, preventing camera feedback loops, promise-based animations, and globe visibility filtering. ALWAYS use the composable, never direct map access.
When & Why to Use This Skill
This Claude skill provides a robust framework for managing MapLibre GL camera transitions and state through the useMapCamera composable. It streamlines complex map interactions like flyTo, easeTo, and fitBounds while enforcing architectural best practices to prevent state feedback loops and ensure promise-based animation handling in TypeScript environments.
Use Cases
- Implementing smooth, cinematic 'flyTo' transitions between geographic locations with customizable duration and easing.
- Preventing infinite update loops between application state and map events using programmatic move flags.
- Fitting the map viewport to dynamic bounding boxes (fitBounds) with specific padding for responsive data visualization.
- Optimizing performance on globe projections by filtering marker visibility based on the current hemisphere view.
- Creating custom, high-performance cinematic intro animations using requestAnimationFrame and cubic easing functions.
| name | maplibre-camera |
|---|---|
| description | >- |
MapLibre Camera
Camera control patterns using the useMapCamera composable.
Announce: "I'm using maplibre-camera to implement camera control correctly."
The Iron Rule
NEVER access map.flyTo() directly. ALWAYS use useMapCamera.
// WRONG: Direct map access
const map = inject(MAP_KEY)
map.flyTo({ center: [lng, lat], zoom: 10 })
// CORRECT: Use the composable
const { flyTo } = useMapCamera()
await flyTo({ center: [lng, lat], zoom: 10 })
useMapCamera Composable
Location: src/composables/map/useMapCamera.ts
What It Provides
const {
// State
center, // Ref<{lng, lat}> - current center
zoom, // Ref<number> - current zoom
pitch, // Ref<number> - current pitch (0-85)
bearing, // Ref<number> - current bearing (0-360)
isAnimating, // Ref<boolean> - animation in progress
isLoaded, // Computed<boolean> - map ready
// Methods (all return Promise<void>)
flyTo, // Smooth arc animation
easeTo, // Linear interpolation
jumpTo, // Instant move
fitBounds, // Fit to bounding box
// Lifecycle
cleanup // Remove event listeners
} = useMapCamera(options)
Options
interface UseMapCameraOptions {
initialCenter?: [number, number] // Default: [0, 20]
initialZoom?: number // Default: 2
initialPitch?: number // Default: 0
initialBearing?: number // Default: 0
syncFromMap?: boolean // Default: true - sync state from map events
}
Animation Methods
flyTo - Dramatic Arc Animation
await flyTo({
center: [2.3522, 48.8566], // Paris
zoom: 12,
pitch: 45,
bearing: 30,
duration: 3000 // 3 seconds
})
// Promise resolves when animation completes
Use for: Dramatic camera moves, showing user a new location
easeTo - Linear Interpolation
await easeTo({
center: [2.3522, 48.8566],
zoom: 12,
duration: 1000
})
Use for: Quick, responsive moves (faster than flyTo)
jumpTo - Instant Move
jumpTo({
center: [2.3522, 48.8566],
zoom: 12
})
// No promise - instant
Use for: Initial positioning, resetting view
fitBounds - Fit to Bounding Box
await fitBounds(
[[minLng, minLat], [maxLng, maxLat]],
{ padding: 50, maxZoom: 15 }
)
Use for: Showing all candidates, fitting to region
Feedback Loop Prevention
The composable prevents infinite loops between state and map:
// Inside useMapCamera
let isProgrammaticMove = false
function flyTo(options) {
isProgrammaticMove = true // Flag: we initiated this
map.flyTo(options)
map.once('moveend', () => {
isProgrammaticMove = false
})
}
// Map event listener
map.on('move', () => {
if (!isProgrammaticMove) {
// Only sync state if user moved the map
center.value = map.getCenter()
}
})
This prevents: Component → updates state → triggers watcher → calls map method → triggers event → updates state → ...
Globe Visibility Filtering
For globe projection, filter markers to visible hemisphere:
// src/composables/map/useGlobeVisibility.ts
export function isVisibleOnGlobe(
pointLng: number,
pointLat: number,
centerLng: number,
centerLat: number
): boolean {
const toRad = (d: number) => d * Math.PI / 180
const lat1 = toRad(pointLat)
const lat2 = toRad(centerLat)
const dLng = toRad(pointLng - centerLng)
// Cosine of angle between points on sphere
const cosAngle =
Math.sin(lat1) * Math.sin(lat2) +
Math.cos(lat1) * Math.cos(lat2) * Math.cos(dLng)
// Visible if on front hemisphere (with small buffer)
return cosAngle > -0.1
}
Use with computed to filter candidates:
const visibleCandidates = computed(() =>
candidates.value.filter(c =>
isVisibleOnGlobe(c.lng, c.lat, center.value.lng, center.value.lat)
)
)
Cinematic Intro Animation
For custom animations beyond built-in methods:
// src/composables/map/useCinematicIntro.ts
export function useCinematicIntro() {
async function animate(
map: MapLibreMap,
target: { lng: number, lat: number },
options: { duration?: number, signal?: AbortSignal }
): Promise<void> {
return new Promise((resolve) => {
const startTime = performance.now()
let rafId: number
function frame(currentTime: number) {
if (options.signal?.aborted) {
cancelAnimationFrame(rafId)
resolve()
return
}
const progress = (currentTime - startTime) / (options.duration || 3000)
const eased = easeOutCubic(Math.min(progress, 1))
// Interpolate camera
map.jumpTo({
center: lerp(startCenter, target, eased),
zoom: lerp(startZoom, targetZoom, eased)
})
if (progress < 1) {
rafId = requestAnimationFrame(frame)
} else {
resolve()
}
}
rafId = requestAnimationFrame(frame)
})
}
return { animate }
}
Anti-Patterns
DON'T: Access Map Directly
// WRONG
const map = inject(MAP_KEY)
map.flyTo({ center })
// CORRECT
const { flyTo } = useMapCamera()
await flyTo({ center })
DON'T: Forget to Await Animations
// WRONG: May cause race conditions
flyTo({ center: a })
flyTo({ center: b }) // Interrupts first animation!
// CORRECT: Await each animation
await flyTo({ center: a })
await flyTo({ center: b })
DON'T: Check Map Before isLoaded
// WRONG: Map may not be ready
onMounted(() => {
flyTo({ center }) // May fail!
})
// CORRECT: Wait for map
watch(isLoaded, (loaded) => {
if (loaded) flyTo({ center })
})
References
See references/camera-examples.md for more animation patterns.