Skip to content
Merged
165 changes: 20 additions & 145 deletions docs/content/scripts/google-maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,160 +521,35 @@
</template>
```

**See the [SFC Playground Example](https://nuxt-scripts-playground.stackblitz.io/third-parties/google-maps/sfcs) for a complete demonstration.**
### Component Hierarchy

### Component Details

#### ScriptGoogleMapsMarker

Classic Google Maps marker with icon support.

**Props:**
- `options` - `google.maps.MarkerOptions` (excluding `map`)

**Events:**
- Standard marker events: `click`, `mousedown`, `mouseover`, etc.

#### ScriptGoogleMapsAdvancedMarkerElement

Modern advanced markers that support HTML content and better customization.

**Props:**
- `options` - `google.maps.marker.AdvancedMarkerElementOptions` (excluding `map`)

**Events:**
- Standard marker events: `click`, `drag`, `position_changed`, etc.

#### ScriptGoogleMapsInfoWindow

Information windows that display content when triggered.

**Props:**
- `options` - `google.maps.InfoWindowOptions`

**Behavior:**
- Automatically opens on parent marker click
- You can use it standalone with an explicit position
- Supports custom HTML content via default slot

#### ScriptGoogleMapsMarkerClusterer

Groups nearby markers into clusters for better performance and UX.

**Props:**
- `options` - `MarkerClustererOptions` (excluding `map`)

**Dependencies:**
- Requires `@googlemaps/markerclusterer` peer dependency

#### Other Components

- **ScriptGoogleMapsPinElement**: Use within AdvancedMarkerElement for customizable pins
- **ScriptGoogleMapsCircle**: Circular overlays with radius and styling
- **ScriptGoogleMapsPolygon/Polyline**: Shape and line overlays
- **ScriptGoogleMapsRectangle**: Rectangular overlays
- **ScriptGoogleMapsHeatmapLayer**: Data visualization with heatmaps

All components support:
- Reactive `options` prop that updates the basic Google Maps object
- Automatic cleanup on component unmount
- TypeScript support with Google Maps types

### Best Practices

#### Performance Considerations

**Use MarkerClusterer for Many Markers**
```vue
<!-- βœ… Good: Use clusterer for >10 markers -->
<ScriptGoogleMapsMarkerClusterer>
<ScriptGoogleMapsMarker v-for="marker in manyMarkers" />
</ScriptGoogleMapsMarkerClusterer>

<!-- ❌ Avoid: Many individual markers -->
<ScriptGoogleMapsMarker v-for="marker in manyMarkers" />
```

**Prefer AdvancedMarkerElement for Modern Apps**
```vue
<!-- βœ… Recommended: Better performance and styling -->
<ScriptGoogleMapsAdvancedMarkerElement :options="options">
<ScriptGoogleMapsPinElement :options="{ background: '#FF0000' }" />
</ScriptGoogleMapsAdvancedMarkerElement>

<!-- ⚠️ Legacy: Use only when advanced markers aren't supported -->
<ScriptGoogleMapsMarker :options="options" />
```

#### Component Hierarchy

Follow this nesting structure for components:

```
```text
ScriptGoogleMaps (root)
β”œβ”€β”€ ScriptGoogleMapsMarkerClusterer (optional)
β”‚ └── ScriptGoogleMapsMarker/AdvancedMarkerElement
β”‚ └── ScriptGoogleMapsMarker / ScriptGoogleMapsAdvancedMarkerElement
β”‚ └── ScriptGoogleMapsInfoWindow (optional)
β”œβ”€β”€ ScriptGoogleMapsAdvancedMarkerElement
β”‚ β”œβ”€β”€ ScriptGoogleMapsPinElement (optional)
β”‚ └── ScriptGoogleMapsInfoWindow (optional)
└── Other overlays (Circle, Polygon, etc.)
└── ScriptGoogleMapsCircle / Polygon / Polyline / Rectangle / HeatmapLayer
```

#### Reactive Data Patterns

**Reactive Marker Updates**
```vue
<script setup>
const markers = ref([
{ id: 1, position: { lat: -34.397, lng: 150.644 }, title: 'Sydney' }
])

// Markers automatically update when data changes
function addMarker() {
markers.value.push({
id: Date.now(),
position: getRandomPosition(),
title: 'New Location'
})
}
</script>

<template>
<ScriptGoogleMaps>
<ScriptGoogleMapsMarker
v-for="marker in markers"
:key="marker.id"
:options="{ position: marker.position, title: marker.title }"
/>
</ScriptGoogleMaps>
</template>
```

#### Error Handling

Always provide error fallbacks and loading states:

```vue
<script setup>
const mapError = ref(false)
</script>

<template>
<ScriptGoogleMaps
api-key="your-api-key"
@error="mapError = true"
>
<template #error>
<div class="p-4 bg-red-100">
Failed to load Google Maps
</div>
</template>

<!-- Your components -->
</ScriptGoogleMaps>
</template>
```
All SFC components accept an `options` prop matching their Google Maps API options type (excluding `map`, which is injected automatically). Options are reactive - changes update the basic Google Maps object. Components clean up automatically on unmount.

Check warning on line 537 in docs/content/scripts/google-maps.md

View workflow job for this annotation

GitHub Actions / test

Passive voice: "is injected". Consider rewriting in active voice

### Component Reference

| Component | Options Type | Notes |
|---|---|---|
| `ScriptGoogleMapsMarker` | `google.maps.MarkerOptions` | Classic marker |
| `ScriptGoogleMapsAdvancedMarkerElement` | `google.maps.marker.AdvancedMarkerElementOptions` | Recommended |
| `ScriptGoogleMapsPinElement` | `google.maps.marker.PinElementOptions` | Child of AdvancedMarkerElement |
| `ScriptGoogleMapsInfoWindow` | `google.maps.InfoWindowOptions` | Auto-opens on parent marker click |
| `ScriptGoogleMapsMarkerClusterer` | `MarkerClustererOptions` | Requires `@googlemaps/markerclusterer` |
| `ScriptGoogleMapsCircle` | `google.maps.CircleOptions` | |
| `ScriptGoogleMapsPolygon` | `google.maps.PolygonOptions` | |
| `ScriptGoogleMapsPolyline` | `google.maps.PolylineOptions` | |
| `ScriptGoogleMapsRectangle` | `google.maps.RectangleOptions` | |
| `ScriptGoogleMapsHeatmapLayer` | `google.maps.visualization.HeatmapLayerOptions` | |

## [`useScriptGoogleMaps()`{lang="ts"}](/scripts/google-maps){lang="ts"}

Expand Down
45 changes: 23 additions & 22 deletions src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/// <reference types="google.maps" />
import type { ElementScriptTrigger } from '#nuxt-scripts/types'
import type { QueryObject } from 'ufo'
import type { HTMLAttributes, ImgHTMLAttributes, InjectionKey, Ref, ReservedProps, ShallowRef } from 'vue'
import type { HTMLAttributes, ImgHTMLAttributes, Ref, ReservedProps, ShallowRef } from 'vue'
import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTriggerElement'
import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps'
import { scriptRuntimeConfig } from '#nuxt-scripts/utils'
Expand All @@ -13,10 +13,9 @@ import { withQuery } from 'ufo'
import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, watch } from 'vue'
import ScriptAriaLoadingIndicator from '../ScriptAriaLoadingIndicator.vue'

export const MAP_INJECTION_KEY = Symbol('map') as InjectionKey<{
map: ShallowRef<google.maps.Map | undefined>
mapsApi: Ref<typeof google.maps | undefined>
}>
import { MAP_INJECTION_KEY } from './injectionKeys'

export { MAP_INJECTION_KEY } from './injectionKeys'
</script>

<script lang="ts" setup>
Expand Down Expand Up @@ -190,16 +189,12 @@ function isLocationQuery(s: string | any) {
return typeof s === 'string' && (s.split(',').length > 2 || s.includes('+'))
}

function resetMapMarkerMap(_marker: google.maps.marker.AdvancedMarkerElement | Promise<google.maps.marker.AdvancedMarkerElement>) {
// eslint-disable-next-line no-async-promise-executor
return new Promise<void>(async (resolve) => {
const marker = _marker instanceof Promise ? await _marker : _marker
if (marker) {
// @ts-expect-error broken type
marker.setMap(null)
}
resolve()
})
async function resetMapMarkerMap(_marker: google.maps.marker.AdvancedMarkerElement | Promise<google.maps.marker.AdvancedMarkerElement>) {
const marker = _marker instanceof Promise ? await _marker : _marker
if (marker) {
// @ts-expect-error broken type
marker.setMap(null)
}
}

function normalizeAdvancedMapMarkerOptions(_options?: google.maps.marker.AdvancedMarkerElementOptions | `${string},${string}`) {
Expand Down Expand Up @@ -228,14 +223,12 @@ async function createAdvancedMapMarker(_options?: google.maps.marker.AdvancedMar
const key = hash({ position: normalizedOptions.position })
if (mapMarkers.value.has(key))
return mapMarkers.value.get(key)
// eslint-disable-next-line no-async-promise-executor
const p = new Promise<google.maps.marker.AdvancedMarkerElement>(async (resolve) => {
const lib = await importLibrary('marker')
const p = importLibrary('marker').then((lib) => {
const mapMarkerOptions = {
...toRaw(normalizedOptions),
map: toRaw(map.value!),
}
resolve(new lib.AdvancedMarkerElement(mapMarkerOptions))
return new lib.AdvancedMarkerElement(mapMarkerOptions)
})
mapMarkers.value.set(key, p)
return p
Expand Down Expand Up @@ -536,12 +529,20 @@ const rootAttrs = computed(() => {
}) as HTMLAttributes
})

onBeforeUnmount(async () => {
await Promise.all(Array.from(mapMarkers.value.entries(), ([,marker]) => resetMapMarkerMap(marker)))
mapMarkers.value.clear()
onBeforeUnmount(() => {
// Synchronous cleanup β€” Vue does not await async lifecycle hooks,
// so anything after an `await` runs as a detached microtask.
// Note: do NOT null mapsApi here β€” children unmount AFTER onBeforeUnmount
// and need mapsApi.value for clearInstanceListeners in their cleanup.
map.value?.unbindAll()
map.value = undefined
mapEl.value?.firstChild?.remove()
libraries.clear()
queryToLatLngCache.clear()

// Async marker cleanup (fire-and-forget β€” markers are already detached from the nulled map)
Promise.all(Array.from(mapMarkers.value.entries(), ([, marker]) => resetMapMarkerMap(marker)))
mapMarkers.value.clear()
})
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
<script lang="ts">
import type { InjectionKey, ShallowRef } from 'vue'
import { whenever } from '@vueuse/core'
import { inject, onUnmounted, provide, ref, shallowRef } from 'vue'
import { MAP_INJECTION_KEY } from './ScriptGoogleMaps.vue'
import { inject, provide, watch } from 'vue'
import { ADVANCED_MARKER_ELEMENT_INJECTION_KEY } from './injectionKeys'
import { MARKER_CLUSTERER_INJECTION_KEY } from './ScriptGoogleMapsMarkerClusterer.vue'
import { useGoogleMapsResource } from './useGoogleMapsResource'

export const ADVANCED_MARKER_ELEMENT_INJECTION_KEY = Symbol('marker') as InjectionKey<{
advancedMarkerElement: ShallowRef<google.maps.marker.AdvancedMarkerElement | undefined>
}>
export { ADVANCED_MARKER_ELEMENT_INJECTION_KEY } from './injectionKeys'
</script>

<script setup lang="ts">
Expand Down Expand Up @@ -47,68 +44,46 @@ const eventsWithMapMouseEventPayload = [
'mouseup',
] as const

const mapContext = inject(MAP_INJECTION_KEY, undefined)
const markerClustererContext = inject(MARKER_CLUSTERER_INJECTION_KEY, undefined)

const advancedMarkerElement = shallowRef<google.maps.marker.AdvancedMarkerElement | undefined>(undefined)
const isUnmounted = ref(false)

whenever(() => mapContext?.map.value && mapContext.mapsApi.value, async () => {
await mapContext!.mapsApi.value!.importLibrary('marker')

// component was unmounted while awaiting the library import
if (isUnmounted.value)
return

advancedMarkerElement.value = new mapContext!.mapsApi.value!.marker.AdvancedMarkerElement(props.options)

setupAdvancedMarkerElementEventListeners(advancedMarkerElement.value)

if (markerClustererContext?.markerClusterer.value) {
markerClustererContext.markerClusterer.value.addMarker(advancedMarkerElement.value)
}
else {
advancedMarkerElement.value.map = mapContext!.map.value
}

whenever(() => props.options, (options) => {
if (advancedMarkerElement.value && options) {
Object.assign(advancedMarkerElement.value, options)
const advancedMarkerElement = useGoogleMapsResource<google.maps.marker.AdvancedMarkerElement>({
async create({ mapsApi, map }) {
await mapsApi.importLibrary('marker')
const marker = new mapsApi.marker.AdvancedMarkerElement(props.options)
setupEventListeners(marker)
if (markerClustererContext?.markerClusterer.value) {
markerClustererContext.markerClusterer.value.addMarker(marker)
}
}, {
deep: true,
})
}, {
immediate: true,
once: true,
else {
marker.map = map
}
return marker
},
cleanup(marker, { mapsApi }) {
mapsApi.event.clearInstanceListeners(marker)
if (markerClustererContext?.markerClusterer.value) {
markerClustererContext.markerClusterer.value.removeMarker(marker)
}
else {
marker.map = null
}
},
})

onUnmounted(() => {
isUnmounted.value = true

if (!advancedMarkerElement.value || !mapContext?.mapsApi.value) {
return
watch(() => props.options, (options) => {
if (advancedMarkerElement.value && options) {
Object.assign(advancedMarkerElement.value, options)
}

mapContext.mapsApi.value.event.clearInstanceListeners(advancedMarkerElement.value)

if (markerClustererContext) {
markerClustererContext.markerClusterer.value?.removeMarker(advancedMarkerElement.value)
}
else {
advancedMarkerElement.value.map = null
}
})
}, { deep: true })

provide(ADVANCED_MARKER_ELEMENT_INJECTION_KEY, { advancedMarkerElement })

function setupAdvancedMarkerElementEventListeners(advancedMarkerElement: google.maps.marker.AdvancedMarkerElement) {
function setupEventListeners(marker: google.maps.marker.AdvancedMarkerElement) {
eventsWithoutPayload.forEach((event) => {
advancedMarkerElement.addListener(event, () => emit(event))
marker.addListener(event, () => emit(event))
})

eventsWithMapMouseEventPayload.forEach((event) => {
advancedMarkerElement.addListener(event, (payload: google.maps.MapMouseEvent) => emit(event, payload))
marker.addListener(event, (payload: google.maps.MapMouseEvent) => emit(event, payload))
})
}
</script>
Expand Down
Loading
Loading