maplibre-camera

discountedcookie's avatarfrom discountedcookie

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.

⭐0stars🔀0forks📁View on GitHub🕐Updated Dec 15, 2025

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.
namemaplibre-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.