Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ MapLibre adds an attribution control to every map by default. Two controls would
- When the component mounts, it removes the map's attribution control and adds its own.
- When the component unmounts, it adds the map's control back. This also happens if your code removed the component's control first.

The map therefore shows attribution exactly once. Unmounting the component does not remove the default attribution.
When no other attribution control is present, the map shows attribution exactly once. Unmounting the component does not remove the default attribution.

If you set `attributionControl: false` in the map `options`, the map has no control to restore. The component then adds its own control, and unmounting removes it.

The component replaces only the control that `<ScriptMapLibreMap>`{lang="html"} adds. If your code adds an attribution control with `map.addControl()`{lang="ts"}, that control stays on the map.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Use one `<ScriptMapLibreAttributionControl>`{lang="html"} per map. The map's control returns to its default position, bottom-right, when the component unmounts.

## Attribution text
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script setup lang="ts">
import type * as MapLibre from 'maplibre-gl'
import type { ScriptMapLibreAttributionControlProps } from './types'
import { useMapLibreResource } from './useMapLibreResource'
import { inject } from 'vue'
import { MAPLIBRE_MAP_INJECTION_KEY, useMapLibreResource } from './useMapLibreResource'

// Renders no DOM of its own. A render function that returns `null` gives a
// comment node on the server and the client. A comment-only template does not:
Expand All @@ -12,56 +13,56 @@ defineOptions({ render: () => null })
const props = defineProps<ScriptMapLibreAttributionControlProps>()

/**
* MapLibre adds its own attribution control unless the map sets
* `attributionControl: false`. This component replaces that control, so the
* map shows attribution once. Unmounting restores the replaced control, so
* required attribution never disappears.
* `<ScriptMapLibreMap>` adds the default attribution control and shares it
* through the map context. This component replaces that control, so the map
* shows attribution once. Unmounting restores it, so required attribution never
* disappears. Only public MapLibre APIs touch the control list.
*/
let replaced: MapLibre.AttributionControl[] = []
const defaultControl = inject(MAPLIBRE_MAP_INJECTION_KEY, undefined)?.defaultAttributionControl

function restore(map: MapLibre.Map): void {
for (const existing of replaced) {
if (!map.hasControl(existing))
map.addControl(existing)
}
replaced = []
/** The default control this component removed. Only this control is restored. */
let replaced: MapLibre.AttributionControl | undefined
/** A removed map has already dropped every control, and adding to it throws. */
let mapRemoved = false
function onMapRemove(): void {
mapRemoved = true
}

function mergeCredits(controls: MapLibre.AttributionControl[]): string[] | undefined {
const credits = [...new Set(controls.flatMap(existing => existing.options.customAttribution ?? []))]
return credits.length ? credits : undefined
function restore(map: MapLibre.Map): void {
if (replaced && !mapRemoved && !map.hasControl(replaced))
map.addControl(replaced)
replaced = undefined
}

const control = useMapLibreResource<MapLibre.AttributionControl>({
create({ maplibre, map }) {
// `_controls` is the list that `hasControl()` reads. `filter` copies it before removal.
replaced = map._controls.filter((existing): existing is MapLibre.AttributionControl => existing instanceof maplibre.AttributionControl)
// The component options override the map's `attributionControl` options.
// Credits from every replaced control stay unless the component sets its own.
const inherited = replaced[0]?.options
const builtIn = defaultControl?.value
replaced = builtIn && map.hasControl(builtIn) ? builtIn : undefined
// The component options override the options of the map default.
// The credits of the map default stay unless the component sets its own.
const inherited = replaced?.options
const options = inherited || props.options
? { ...inherited, ...props.options, customAttribution: props.options?.customAttribution ?? mergeCredits(replaced) }
? { ...inherited, ...props.options, customAttribution: props.options?.customAttribution ?? inherited?.customAttribution }
: undefined
const instance = new maplibre.AttributionControl(options)
for (const existing of replaced)
map.removeControl(existing)
map.on('remove', onMapRemove)
if (replaced)
map.removeControl(replaced)
try {
map.addControl(instance, props.position)
}
catch (error) {
restore(map)
map.off('remove', onMapRemove)
throw error
}
return instance
},
cleanup(instance, { map }) {
map.off('remove', onMapRemove)
if (map.hasControl(instance))
map.removeControl(instance)
// A removed map has already dropped every control, and adding to it throws.
if (map._removed)
replaced = []
else
restore(map)
restore(map)
},
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const { load, status, onLoaded, onError } = useScriptMapLibre({

const maplibre = shallowRef() as VueShallowRef<typeof MapLibre | undefined>
const map = shallowRef<MapLibre.Map>()
const defaultAttributionControl = shallowRef<MapLibre.AttributionControl>()
const isMapReady = shallowRef(false)
const loadError = shallowRef(new Error('MapLibre failed to load'))
const initializationError = shallowRef<Error>()
Expand Down Expand Up @@ -78,6 +79,7 @@ defineExpose<ScriptMapLibreMapExpose>(exposed)
provide(MAPLIBRE_MAP_INJECTION_KEY, {
maplibre: maplibre as unknown as MapLibreMapContext['maplibre'],
map,
defaultAttributionControl,
})

function bindMapEvents(instance: MapLibre.Map): void {
Expand Down Expand Up @@ -137,8 +139,14 @@ onMounted(() => {
maplibre.value = instance.maplibregl
let mapInstance: MapLibre.Map | undefined
try {
const mapOptions = toRaw(props.options)
const attributionOptions = mapOptions?.attributionControl
mapInstance = new instance.maplibregl.Map({
...toRaw(props.options),
...mapOptions,
// The component adds the default attribution control itself, so
// `<ScriptMapLibreAttributionControl>` can replace it through the public API.
attributionControl: false,
maplibreLogo: false,
container: mapEl.value,
style: toRaw(props.mapStyle),
center: toRaw(props.center),
Expand All @@ -147,6 +155,16 @@ onMounted(() => {
pitch: props.pitch,
interactive: props.interactive,
})
// MapLibre's constructor adds the attribution control first and the logo
// second, and a bottom corner renders its first child on top. Adding the
// controls in that order here keeps the native vertical order when the
// logo shares the attribution corner.
if (attributionOptions !== false) {
defaultAttributionControl.value = new instance.maplibregl.AttributionControl(typeof attributionOptions === 'object' ? attributionOptions : undefined)
mapInstance.addControl(defaultAttributionControl.value)
}
if (mapOptions?.maplibreLogo)
mapInstance.addControl(new instance.maplibregl.LogoControl(), mapOptions.logoPosition)
configureCanvasAccessibility(mapInstance)
bindMapEvents(mapInstance)
map.value = mapInstance
Expand All @@ -160,6 +178,7 @@ onMounted(() => {
}
catch (error) {
mapInstance?.remove()
defaultAttributionControl.value = undefined
const cause = error instanceof Error ? error : new Error('MapLibre map initialization failed')
initializationError.value = cause
loadError.value = cause
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { inject, onUnmounted, shallowRef, watch } from 'vue'
export interface MapLibreMapContext {
map: ShallowRef<MapLibre.Map | undefined>
maplibre: ShallowRef<typeof MapLibre | undefined>
/**
* The attribution control that `<ScriptMapLibreMap>` adds for the
* `attributionControl` map option. It is `undefined` when that option is `false`.
*/
defaultAttributionControl: ShallowRef<MapLibre.AttributionControl | undefined>
}

export const MAPLIBRE_MAP_INJECTION_KEY = Symbol('maplibre-map') as InjectionKey<MapLibreMapContext>
Expand Down
43 changes: 43 additions & 0 deletions test/e2e/maplibre.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,49 @@ describe('maplibre in a real browser', { timeout: 60000 }, async () => {
expect(center[0]).toBeCloseTo(-1.2, 3)
})

it('keeps the native vertical order when the logo shares the attribution corner', async () => {
const page = await openMap('/logo')
const readCorner = () => page.evaluate(() => {
const corner = document.querySelector<HTMLElement>('.maplibregl-ctrl-bottom-right')
const logo = corner?.querySelector<HTMLElement>('.maplibregl-ctrl-logo')?.closest<HTMLElement>('.maplibregl-ctrl')
const attribution = corner?.querySelector<HTMLElement>('.maplibregl-ctrl-attrib')
if (!corner || !logo || !attribution)
return null
// A native map renders the logo above the attribution when both sit in a
// bottom corner, which is the DOM order inside the corner container.
return Boolean(logo.compareDocumentPosition(attribution) & Node.DOCUMENT_POSITION_FOLLOWING)
})
await expect.poll(readCorner).toBe(true)
})

it('shows the default attribution once on a map without an attribution control', async () => {
const page = await openMap('/style-swap')
const readAttribution = () => page.evaluate(() => [...document.querySelectorAll('.maplibregl-ctrl-attrib')].map(control => control.textContent!.trim()))
await expect.poll(readAttribution).toEqual([expect.stringContaining('MapLibre')])
})

it('shows attribution exactly once while the attribution control mounts, unmounts and remounts', async () => {
const page = await openMap('/attribution')
const readAttribution = () => page.evaluate(() => [...document.querySelectorAll('.maplibregl-ctrl-attrib')].map(control => ({
corner: [...control.parentElement!.classList].find(name => name.startsWith('maplibregl-ctrl-bottom') || name.startsWith('maplibregl-ctrl-top')),
text: control.textContent!.trim(),
})))
const toggle = () => page.click('#toggle-control')
// The component inherits the credits of the map default, and the style source adds its own.
const credits = expect.stringMatching(/^(?=.*Map credits)(?=.*Source credits)/)

await expect.poll(readAttribution).toEqual([{ corner: 'maplibregl-ctrl-bottom-left', text: credits }])

await toggle()
await expect.poll(readAttribution).toEqual([{ corner: 'maplibregl-ctrl-bottom-right', text: credits }])

await toggle()
await expect.poll(readAttribution).toEqual([{ corner: 'maplibregl-ctrl-bottom-left', text: credits }])

await toggle()
await expect.poll(readAttribution).toEqual([{ corner: 'maplibregl-ctrl-bottom-right', text: credits }])
})

it.each(['diff', 'full'])('emits sourceready after a %s style swap so feature state can be restored', async (mode) => {
const page = await openMap('/style-swap')
const readLog = async () => JSON.parse(await page.locator('#log').textContent() ?? '{}') as { styleload: boolean[], sourceready: string[] }
Expand Down
48 changes: 48 additions & 0 deletions test/fixtures/maplibre/pages/attribution.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { ScriptMapLibreMapExpose } from '@nuxt/scripts'
import type { StyleSpecification } from 'maplibre-gl'
import { shallowRef } from 'vue'

// An inline GeoJSON source with its own credit, so the source attribution
// renders without a network request.
const style: StyleSpecification = {
...blankStyle('#ffffff'),
sources: {
points: {
type: 'geojson',
attribution: 'Source credits',
data: points([{ id: 1, name: 'one', kind: 'site', position: [0, 0] }]),
},
},
layers: [
...blankStyle('#ffffff').layers,
{ id: 'sites', type: 'circle', source: 'points', paint: { 'circle-radius': 6 } },
],
}

const showControl = shallowRef(true)

function onReady({ map }: ScriptMapLibreMapExpose): void {
exposeMap(map.value)
;(window as any).__ready = true
}
</script>

<template>
<div>
<ScriptMapLibreMap
trigger="immediate"
:map-style="style"
:center="[0, 0]"
:width="400"
:height="300"
:options="{ attributionControl: { compact: false, customAttribution: 'Map credits' } }"
@ready="onReady"
>
<ScriptMapLibreAttributionControl v-if="showControl" position="bottom-left" :options="{ compact: false }" />
</ScriptMapLibreMap>
<button id="toggle-control" type="button" @click="showControl = !showControl">
Toggle attribution control
</button>
</div>
</template>
24 changes: 24 additions & 0 deletions test/fixtures/maplibre/pages/logo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { ScriptMapLibreMapExpose } from '@nuxt/scripts'

const style = blankStyle('#ffffff')

function onReady({ map }: ScriptMapLibreMapExpose): void {
exposeMap(map.value)
;(window as any).__ready = true
}
</script>

<template>
<div>
<ScriptMapLibreMap
trigger="immediate"
:map-style="style"
:center="[0, 0]"
:width="400"
:height="300"
:options="{ maplibreLogo: true, logoPosition: 'bottom-right' }"
@ready="onReady"
/>
</div>
</template>
Loading
Loading