-
Notifications
You must be signed in to change notification settings - Fork 84
Expand file tree
/
Copy pathScriptGoogleMaps.vue
More file actions
558 lines (521 loc) · 17.2 KB
/
ScriptGoogleMaps.vue
File metadata and controls
558 lines (521 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
<script lang="ts">
/// <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 { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTriggerElement'
import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps'
import { scriptRuntimeConfig } from '#nuxt-scripts/utils'
import { defu } from 'defu'
import { $fetch } from 'ofetch'
import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app'
import { hash } from 'ohash'
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>
}>
</script>
<script lang="ts" setup>
export interface PlaceholderOptions {
width?: string | number
height?: string | number
center?: string
zoom?: number
size?: string
scale?: number
format?: 'png' | 'jpg' | 'gif' | 'png8' | 'png32' | 'jpg-baseline'
maptype?: 'roadmap' | 'satellite' | 'terrain' | 'hybrid'
language?: string
region?: string
markers?: string
path?: string
visible?: string
style?: string
map_id?: string
key?: string
signature?: string
}
const props = withDefaults(defineProps<{
/**
* Defines the trigger event to load the script.
*/
trigger?: ElementScriptTrigger
/**
* Is Google Maps being rendered above the fold?
* This will load the placeholder image with higher priority.
*/
aboveTheFold?: boolean
/**
* Defines the Google Maps API key. Must have access to the Static Maps API as well.
*/
apiKey?: string
/**
* A latitude / longitude of where to focus the map.
*/
center?: google.maps.LatLng | google.maps.LatLngLiteral | `${string},${string}`
/**
* Should a marker be displayed on the map where the centre is.
*/
centerMarker?: boolean
/**
* Options for the map.
*/
mapOptions?: google.maps.MapOptions
/**
* Defines the region of the map.
*/
region?: string
/**
* Defines the language of the map
*/
language?: string
/**
* Defines the version of google maps js API
*/
version?: string
/**
* Defines the width of the map.
*/
width?: number | string
/**
* Defines the height of the map
*/
height?: number | string
/**
* Customize the placeholder image attributes.
*
* @see https://developers.google.com/maps/documentation/maps-static/start.
*/
placeholderOptions?: PlaceholderOptions
/**
* Customize the placeholder image attributes.
*/
placeholderAttrs?: ImgHTMLAttributes & ReservedProps & Record<string, unknown>
/**
* Customize the root element attributes.
*/
rootAttrs?: HTMLAttributes & ReservedProps & Record<string, unknown>
/**
* Extra Markers to add to the map.
*/
markers?: (`${string},${string}` | google.maps.marker.AdvancedMarkerElementOptions)[]
/**
* Map IDs for light and dark color modes.
* When provided, the map will automatically switch styles based on color mode.
* Requires @nuxtjs/color-mode or manual colorMode prop.
*/
mapIds?: { light?: string, dark?: string }
/**
* Manual color mode control. When provided, overrides auto-detection from @nuxtjs/color-mode.
* Accepts 'light', 'dark', or a reactive ref.
*/
colorMode?: 'light' | 'dark'
}>(), {
// @ts-expect-error untyped
trigger: ['mouseenter', 'mouseover', 'mousedown'],
width: 640,
height: 400,
centerMarker: true,
})
const emits = defineEmits<{
// our emit
ready: [e: typeof googleMaps]
error: []
}>()
const apiKey = props.apiKey || scriptRuntimeConfig('googleMaps')?.apiKey
const runtimeConfig = useRuntimeConfig()
const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy
const geocodeProxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleGeocodeProxy
// Color mode support - try to auto-detect from @nuxtjs/color-mode
const nuxtApp = tryUseNuxtApp()
const nuxtColorMode = nuxtApp?.$colorMode as { value: string } | undefined
const currentColorMode = computed(() => {
if (props.colorMode)
return props.colorMode
if (nuxtColorMode?.value)
return nuxtColorMode.value === 'dark' ? 'dark' : 'light'
return 'light'
})
const currentMapId = computed(() => {
if (!props.mapIds)
return props.mapOptions?.mapId
return props.mapIds[currentColorMode.value] || props.mapIds.light || props.mapOptions?.mapId
})
const mapsApi = ref<typeof google.maps | undefined>()
if (import.meta.dev && !apiKey)
throw new Error('GoogleMaps requires an API key. Please provide `apiKey` on the <ScriptGoogleMaps> or globally via `runtimeConfig.public.scripts.googleMaps.apiKey`.')
// TODO allow a null center may need to be resolved via an API function
const rootEl = ref<HTMLElement>()
const mapEl = ref<HTMLElement>()
const centerOverride = ref()
const trigger = useScriptTriggerElement({ trigger: props.trigger, el: rootEl })
const { load, status, onLoaded } = useScriptGoogleMaps({
apiKey: props.apiKey,
scriptOptions: {
trigger,
},
region: props.region,
language: props.language,
v: props.version,
})
const options = computed(() => {
const mapId = props.mapOptions?.styles ? undefined : (currentMapId.value || 'map')
return defu({ center: centerOverride.value, mapId }, props.mapOptions, {
center: props.center,
zoom: 15,
})
})
const ready = ref(false)
const map: ShallowRef<google.maps.Map | undefined> = shallowRef()
const mapMarkers: Ref<Map<string, Promise<google.maps.marker.AdvancedMarkerElement>>> = ref(new Map())
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()
})
}
function normalizeAdvancedMapMarkerOptions(_options?: google.maps.marker.AdvancedMarkerElementOptions | `${string},${string}`) {
const opts = typeof _options === 'string'
? {
position: {
lat: Number.parseFloat(_options.split(',')[0] || '0'),
lng: Number.parseFloat(_options.split(',')[1] || '0'),
},
}
: _options || {}
if (!opts.position) {
// set default
opts.position = {
lat: 0,
lng: 0,
}
}
return opts
}
async function createAdvancedMapMarker(_options?: google.maps.marker.AdvancedMarkerElementOptions | `${string},${string}`) {
if (!_options)
return
const normalizedOptions = normalizeAdvancedMapMarkerOptions(_options)
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 mapMarkerOptions = {
...toRaw(normalizedOptions),
map: toRaw(map.value!),
}
resolve(new lib.AdvancedMarkerElement(mapMarkerOptions))
})
mapMarkers.value.set(key, p)
return p
}
const queryToLatLngCache = new Map<string, google.maps.LatLng>()
async function resolveQueryToLatLang(query: string) {
if (query && typeof query === 'object')
return Promise.resolve(query)
if (queryToLatLngCache.has(query)) {
return Promise.resolve(queryToLatLngCache.get(query))
}
// Use server-side geocode proxy when enabled to save Places API costs
if (geocodeProxyConfig?.enabled) {
const data = await $fetch<{ lat: number, lng: number }>('/_scripts/google-maps-geocode-proxy', {
query: { input: query },
}).catch(() => null)
if (data) {
const latLng = new mapsApi.value!.LatLng(data.lat, data.lng)
queryToLatLngCache.set(query, latLng)
return latLng
}
}
// Fallback to client-side Places API
// eslint-disable-next-line no-async-promise-executor
return new Promise<google.maps.LatLng>(async (resolve, reject) => {
if (!mapsApi.value) {
await load()
// await new promise, watch until mapsApi is set
await new Promise<void>((resolve) => {
const _ = watch(mapsApi, () => {
_()
resolve()
})
})
}
const placesService = new mapsApi.value!.places.PlacesService(map.value!)
placesService.findPlaceFromQuery({
query,
fields: ['name', 'geometry'],
}, (results, status) => {
if (status === 'OK' && results?.[0]?.geometry?.location)
return resolve(results[0].geometry.location)
return reject(new Error(`No location found for ${query}`))
})
}).then((res) => {
queryToLatLngCache.set(query, res)
return res
})
}
const libraries = new Map<string, any>()
function importLibrary(key: 'marker'): Promise<google.maps.MarkerLibrary>
function importLibrary(key: 'places'): Promise<google.maps.PlacesLibrary>
function importLibrary(key: 'geometry'): Promise<google.maps.GeometryLibrary>
function importLibrary(key: 'drawing'): Promise<google.maps.DrawingLibrary>
function importLibrary(key: 'visualization'): Promise<google.maps.VisualizationLibrary>
function importLibrary(key: string): Promise<any>
function importLibrary<T>(key: string): Promise<T> {
if (libraries.has(key))
return libraries.get(key)
const p = mapsApi.value?.importLibrary(key) || new Promise((resolve) => {
const stop = watch(mapsApi, (api) => {
if (api) {
const p = api.importLibrary(key)
resolve(p)
stop()
}
}, { immediate: true })
})
// Clear cache on failure to allow retry
const cached = Promise.resolve(p).catch((err) => {
libraries.delete(key)
throw err
})
libraries.set(key, cached)
return cached as Promise<T>
}
const googleMaps = {
googleMaps: mapsApi,
map,
createAdvancedMapMarker,
resolveQueryToLatLang,
importLibrary,
} as const
defineExpose(googleMaps)
provide(MAP_INJECTION_KEY, { map, mapsApi })
onMounted(() => {
watch(ready, (v) => {
if (v) {
emits('ready', googleMaps)
}
})
watch(status, (v) => {
if (v === 'error') {
emits('error')
}
})
watch(options, () => {
map.value?.setOptions(options.value)
})
watch([() => props.markers, map], async () => {
if (!map.value) {
return
}
// mapMarkers is a map where we hash the next array entry as the map key
// we need to do a diff to see what we remove or add
const nextMap = new Map((props.markers || []).map(m => [hash({ position: normalizeAdvancedMapMarkerOptions(m).position }), m]))
// compare idsToMatch in nextMap, if we're missing an id, we need to remove it
const toRemove = new Set([
...mapMarkers.value.keys(),
].filter(k => !nextMap.has(k)))
// compare to existing
const toAdd = new Set([...nextMap.keys()].filter(k => !mapMarkers.value.has(k)))
// do a diff of next and prev
const centerHash = hash({ position: options.value.center })
for (const key of toRemove) {
if (props.centerMarker && key === centerHash) {
continue
}
const marker = await mapMarkers.value.get(key)
if (marker) {
resetMapMarkerMap(marker)
.then(() => {
mapMarkers.value.delete(key)
})
}
}
for (const k of toAdd) {
createAdvancedMapMarker(nextMap.get(k))
}
}, {
immediate: true,
deep: true,
})
watch([() => options.value.center, ready, map], async (next, prev) => {
if (!map.value) {
return
}
let center = toRaw(next[0])
if (center) {
if (isLocationQuery(center) && ready.value) {
// need to resolve center from query
center = await resolveQueryToLatLang(center as string)
}
map.value!.setCenter(center as google.maps.LatLng)
if (props.centerMarker) {
if (options.value.mapId) {
// not allowed to use advanced markers with styles
return
}
if (prev[0]) {
const prevCenterHash = hash({ position: prev[0] })
if (mapMarkers.value.has(prevCenterHash)) {
resetMapMarkerMap(mapMarkers.value.get(prevCenterHash)!)
.then(() => {
mapMarkers.value.delete(prevCenterHash)
})
}
}
createAdvancedMapMarker({ position: center })
}
}
}, {
immediate: true,
})
onLoaded(async (instance) => {
mapsApi.value = await instance.maps
// may need to transform the center before we can init the map
const center = options.value.center as string
const _options: google.maps.MapOptions = {
...options.value,
// @ts-expect-error broken
center: !center || isLocationQuery(center) ? undefined : center,
}
map.value = new mapsApi.value!.Map(mapEl.value!, _options)
if (center && isLocationQuery(center)) {
// need to resolve center
centerOverride.value = await resolveQueryToLatLang(center)
map.value?.setCenter(centerOverride.value)
}
ready.value = true
})
})
if (import.meta.server && !proxyConfig?.enabled) {
useHead({
link: [
{
rel: props.aboveTheFold ? 'preconnect' : 'dns-prefetch',
href: 'https://maps.googleapis.com',
},
],
})
}
function transformMapStyles(styles: google.maps.MapTypeStyle[]) {
return styles.map((style) => {
const feature = style.featureType ? `feature:${style.featureType}` : ''
const element = style.elementType ? `element:${style.elementType}` : ''
const rules = (style.stylers || []).map((styler) => {
return Object.entries(styler).map(([key, value]) => {
if (key === 'color' && typeof value === 'string') {
value = value.replace('#', '0x')
}
return `${key}:${value}`
}).join('|')
}).filter(Boolean).join('|')
return [feature, element, rules].filter(Boolean).join('|')
}).filter(Boolean)
}
const placeholder = computed(() => {
let center = options.value.center
if (center && typeof center === 'object') {
center = `${center.lat},${center.lng}`
}
// @ts-expect-error lazy type
const placeholderOptions: PlaceholderOptions = defu(props.placeholderOptions, {
// only map option values
zoom: options.value.zoom,
center,
}, {
size: `${props.width}x${props.height}`,
// Only include API key if not using proxy (proxy injects it server-side)
key: proxyConfig?.enabled ? undefined : apiKey,
scale: 2, // we assume a high DPI to avoid hydration issues
style: props.mapOptions?.styles ? transformMapStyles(props.mapOptions.styles) : undefined,
map_id: currentMapId.value,
markers: [
...(props.markers || []),
props.centerMarker && center,
]
.filter(Boolean)
.map((m) => {
if (typeof m === 'object' && m.location) {
m = m.location
}
if (typeof m === 'object' && m.lat) {
return `${m.lat},${m.lng}`
}
return m
})
.join('|'),
})
const baseUrl = proxyConfig?.enabled
? '/_scripts/google-static-maps-proxy'
: 'https://maps.googleapis.com/maps/api/staticmap'
return withQuery(baseUrl, placeholderOptions as QueryObject)
})
const placeholderAttrs = computed(() => {
return defu(props.placeholderAttrs, {
src: placeholder.value,
alt: 'Google Maps Static Map',
loading: props.aboveTheFold ? 'eager' : 'lazy',
style: {
cursor: 'pointer',
width: '100%',
objectFit: 'cover',
height: '100%',
},
} satisfies ImgHTMLAttributes)
})
const rootAttrs = computed(() => {
return defu(props.rootAttrs, {
'aria-busy': status.value === 'loading',
'aria-label': status.value === 'awaitingLoad'
? 'Google Maps Static Map'
: status.value === 'loading'
? 'Google Maps Map Embed Loading'
: 'Google Maps Embed',
'aria-live': 'polite',
'role': 'application',
'style': {
cursor: 'pointer',
position: 'relative',
maxWidth: '100%',
width: `${props.width}px`,
height: `'auto'`,
aspectRatio: `${props.width}/${props.height}`,
},
...(trigger instanceof Promise ? trigger.ssrAttrs || {} : {}),
}) as HTMLAttributes
})
onBeforeUnmount(async () => {
await Promise.all([...mapMarkers.value.entries()].map(([,marker]) => resetMapMarkerMap(marker)))
mapMarkers.value.clear()
map.value?.unbindAll()
map.value = undefined
mapEl.value?.firstChild?.remove()
})
</script>
<template>
<div ref="rootEl" v-bind="rootAttrs">
<div v-show="ready" ref="mapEl" :style="{ width: '100%', height: '100%', maxWidth: '100%' }" />
<slot v-if="!ready" :placeholder="placeholder" name="placeholder">
<img v-bind="placeholderAttrs">
</slot>
<slot v-if="status !== 'awaitingLoad' && !ready" name="loading">
<ScriptAriaLoadingIndicator />
</slot>
<slot v-if="status === 'awaitingLoad'" name="awaitingLoad" />
<slot v-else-if="status === 'error'" name="error" />
<slot />
</div>
</template>