Features

Maps Integration

Interactive maps with Mapbox GL JS for location-based features
Status: Beta - This package is in beta and under active development. APIs may change in future releases. Use in production with caution and be prepared for potential breaking changes.

The @friendlyinternet/nuxt-crouton-maps package provides seamless map integration for your Nuxt Crouton applications using Mapbox GL JS. Add interactive maps, geocoding, and location-based features to your app.

Package Information

{
  "name": "@friendlyinternet/nuxt-crouton-maps",
  "version": "0.3.0",
  "status": "BETA"
}

Installation

Install the beta package:

pnpm add @friendlyinternet/nuxt-crouton-maps
Dependencies: This package uses nuxt-mapbox (v1.6.4+) and mapbox-gl (v3.0.0+) under the hood.

Setup

1. Get a Mapbox Access Token

  1. Sign up for a free Mapbox account: https://account.mapbox.com/
  2. Get your access token: https://account.mapbox.com/access-tokens/
  3. Free tier includes:
    • 50,000 map loads/month
    • 100,000 geocoding requests/month

2. Configure Your Project

Add the layer to your nuxt.config.ts:

export default defineNuxtConfig({
  extends: [
    '@friendlyinternet/nuxt-crouton',
    '@friendlyinternet/nuxt-crouton-maps'  // Add maps layer
  ],

  runtimeConfig: {
    public: {
      mapbox: {
        accessToken: process.env.MAPBOX_TOKEN,
        // Optional defaults
        style: 'mapbox://styles/mapbox/streets-v12',
        center: [-122.4194, 37.7749], // [lng, lat]
        zoom: 12
      }
    }
  }
})

3. Add Environment Variable

Create or update your .env file:

MAPBOX_TOKEN=pk.eyJ1IjoieW91ciIsImEiOiJ0b2tlbiJ9...
Security: Never commit your .env file to version control. Add it to your .gitignore.

Components

CroutonMapsMap

Interactive map component with loading states and error handling.

Props

PropTypeDefaultDescription
idstringauto-generatedMap container ID
center[number, number]From configInitial center coordinates [lng, lat]
zoomnumber12Initial zoom level (0-22)
stylestringstreets-v12Mapbox style URL or preset
heightstring'400px'Container height (CSS value)
widthstring'100%'Container width (CSS value)
classstring-Additional CSS classes
flyToOnCenterChangebooleanfalseAnimate center changes
flyToDurationnumber800Animation duration (ms)
flyToEasingfunctioneaseInOutCubicEasing function

Events

EventPayloadDescription
@loadmap: MapMap instance loaded and ready
@errorerror: stringMap failed to load

Slots

SlotPropsDescription
default{ map: Map }Scoped slot for markers/popups

Example

<script setup lang="ts">
const center = ref<[number, number]>([-122.4194, 37.7749])
const zoom = ref(12)

const handleMapLoad = (map) => {
  console.log('Map loaded:', map)
}
</script>

<template>
  <CroutonMapsMap
    :center="center"
    :zoom="zoom"
    height="500px"
    @load="handleMapLoad"
  >
    <template #default="{ map }">
      <!-- Add markers here -->
    </template>
  </CroutonMapsMap>
</template>

CroutonMapsMarker

Map marker with optional popup and smooth animations.

Props

PropTypeRequiredDescription
mapMapMap instance from parent
position[number, number]Marker position [lng, lat]
colorstring-Marker color (hex/rgb/named)
optionsMarkerOptions-Mapbox marker options
popupContentstring-HTML content for popup
animateTransitionsbooleantrueSmooth position animation
animationDurationnumber800Animation duration (ms)
animationEasingstring | function'easeInOutCubic'Easing preset or function

Events

EventDescription
@clickMarker clicked
@dragStartDrag started (requires draggable: true)
@dragDragging in progress
@dragEndDrag ended

Example

<template>
  <CroutonMapsMap :center="center" :zoom="12" height="500px">
    <template #default="{ map }">
      <CroutonMapsMarker
        :map="map"
        :position="[-122.4194, 37.7749]"
        color="#ef4444"
        popup-content="<h3>San Francisco</h3>"
        @click="handleMarkerClick"
      />
    </template>
  </CroutonMapsMap>
</template>

Draggable Markers

<script setup lang="ts">
const markerPosition = ref<[number, number]>([-122.4194, 37.7749])

const handleDragEnd = () => {
  console.log('New position:', markerPosition.value)
}
</script>

<template>
  <CroutonMapsMarker
    :map="map"
    :position="markerPosition"
    :options="{ draggable: true }"
    @dragEnd="handleDragEnd"
  />
</template>

CroutonMapsPopup

Standalone popup component for custom content.

Props

PropTypeDefaultDescription
mapMap-Map instance (required)
position[number, number]-Popup position (required)
optionsPopupOptions-Mapbox popup options
closeButtonbooleantrueShow close button
closeOnClickbooleantrueClose on map click
maxWidthstring'240px'Maximum width

Events

EventDescription
@openPopup opened
@closePopup closed

Slots

SlotDescription
defaultPopup content (supports Vue components)

Example

<template>
  <CroutonMapsMap :center="center" :zoom="12" height="500px">
    <template #default="{ map }">
      <CroutonMapsPopup
        :map="map"
        :position="[-122.4194, 37.7749]"
        max-width="300px"
      >
        <div class="p-4">
          <h3 class="font-bold">Custom Popup</h3>
          <p>Vue components work here!</p>
          <UButton size="xs">Action</UButton>
        </div>
      </CroutonMapsPopup>
    </template>
  </CroutonMapsMap>
</template>

CroutonMapsPreview

Compact map preview with modal expansion - perfect for forms and cards.

Props

PropTypeDescription
locationstring | [number, number]Location as coordinates or JSON string

Example

<script setup lang="ts">
const location = ref<[number, number]>([-122.4194, 37.7749])
// Or from a collection field
// const location = ref(item.coordinates) // "[lon, lat]" string
</script>

<template>
  <div class="space-y-4">
    <!-- Compact preview with click-to-expand -->
    <CroutonMapsPreview :location="location" />
  </div>
</template>
Use Case: Perfect for displaying locations in collection items, form previews, or dashboards. Shows a small map preview with an expand button for full-screen viewing.

Composables

useMap()

Core composable for managing map instances directly.

const {
  map,           // Computed<Map | null>
  isLoaded,      // Readonly<Ref<boolean>>
  error,         // Readonly<Ref<string | null>>
  container,     // Readonly<Ref<HTMLElement | null>>
  initialize,    // (options: UseMapOptions) => Promise<void>
  destroy,       // () => void
  resize         // () => void
} = useMap()

Example

<script setup lang="ts">
const { map, isLoaded, error, initialize } = useMap()

onMounted(async () => {
  await initialize({
    container: 'my-map',
    center: [-122.4194, 37.7749],
    zoom: 12
  })

  // Access map instance directly
  if (map.value) {
    map.value.on('click', (e) => {
      console.log('Clicked at:', e.lngLat)
    })
  }
})
</script>

<template>
  <div id="my-map" style="height: 500px;" />
</template>

useMapConfig()

Access runtime configuration for Mapbox.

const {
  accessToken,  // string
  style,        // string
  center,       // [number, number] | undefined
  zoom          // number
} = useMapConfig()

Example

<script setup lang="ts">
const config = useMapConfig()

console.log('Using style:', config.style)
console.log('Default center:', config.center)
</script>

useGeocode()

Forward and reverse geocoding with Mapbox API.

const {
  geocode,          // (query: string) => Promise<GeocodeResult | null>
  reverseGeocode,   // (coords: [number, number]) => Promise<GeocodeResult | null>
  loading,          // Readonly<Ref<boolean>>
  error             // Readonly<Ref<string | null>>
} = useGeocode()

Forward Geocoding (Address → Coordinates)

<script setup lang="ts">
const { geocode, loading, error } = useGeocode()

const address = ref('1600 Amphitheatre Parkway, Mountain View, CA')
const result = ref<GeocodeResult | null>(null)

const searchAddress = async () => {
  result.value = await geocode(address.value)
  if (result.value) {
    console.log('Coordinates:', result.value.coordinates)
    console.log('Full address:', result.value.address)
  }
}
</script>

<template>
  <div class="space-y-4">
    <UInput
      v-model="address"
      placeholder="Enter an address"
    />
    <UButton @click="searchAddress" :loading="loading">
      Search
    </UButton>

    <div v-if="result" class="p-4 bg-gray-50 rounded">
      <p class="font-semibold">{{ result.address }}</p>
      <p class="text-sm text-gray-600">
        {{ result.coordinates[0] }}, {{ result.coordinates[1] }}
      </p>
    </div>

    <div v-if="error" class="text-red-500">
      {{ error }}
    </div>
  </div>
</template>

Reverse Geocoding (Coordinates → Address)

<script setup lang="ts">
const { reverseGeocode } = useGeocode()

const coordinates = ref<[number, number]>([-122.4194, 37.7749])
const address = ref('')

const findAddress = async () => {
  const result = await reverseGeocode(coordinates.value)
  if (result) {
    address.value = result.address
  }
}
</script>

GeocodeResult Type

interface GeocodeResult {
  coordinates: [number, number]
  address: string
  placeName: string
  context?: {
    postcode?: string
    place?: string
    region?: string
    country?: string
  }
}

useMapboxStyles()

Helper for working with Mapbox style presets.

const {
  styles,     // All available Mapbox style URLs
  getStyle    // (preset: string) => string
} = useMapboxStyles()

Available Styles

PresetURLDescription
standardmapbox://styles/mapbox/standardNew 3D style (recommended)
streetsmapbox://styles/mapbox/streets-v12Classic street map
outdoorsmapbox://styles/mapbox/outdoors-v12Outdoor/hiking map
lightmapbox://styles/mapbox/light-v11Light theme
darkmapbox://styles/mapbox/dark-v11Dark theme
satellitemapbox://styles/mapbox/satellite-v9Satellite imagery
satelliteStreetsmapbox://styles/mapbox/satellite-streets-v12Satellite + streets
navigationDaymapbox://styles/mapbox/navigation-day-v1Navigation (day)
navigationNightmapbox://styles/mapbox/navigation-night-v1Navigation (night)

Example

<script setup lang="ts">
const { styles, getStyle } = useMapboxStyles()

const selectedStyle = ref('dark')

const mapStyle = computed(() => getStyle(selectedStyle.value))
</script>

<template>
  <div>
    <select v-model="selectedStyle">
      <option value="streets">Streets</option>
      <option value="dark">Dark</option>
      <option value="satellite">Satellite</option>
    </select>

    <CroutonMapsMap
      :style="mapStyle"
      :center="[-122.4194, 37.7749]"
      :zoom="12"
      height="500px"
    />
  </div>
</template>

useMarkerColor()

Automatically sync marker colors with your Nuxt UI theme.

const markerColor = useMarkerColor()  // Ref<string> (hex color)

Example

<script setup lang="ts">
const markerColor = useMarkerColor()
// Automatically uses --ui-primary from your theme
</script>

<template>
  <CroutonMapsMarker
    :map="map"
    :position="position"
    :color="markerColor"
  />
</template>

Complete Examples

Interactive Search with Map

Combine geocoding with animated map navigation:

Search with geocoding:

<script setup lang="ts">
const { geocode, loading } = useGeocode()
const searchQuery = ref('')
const center = ref<[number, number]>([-122.4194, 37.7749])

const handleSearch = async () => {
  const result = await geocode(searchQuery.value)
  if (result) {
    center.value = result.coordinates
  }
}
</script>

<template>
  <div class="space-y-4">
    <div class="flex gap-2">
      <UInput
        v-model="searchQuery"
        placeholder="Search for a place..."
        class="flex-1"
        @keyup.enter="handleSearch"
      />
      <UButton @click="handleSearch" :loading="loading">Search</UButton>
    </div>

    <CroutonMapsMap :center="center" :zoom="12" height="500px">
      <template #default="{ map }">
        <CroutonMapsMarker :map="map" :position="center" color="#ef4444" />
      </template>
    </CroutonMapsMap>
  </div>
</template>

With smooth animations:

<script setup lang="ts">
const mapInstance = ref(null)

const handleSearch = async () => {
  const result = await geocode(searchQuery.value)
  if (result && mapInstance.value) {
    mapInstance.value.flyTo({
      center: result.coordinates,
      zoom: 14,
      duration: 2000
    })
  }
}
</script>

<template>
  <CroutonMapsMap @load="(map) => mapInstance = map">
    <!-- Map content -->
  </CroutonMapsMap>
</template>

Store Locator

Display collection items on a map:

Store list:

<script setup lang="ts">
const { items: stores } = useCollection('stores')
const selectedStore = ref(null)
</script>

<template>
  <div class="space-y-4">
    <div
      v-for="store in stores"
      :key="store.id"
      class="p-4 border rounded hover:bg-gray-50"
      :class="{ 'bg-blue-50': selectedStore?.id === store.id }"
    >
      <h3 class="font-bold">{{ store.name }}</h3>
      <p class="text-sm text-gray-600">{{ store.address }}</p>
    </div>
  </div>
</template>

Map with multiple markers:

<script setup lang="ts">
const { items: stores } = useCollection('stores')

const storeMarkers = computed(() =>
  stores.value.map(store => ({
    id: store.id,
    position: [store.longitude, store.latitude],
    name: store.name,
    address: store.address
  }))
)

const handleMarkerClick = (store) => {
  selectedStore.value = store
}
</script>

<template>
  <CroutonMapsMap :center="[-120, 37]" :zoom="6" height="600px">
    <template #default="{ map }">
      <CroutonMapsMarker
        v-for="marker in storeMarkers"
        :key="marker.id"
        :map="map"
        :position="marker.position"
        :popup-content="`<div class='p-2'><h3 class='font-bold'>${marker.name}</h3></div>`"
        @click="handleMarkerClick(marker)"
      />
    </template>
  </CroutonMapsMap>
</template>

Collection with Map Field

Add location tracking to your collections:

<script setup lang="ts">
const { items } = useCollection('events')

// Assume your collection has latitude/longitude fields
const eventMarkers = computed(() =>
  items.value
    .filter(event => event.latitude && event.longitude)
    .map(event => ({
      id: event.id,
      position: [event.longitude, event.latitude],
      title: event.title,
      date: event.date
    }))
)
</script>

<template>
  <CroutonMapsMap :center="[-120, 37]" :zoom="6" height="600px">
    <template #default="{ map }">
      <CroutonMapsMarker
        v-for="marker in eventMarkers"
        :key="marker.id"
        :map="map"
        :position="marker.position"
      >
        <template #popup>
          <div class="p-4">
            <h3 class="font-bold">{{ marker.title }}</h3>
            <p class="text-sm text-gray-600">{{ marker.date }}</p>
            <UButton size="xs" class="mt-2">
              View Details
            </UButton>
          </div>
        </template>
      </CroutonMapsMarker>
    </template>
  </CroutonMapsMap>
</template>

TypeScript Support

All types are exported from the package:

import type {
  // Main types
  MapConfig,
  MapInstance,
  MarkerInstance,
  PopupInstance,

  // Options
  UseMapOptions,
  UseMarkerOptions,

  // Results
  GeocodeResult,

  // Animations
  EasingFunction,
  EasingPreset,
  MarkerAnimationOptions,
  MapFlyToOptions,

  // Style presets
  MapboxStylePreset,

  // Re-exported Mapbox GL types
  Map,
  Marker,
  Popup,
  MapOptions,
  MarkerOptions,
  PopupOptions,
  LngLatLike
} from '@friendlyinternet/nuxt-crouton-maps'

Animation System

Both maps and markers support smooth animations.

Map Animations (flyTo)

<script setup lang="ts">
const center = ref<[number, number]>([-122.4194, 37.7749])

const goToNewYork = () => {
  center.value = [-74.0060, 40.7128]
}
</script>

<template>
  <CroutonMapsMap
    :center="center"
    :zoom="12"
    height="500px"
    :flyToOnCenterChange="true"
    :flyToDuration="1500"
  />
</template>

Marker Animations

<script setup lang="ts">
const position = ref<[number, number]>([-122.4194, 37.7749])

// Position updates will animate smoothly
const moveMarker = () => {
  position.value = [-122.4084, 37.7749]
}
</script>

<template>
  <CroutonMapsMarker
    :map="map"
    :position="position"
    :animateTransitions="true"
    :animationDuration="1000"
    animationEasing="easeInOutCubic"
  />
</template>

Custom Easing Functions

<script setup lang="ts">
// Bounce easing
const bounceEasing = (t: number): number => {
  return t < 0.5
    ? 8 * t * t * t * t
    : 1 - 8 * (--t) * t * t * t
}
</script>

<template>
  <CroutonMapsMarker
    :map="map"
    :position="position"
    :animationEasing="bounceEasing"
  />
</template>

Best Practices

Performance

Large Datasets: For 100+ markers, consider implementing marker clustering to improve performance.
<script setup lang="ts">
// Instead of rendering all markers
const visibleMarkers = computed(() => {
  // Filter markers based on map bounds
  return allMarkers.value.filter(marker => {
    // Check if marker is in viewport
    return isInBounds(marker.position)
  })
})
</script>

Error Handling

Always handle geocoding errors gracefully:

<script setup lang="ts">
const { geocode, error } = useGeocode()

const search = async (query: string) => {
  const result = await geocode(query)

  if (error.value) {
    // Show user-friendly error
    toast.add({
      title: 'Location not found',
      description: 'Please try a different search term',
      color: 'red'
    })
    return
  }

  if (!result) {
    // No results found
    toast.add({
      title: 'No results',
      description: 'Try being more specific',
      color: 'amber'
    })
  }
}
</script>

API Rate Limits

Monitor your Mapbox usage:

<script setup lang="ts">
// Debounce geocoding requests
import { useDebounceFn } from '@vueuse/core'

const { geocode } = useGeocode()

const debouncedGeocode = useDebounceFn(async (query: string) => {
  await geocode(query)
}, 500) // Wait 500ms after user stops typing
</script>

Accessibility

Add proper labels and ARIA attributes:

<template>
  <div role="region" aria-label="Interactive map">
    <CroutonMapsMap
      :center="center"
      :zoom="12"
      height="500px"
    >
      <template #default="{ map }">
        <CroutonMapsMarker
          v-for="location in locations"
          :key="location.id"
          :map="map"
          :position="location.position"
          :aria-label="`Location: ${location.name}`"
        />
      </template>
    </CroutonMapsMap>
  </div>
</template>

Troubleshooting

Map Not Displaying

Symptoms: Blank container or loading spinner never disappears.

Solutions:

  1. Check access token in .env:
    echo $MAPBOX_TOKEN
    
  2. Verify runtime config:
    const config = useMapConfig()
    console.log('Has token:', !!config.accessToken)
    
  3. Ensure container has explicit height:
    <CroutonMapsMap height="500px" /> <!-- ✅ Good -->
    <CroutonMapsMap /> <!-- ❌ Bad - no height -->
    
  4. Check browser console for errors

Geocoding Failures

Symptoms: useGeocode() returns null or errors.

Solutions:

  1. Verify API limits: https://account.mapbox.com/
  2. Check network tab for 401/403 errors
  3. Validate query formatting:
    // ✅ Good
    await geocode('123 Main St, San Francisco, CA')
    

// ❌ Bad await geocode('') // Empty query


### TypeScript Errors

**Symptoms:** Type errors with Mapbox GL types.

**Solutions:**
1. Install peer dependencies:
```bash
pnpm add -D mapbox-gl
  1. Import types from package:
    import type { LngLatLike } from '@friendlyinternet/nuxt-crouton-maps'
    

Performance Issues

Symptoms: Slow rendering with many markers.

Solutions:

  1. Implement marker filtering based on zoom level
  2. Use marker clustering (consider future addon)
  3. Lazy load map component:
    <script setup lang="ts">
    const MapComponent = defineAsyncComponent(() =>
      import('#components').then(c => c.CroutonMapsMap)
    )
    </script>
    

Roadmap

Planned features for future releases:

  • Marker clustering for large datasets
  • Drawing tools (polygons, circles, lines)
  • Heatmap layer support
  • 3D terrain and buildings
  • Custom marker icons
  • Route/directions integration
  • GeoJSON layer support
  • MapLibre GL JS option (non-Mapbox alternative)
Beta Status: These features are planned but not guaranteed. API design may change based on community feedback.

Resources