Skip to content

Commit 7fc0b52

Browse files
authored
feat(google-maps): overlay view panning, cluster auto-hide, and render guard (#663)
1 parent 1ac35a4 commit 7fc0b52

3 files changed

Lines changed: 122 additions & 3 deletions

File tree

docs/content/scripts/google-maps/2.api/13.overlay-view.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,42 @@ For simple cases where remounting is acceptable, `v-if` also works:
9494
</template>
9595
```
9696

97+
## Map Panning
98+
99+
When an overlay opens, the map automatically pans so the overlay is fully visible, matching the native `InfoWindow` behavior. The default padding is 40px from the map edge.
100+
101+
To customize the padding or disable panning:
102+
103+
```vue
104+
<!-- Custom padding -->
105+
<ScriptGoogleMapsOverlayView :pan-on-open="60">
106+
...
107+
</ScriptGoogleMapsOverlayView>
108+
109+
<!-- Disable panning -->
110+
<ScriptGoogleMapsOverlayView :pan-on-open="false">
111+
...
112+
</ScriptGoogleMapsOverlayView>
113+
```
114+
115+
## Cluster Awareness
116+
117+
When used inside a `ScriptGoogleMapsMarkerClusterer`, overlay views automatically hide when their parent marker joins a cluster on zoom out. This prevents orphaned overlays from floating over cluster icons.
118+
119+
When its marker is clustered, the overlay updates `v-model:open` to `false`. The user will need to reopen the overlay (e.g. click the marker again) after zooming back in.
120+
121+
To disable this behavior:
122+
123+
```vue
124+
<ScriptGoogleMapsMarkerClusterer>
125+
<ScriptGoogleMapsAdvancedMarkerElement :position="markerPosition">
126+
<ScriptGoogleMapsOverlayView :hide-when-clustered="false">
127+
...
128+
</ScriptGoogleMapsOverlayView>
129+
</ScriptGoogleMapsAdvancedMarkerElement>
130+
</ScriptGoogleMapsMarkerClusterer>
131+
```
132+
97133
::callout
98134
The `blockMapInteraction` prop (default `true`) calls `google.maps.OverlayView.preventMapHitsAndGesturesFrom()`{lang="ts"} to stop clicks, taps, and drags from propagating through the overlay to the map. Set it to `false` for non-interactive overlays like labels.
99135
::

src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script lang="ts">
22
import type { InjectionKey, ShallowRef } from 'vue'
3-
import { provide, shallowRef, watch } from 'vue'
3+
import { inject, provide, shallowRef, watch } from 'vue'
44
import { bindGoogleMapsEvents } from './bindGoogleMapsEvents'
5+
import { MAP_INJECTION_KEY } from './injectionKeys'
56
import { useGoogleMapsResource } from './useGoogleMapsResource'
67
78
// Inline types to avoid requiring @googlemaps/markerclusterer as a build-time dependency
@@ -20,10 +21,14 @@ export interface MarkerClustererOptions {
2021
onClusterClick?: unknown
2122
}
2223
23-
export const MARKER_CLUSTERER_INJECTION_KEY = Symbol('marker-clusterer') as InjectionKey<{
24+
export interface MarkerClustererContext {
2425
markerClusterer: ShallowRef<MarkerClustererInstance | undefined>
2526
requestRerender: () => void
26-
}>
27+
/** Increments after each clustering cycle; watch to detect cluster membership changes */
28+
clusteringVersion: ShallowRef<number>
29+
}
30+
31+
export const MARKER_CLUSTERER_INJECTION_KEY = Symbol('marker-clusterer') as InjectionKey<MarkerClustererContext>
2732
</script>
2833

2934
<script setup lang="ts">
@@ -56,6 +61,9 @@ const markerClustererEvents = [
5661
'clusteringend',
5762
] as const
5863
64+
const mapContext = inject(MAP_INJECTION_KEY, undefined)
65+
const clusteringVersion = shallowRef(0)
66+
5967
const markerClusterer = useGoogleMapsResource<MarkerClustererInstance>({
6068
async create({ map }) {
6169
const { MarkerClusterer } = await import('@googlemaps/markerclusterer')
@@ -64,6 +72,9 @@ const markerClusterer = useGoogleMapsResource<MarkerClustererInstance>({
6472
...props.options,
6573
} as any) as MarkerClustererInstance
6674
bindGoogleMapsEvents(clusterer, emit, { withPayload: markerClustererEvents })
75+
clusterer.addListener('clusteringend', () => {
76+
clusteringVersion.value++
77+
})
6778
return clusterer
6879
},
6980
cleanup(clusterer, { mapsApi }) {
@@ -83,6 +94,11 @@ watch(
8394
(ready) => {
8495
if (!ready)
8596
return
97+
// Guard: map projection must be ready, otherwise MarkerClusterer.render()
98+
// throws "Cannot read properties of null (reading 'fromLatLngToDivPixel')"
99+
// Keep rerenderPending true so the request isn't lost
100+
if (!mapContext?.map.value?.getProjection())
101+
return
86102
rerenderPending.value = false
87103
try {
88104
markerClusterer.value!.render()
@@ -100,6 +116,7 @@ provide(
100116
{
101117
markerClusterer,
102118
requestRerender,
119+
clusteringVersion,
103120
},
104121
)
105122
</script>

src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import { inject, useTemplateRef, watch } from 'vue'
33
import { ADVANCED_MARKER_ELEMENT_INJECTION_KEY, MARKER_INJECTION_KEY } from './injectionKeys'
4+
import { MARKER_CLUSTERER_INJECTION_KEY } from './ScriptGoogleMapsMarkerClusterer.vue'
45
import { useGoogleMapsResource } from './useGoogleMapsResource'
56
67
type OverlayAnchor = 'center' | 'top-left' | 'top-center' | 'top-right'
@@ -40,16 +41,31 @@ const props = withDefaults(defineProps<{
4041
* @default true
4142
*/
4243
blockMapInteraction?: boolean
44+
/**
45+
* Pan the map so the overlay is fully visible when opened, similar to InfoWindow behavior.
46+
* Set to `true` for default 40px padding, or a number for custom padding.
47+
* @default true
48+
*/
49+
panOnOpen?: boolean | number
50+
/**
51+
* Automatically hide the overlay when its parent marker joins a cluster (on zoom out).
52+
* Only applies when nested inside a ScriptGoogleMapsMarkerClusterer.
53+
* @default true
54+
*/
55+
hideWhenClustered?: boolean
4356
}>(), {
4457
anchor: 'bottom-center',
4558
pane: 'floatPane',
4659
blockMapInteraction: true,
60+
panOnOpen: true,
61+
hideWhenClustered: true,
4762
})
4863
4964
const open = defineModel<boolean>('open', { default: undefined })
5065
5166
const markerContext = inject(MARKER_INJECTION_KEY, undefined)
5267
const advancedMarkerElementContext = inject(ADVANCED_MARKER_ELEMENT_INJECTION_KEY, undefined)
68+
const markerClustererContext = inject(MARKER_CLUSTERER_INJECTION_KEY, undefined)
5369
5470
// Read position fresh each call — NOT a computed, because Google Maps object
5571
// internal state (marker.getPosition()) is invisible to Vue's reactivity.
@@ -91,6 +107,26 @@ const overlayContent = useTemplateRef('overlay-content')
91107
// Track all event listeners for clean teardown
92108
const listeners: google.maps.MapsEventListener[] = []
93109
110+
function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: number) {
111+
const child = el.firstElementChild
112+
if (!child)
113+
return
114+
const overlayRect = child.getBoundingClientRect()
115+
const mapRect = map.getDiv().getBoundingClientRect()
116+
let panX = 0
117+
let panY = 0
118+
if (overlayRect.top - padding < mapRect.top)
119+
panY = overlayRect.top - mapRect.top - padding
120+
if (overlayRect.bottom + padding > mapRect.bottom)
121+
panY = overlayRect.bottom - mapRect.bottom + padding
122+
if (overlayRect.left - padding < mapRect.left)
123+
panX = overlayRect.left - mapRect.left - padding
124+
else if (overlayRect.right + padding > mapRect.right)
125+
panX = overlayRect.right - mapRect.right + padding
126+
if (panX !== 0 || panY !== 0)
127+
map.panBy(panX, panY)
128+
}
129+
94130
const overlay = useGoogleMapsResource<google.maps.OverlayView>({
95131
// ready condition accesses .value on ShallowRefs — tracked by whenever() in useGoogleMapsResource
96132
ready: () => !!overlayContent.value
@@ -106,6 +142,13 @@ const overlay = useGoogleMapsResource<google.maps.OverlayView>({
106142
if (props.blockMapInteraction)
107143
mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el)
108144
}
145+
if (props.panOnOpen) {
146+
// Wait for draw() to position the element, then pan
147+
const padding = typeof props.panOnOpen === 'number' ? props.panOnOpen : 40
148+
requestAnimationFrame(() => {
149+
panMapToFitOverlay(el, map, padding)
150+
})
151+
}
109152
}
110153
111154
override draw() {
@@ -217,6 +260,29 @@ watch([() => props.pane, () => props.blockMapInteraction], () => {
217260
}
218261
})
219262
263+
// Auto-hide overlay when its parent marker joins a cluster
264+
if (markerClustererContext && (markerContext || advancedMarkerElementContext)) {
265+
watch(
266+
() => markerClustererContext.clusteringVersion.value,
267+
() => {
268+
if (!props.hideWhenClustered || open.value === false)
269+
return
270+
const clusterer = markerClustererContext.markerClusterer.value as any
271+
if (!clusterer?.clusters)
272+
return
273+
const parentMarker = advancedMarkerElementContext?.advancedMarkerElement.value
274+
?? markerContext?.marker.value
275+
if (!parentMarker)
276+
return
277+
const isClustered = clusterer.clusters.some(
278+
(cluster: any) => cluster.count > 1 && cluster.markers?.includes(parentMarker),
279+
)
280+
if (isClustered)
281+
open.value = false
282+
},
283+
)
284+
}
285+
220286
defineExpose({ overlay })
221287
</script>
222288

0 commit comments

Comments
 (0)