Use Maptoolkit Maps in Vue
A map in Vue 3 is a template ref for the container, a shallowRef for the map instance, and
the onMounted and onBeforeUnmount hooks. The shallowRef is not a style preference; a
plain ref will cause real problems.
The component
<script setup>
import { ref, shallowRef, onMounted, onBeforeUnmount } from "vue";
import { Map } from "@maptoolkit/maps";
import "@maptoolkit/maps/css";
const container = ref(null);
const map = shallowRef(null);
onMounted(() => {
map.value = new Map({
container: container.value,
apiKey: import.meta.env.VITE_MAPTOOLKIT_KEY,
center: [16.37, 48.21],
zoom: 12,
});
});
onBeforeUnmount(() => {
map.value?.remove();
map.value = null;
});
</script>
<template>
<div ref="container" class="map"></div>
</template>
<style scoped>
.map {
width: 100%;
height: 400px;
}
</style>Why shallowRef and not ref
ref() makes its contents deeply reactive: Vue walks the object and wraps every nested
property in a Proxy. A map instance is a large graph with circular references, live WebGL
handles and internal caches that change on every frame. Handing it to ref() means Vue
proxies all of it.
The results range from a map that renders at a fraction of the expected frame rate to internal identity checks failing, because the library compares an object against what is now a Proxy of that object and finds them unequal. The symptoms look like library bugs and are not.
shallowRef() makes only the .value assignment reactive and leaves the instance untouched,
which is exactly what you want: you need to know when the map is created, not when its
internal tile cache changes. The same applies to markers, popups and any other library object
you keep around. markRaw() is the equivalent escape hatch for an object you must put inside a
reactive structure.
The container needs a height
A div with no height is zero pixels tall and the map initializes into nothing. No error, no
map. In a scoped style block this is easy to miss, because the rule lives somewhere other than
the element it applies to.
Reacting to changes
Drive the map imperatively from a watcher rather than recreating it:
watch(selected, (next) => {
map.value?.flyTo({ center: next.coords, zoom: 14 });
});For objects that belong to the map, create them in the watcher and clean them up with the cleanup callback:
watch(selected, (next, prev, onCleanup) => {
if (!map.value) return;
const marker = new Marker().setLngLat(next.coords).addTo(map.value);
onCleanup(() => marker.remove());
});Waiting for the style
Adding a source or a layer before the style has loaded throws “Style is not done loading”. Wait for the event:
map.value.on("load", () => {
map.value.addSource("route", { type: "geojson", data: routeGeoJSON });
map.value.addLayer({ id: "route", type: "line", source: "route" });
});Nuxt
Nuxt server renders by default, and the library touches window at import time, so the import
throws on the server. Keep the map out of the server bundle: name the component with a
.client.vue suffix, or wrap it in <ClientOnly>. Give the <ClientOnly> fallback the same
height as the map so the page does not shift when it appears.
The API key
VITE_ variables are inlined into the client bundle at build time, so the key is readable in
the browser. That is inherent to a map key, because the browser sends it with every tile
request. Restrict it to your domains rather than trying to hide it. A variable without the
VITE_ prefix is not exposed to client code and arrives as undefined, which shows up as a
403 Access denied! from the tile host.
Next steps
- Maps JS API Reference for the classes used above.
- Maps JS Examples for what to do with the map once it is mounted.
- First Steps for getting and restricting an API key.