Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/content/scripts/google-maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ All Google Maps SFC components must work within a `<ScriptGoogleMaps>`{lang="htm
- `<ScriptGoogleMapsPolyline>`{lang="html"} - Line paths
- `<ScriptGoogleMapsRectangle>`{lang="html"} - Rectangular overlays
- `<ScriptGoogleMapsHeatmapLayer>`{lang="html"} - Heatmap visualization
- `<ScriptGoogleMapsOverlayView>`{lang="html"} - Render arbitrary Vue content at a lat/lng position

### Basic Usage

Expand Down Expand Up @@ -531,6 +532,7 @@ ScriptGoogleMaps (root)
β”œβ”€β”€ ScriptGoogleMapsAdvancedMarkerElement
β”‚ β”œβ”€β”€ ScriptGoogleMapsPinElement (optional)
β”‚ └── ScriptGoogleMapsInfoWindow (optional)
β”œβ”€β”€ ScriptGoogleMapsOverlayView (arbitrary Vue content at a position)
└── ScriptGoogleMapsCircle / Polygon / Polyline / Rectangle / HeatmapLayer
```

Expand All @@ -550,6 +552,75 @@ All SFC components accept an `options` prop matching their Google Maps API optio
| `ScriptGoogleMapsPolyline` | `google.maps.PolylineOptions` | |
| `ScriptGoogleMapsRectangle` | `google.maps.RectangleOptions` | |
| `ScriptGoogleMapsHeatmapLayer` | `google.maps.visualization.HeatmapLayerOptions` | |
| `ScriptGoogleMapsOverlayView` | Explicit props (see below) | Renders Vue slot content at a position |

### `<ScriptGoogleMapsOverlayView>`{lang="html"}

Renders arbitrary Vue slot content at a map lat/lng position. Unlike `InfoWindow`, you have full control over HTML structure and styling.

**Props:**

| Prop | Type | Default | Description |
|---|---|---|---|
| `position` | `google.maps.LatLngLiteral` | (required) | Map anchor point |
| `anchor` | `OverlayAnchor` | `'bottom-center'` | Which point of the content aligns to the position |
| `offset` | `{ x: number, y: number }` | - | Pixel fine-tuning after anchor alignment |
| `pane` | `OverlayPane` | `'floatPane'` | Google Maps pane to render into |
| `zIndex` | `number` | - | Stack order |
| `blockMapInteraction` | `boolean` | `true` | Prevents clicks/drags from propagating to the map |

**Popup on marker click:**

```vue
<script setup>
const open = ref(false)
const pos = { lat: -34.397, lng: 150.644 }
</script>

<template>
<ScriptGoogleMaps api-key="your-api-key">
<ScriptGoogleMapsAdvancedMarkerElement
:options="{ position: pos }"
@click="open = true"
/>
<ScriptGoogleMapsOverlayView
v-if="open"
:position="pos"
anchor="bottom-center"
:offset="{ x: 0, y: -40 }"
>
<div class="custom-popup">
<button @click="open = false">
Γ—
</button>
<p>Any Vue content here</p>
</div>
</ScriptGoogleMapsOverlayView>
</ScriptGoogleMaps>
</template>
```

**Persistent label:**

```vue
<template>
<ScriptGoogleMaps api-key="your-api-key">
<ScriptGoogleMapsOverlayView
:position="{ lat: -34.397, lng: 150.644 }"
anchor="center"
:block-map-interaction="false"
>
<span style="background: white; padding: 2px 6px; border-radius: 4px;">
Label text
</span>
</ScriptGoogleMapsOverlayView>
</ScriptGoogleMaps>
</template>
```

::callout
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.
::

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

Expand Down
21 changes: 21 additions & 0 deletions playground/pages/third-parties/google-maps/sfcs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const heatmapLayerData = ref<google.maps.LatLng[]>([])

const isCircleShown = ref(false)

const isOverlayViewShown = ref(false)

const googleMapsRef = useTemplateRef('googleMapsRef')

whenever(() => googleMapsRef.value?.googleMaps, (googleMaps) => {
Expand Down Expand Up @@ -191,6 +193,18 @@ whenever(() => googleMapsRef.value?.googleMaps, (googleMaps) => {
}"
/>

<ScriptGoogleMapsOverlayView
v-if="isOverlayViewShown"
:position="{ lat: -33.8688, lng: 151.2093 }"
anchor="bottom-center"
:offset="{ x: 0, y: -10 }"
>
<div style="background: white; padding: 8px 12px; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.3); font-family: sans-serif;">
<strong>Custom Overlay</strong>
<p style="margin: 4px 0 0;">Vue slot content with full reactivity</p>
</div>
</ScriptGoogleMapsOverlayView>

<ScriptGoogleMapsCircle
v-if="isCircleShown"
:options="{
Expand Down Expand Up @@ -269,6 +283,13 @@ whenever(() => googleMapsRef.value?.googleMaps, (googleMaps) => {
{{ `${isHeatmapLayerShown ? 'Hide' : 'Show'} heatmap layer` }}
</button>

<button
class="bg-[#ffa500] rounded-lg px-2 py-1"
@click="isOverlayViewShown = !isOverlayViewShown"
>
{{ `${isOverlayViewShown ? 'Hide' : 'Show'} overlay view` }}
</button>

<button
class="bg-[#ffa500] rounded-lg px-2 py-1"
@click="isCircleShown = !isCircleShown"
Expand Down
128 changes: 128 additions & 0 deletions src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { useTemplateRef, watch } from 'vue'
import { useGoogleMapsResource } from './useGoogleMapsResource'

type OverlayAnchor = 'center' | 'top-left' | 'top-center' | 'top-right'
| 'bottom-left' | 'bottom-center' | 'bottom-right'
| 'left-center' | 'right-center'

type OverlayPane = 'mapPane' | 'overlayLayer' | 'markerLayer'
| 'overlayMouseTarget' | 'floatPane'

const props = withDefaults(defineProps<{
position: google.maps.LatLngLiteral
anchor?: OverlayAnchor
offset?: { x: number, y: number }
pane?: OverlayPane
zIndex?: number
blockMapInteraction?: boolean
}>(), {
anchor: 'bottom-center',
pane: 'floatPane',
blockMapInteraction: true,
})

const ANCHOR_TRANSFORMS: Record<OverlayAnchor, string> = {
'center': 'translate(-50%, -50%)',
'top-left': 'translate(0, 0)',
'top-center': 'translate(-50%, 0)',
'top-right': 'translate(-100%, 0)',
'bottom-left': 'translate(0, -100%)',
'bottom-center': 'translate(-50%, -100%)',
'bottom-right': 'translate(-100%, -100%)',
'left-center': 'translate(0, -50%)',
'right-center': 'translate(-100%, -50%)',
}

const overlayContent = useTemplateRef('overlay-content')

const overlay = useGoogleMapsResource<google.maps.OverlayView>({
ready: () => !!overlayContent.value,
create({ mapsApi, map }) {
const el = overlayContent.value!
let hasDrawn = false

class CustomOverlay extends mapsApi.OverlayView {
override onAdd() {
const panes = this.getPanes()
if (panes) {
panes[props.pane].appendChild(el)
if (props.blockMapInteraction)
mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el)
}
}

override draw() {
const projection = this.getProjection()
if (!projection)
return
const pos = projection.fromLatLngToDivPixel(
new mapsApi.LatLng(props.position.lat, props.position.lng),
)
if (!pos)
return
el.style.position = 'absolute'
el.style.left = `${pos.x + (props.offset?.x ?? 0)}px`
el.style.top = `${pos.y + (props.offset?.y ?? 0)}px`
el.style.transform = ANCHOR_TRANSFORMS[props.anchor]
if (props.zIndex !== undefined)
el.style.zIndex = String(props.zIndex)
if (!hasDrawn) {
el.style.visibility = 'visible'
hasDrawn = true
}
}

override onRemove() {
el.parentNode?.removeChild(el)
}
}

// Prevent flash: hide until first draw() positions content
el.style.visibility = 'hidden'

const ov = new CustomOverlay()
ov.setMap(map)
return ov
},
cleanup(ov) {
ov.setMap(null)
},
})

// Reposition on prop changes (draw() is designed to be called repeatedly)
watch(
() => [props.position.lat, props.position.lng, props.offset?.x, props.offset?.y, props.zIndex, props.anchor],
() => { overlay.value?.draw() },
)

// Pane change requires remount (setMap cycles onRemove + onAdd + draw)
watch(() => props.pane, () => {
if (overlay.value) {
const map = overlay.value.getMap()
overlay.value.setMap(null)
if (map)
overlay.value.setMap(map as google.maps.Map)
}
})

// blockMapInteraction change requires remount to re-apply preventMapHitsAndGesturesFrom
watch(() => props.blockMapInteraction, () => {
if (overlay.value) {
const map = overlay.value.getMap()
overlay.value.setMap(null)
if (map)
overlay.value.setMap(map as google.maps.Map)
}
})

defineExpose({ overlay })
</script>

<template>
<div style="display: none;">
<div ref="overlay-content">
<slot />
</div>
</div>
</template>
2 changes: 1 addition & 1 deletion test/e2e/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ describe('base', async () => {
await page.waitForTimeout(500)
// get content of #script-src
const text = await page.$eval('#script-src', el => el.textContent)
expect(text).toMatchInlineSnapshot(`"/foo/_scripts/assets/6bEy8slcRmYcRT4E2QbQZ1CMyWw9PpHA7L87BtvSs2U.js"`)
expect(text).toMatchInlineSnapshot(`"/foo/_scripts/assets/PHzhM8DFXcXVSSJF110cyV3pjg9cp8oWv_f4Dk2ax1w.js"`)
})
})
Loading