
In this tutorial, we’ll build MapiLayers — a Vue 3 application that combines OpenLayers maps with Mapillary’s street-level imagery. Users can click on coverage points on the map and instantly view street imagery in a floating panel, complete with a live camera FOV arc that rotates as the user looks around.
We’ll use Vue 3 Composition API, Pinia for state management, OpenLayers for the map, and mapillary-js for the street viewer.
To get full source code for this tutorial, click here.
To check the live demo for this tutorial, click here.
Project Setup
Scaffold a Vue 3 + TypeScript project and install the dependencies:
npm create vue@latest mapilayers -- --typescriptcd mapilayersnpm install ol mapillary-js pinia quasar @quasar/extras
You’ll also need a Mapillary access token. Create a .env file:
VITE_MAPILLARY_TOKEN=MLY|your_token_here
Architecture Overview
The app has four key pieces:
- Pinia Store (
map.ts) — Central state for map view, basemaps, Mapillary overlays, and viewer state (image ID, position, bearing, FOV) - MapViewer.vue — OpenLayers map with vector tile overlay and camera arc overlay
- MapillaryViewer.vue — Floating street view panel using mapillary-js
- LayerPanel.vue — Sidebar for toggling basemaps and Mapillary layers
Step 1: The Pinia Store
The store manages all shared state. The critical part is how we define the Mapillary vector tile layer:
import VectorTileLayer from 'ol/layer/VectorTile'
import VectorTileSource from 'ol/source/VectorTile'
import MVT from 'ol/format/MVT'
import { Style, Circle as CircleStyle, Fill } from 'ol/style'
const MAPILLARY_TOKEN = import.meta.env.VITE_MAPILLARY_TOKEN
const mapillaryOverlays = ref([
{
name: 'Street Imagery Feature Points',
layer: markRaw(
new VectorTileLayer({
source: new VectorTileSource({
format: new MVT(),
maxZoom: 14,
tileUrlFunction: (coord) => {
const z = Math.min(coord[0], 14)
const x = coord[1]
const y = coord[2]
return https://tiles.mapillary.com/maps/vtp/mly1_public/2/${z}/${x}/${y}?access_token=${MAPILLARY_TOKEN}
},
}),
style: (feature) => {
if (feature.getGeometry()?.getType() !== 'Point') return undefined
return new Style({
image: new CircleStyle({
radius: 3,
fill: new Fill({ color: '#05CB63' }),
}),
})
},
visible: false,
})
),
visible: false,
},
])
There are two important decisions here:
maxZoom: 14with clampedtileUrlFunction— Mapillary tiles only go up to zoom 14. SettingmaxZoomtells OpenLayers to reuse z=14 tiles at higher zoom levels (over-zooming) instead of making failing requests.- Point-only style filter — The Mapillary vector tiles contain points, lines, and polygons. We return
undefinedfor non-point geometries to only render image locations.
We also wrap all OpenLayers objects with markRaw() to prevent Vue’s reactivity system from deeply proxying them, which would break OpenLayers internals.
The store also tracks the Mapillary viewer state:
const mapillaryImageId = ref<string | null>(null)const mapillaryPosition = ref<LonLat | null>(null)const mapillaryBearing = ref(0)const mapillaryFov = ref(90)const mapillaryLoading = ref(false)
Step 2: The Map Component
MapViewer.vue initializes the OpenLayers map and handles click-to-view interaction.
Clicking on Coverage Points
Instead of querying the Mapillary API on every click, we use forEachFeatureAtPixel to detect clicks on actual vector tile features:
const onMapClick = (evt) => { if (!map) return let clickedFeature = null map.forEachFeatureAtPixel(evt.pixel, (feature) => { if (!clickedFeature && feature.getGeometry()?.getType() === 'Point') { clickedFeature = feature } }) if (!clickedFeature) return const imageId = clickedFeature.get('id') || clickedFeature.getId() if (!imageId) return mapStore.mapillaryLoading = true mapStore.setMapillaryImageId(String(imageId))}
This is much faster than an API call — the image ID comes directly from the vector tile feature properties.
Camera FOV Arc Overlay
We render a camera direction indicator using an SVG arc inside an OpenLayers Overlay. The arc path is generated dynamically based on the viewer’s field of view:
const makeArcPath = (fov) => {
const radius = 45, cx = 50, cy = 50
const fovRad = (Math.PI / 180) * fov
const arcStart = -Math.PI / 2 - fovRad / 2
const arcEnd = arcStart + fovRad
const sx = cx + radius * Math.cos(arcStart)
const sy = cy + radius * Math.sin(arcStart)
const ex = cx + radius * Math.cos(arcEnd)
const ey = cy + radius * Math.sin(arcEnd)
return M ${cx} ${cy} L ${sx} ${sy} A ${radius} ${radius} 0 0 1 ${ex} ${ey} Z
}
The arc rotates with the viewer’s bearing. A key gotcha here is angle wrapping — when the bearing crosses the 0/360 boundary, a naive rotation interpolates the wrong way around (360 degrees instead of a few). We fix this by tracking cumulative rotation:
let currentRotation = 0
const updateArc = () => {
// ...
let delta = mapillaryBearing.value - (((currentRotation % 360) + 360) % 360)
if (delta > 180) delta -= 360
if (delta < -180) delta += 360
currentRotation += delta
svg.style.transform = rotateZ(${currentRotation}deg)
}
The overlay position updates whenever the Mapillary viewer reports a new position:
watch(mapillaryPosition, (pos) => { if (!pos) { positionOverlay.setPosition(undefined) return } positionOverlay.setPosition(fromLonLat(pos))})
Step 3: The Mapillary Viewer
MapillaryViewer.vue is a floating panel that lazily initializes the mapillary-js Viewer when the first image is selected.
Lazy Initialization
The viewer is created inside v-show, which means the container can have zero dimensions initially. Creating the viewer too early causes internal errors. We solve this with lazy init + resize:
const ensureViewer = () => { if (viewer || !containerRef.value) return viewer = new Viewer({ accessToken: import.meta.env.VITE_MAPILLARY_TOKEN, container: containerRef.value, component: { cover: false, spatial: false, }, }) viewer.on('position', async () => { const pos = await viewer.getPosition() mapStore.setMapillaryPosition([pos.lng, pos.lat]) }) viewer.on('pov', async () => { const pov = await viewer.getPointOfView() mapStore.setMapillaryBearing(pov.bearing) }) viewer.on('fov', async () => { const container = viewer.getContainer() const aspect = container.offsetWidth / container.offsetHeight const vFov = (Math.PI / 180) * (await viewer.getFieldOfView()) const hFov = (180 / Math.PI) * Math.atan(aspect * Math.tan(0.5 * vFov)) * 2 mapStore.setMapillaryFov(hFov) })}
Note spatial: false — this disables the spatial component to avoid API errors where mapillary-js batches too many image IDs (exceeding the 30-element limit).
Navigating to Images
A watcher reacts to image ID changes, ensures the viewer exists, resizes it, and navigates:
watch(mapillaryImageId, async (imageId) => { if (!imageId) return await nextTick() ensureViewer() if (!viewer) return try { viewer.resize() await viewer.moveTo(imageId) } catch { mapStore.setMapillaryImageId(null) }})
FOV Calculation
mapillary-js reports vertical FOV, but our map arc needs horizontal FOV. The conversion uses the container’s aspect ratio:
horizontalFov = (180/PI) * atan(aspect * tan(0.5 * verticalFov)) * 2
Step 4: Wiring It All Together
App.vue brings everything together in a Quasar layout:
<template> <QLayout view="lHh Lpr lFf"> <LayerPanel v-model="drawerOpen" /> <QPageContainer> <QPage> <MapViewer /> <MapillaryViewer /> </QPage> </QPageContainer> </QLayout></template>
The components communicate entirely through the Pinia store — there are no props or emits between them. When the user clicks a point on the map, MapViewer sets the image ID in the store, MapillaryViewer reacts via a watcher, and as the user rotates in street view, the bearing/position/FOV updates flow back through the store to update the arc overlay on the map.
Key Takeaways
- Use
markRaw()for OpenLayers objects in Vue reactivity — Vue’s proxy system will break map internals otherwise. - Lazy-initialize mapillary-js — creating the viewer in a hidden container causes dimension-related errors. Wait until it’s visible and call
resize(). - Set
spatial: falseto avoid theimage_ids must have at most 30 elementsAPI error that occurs in dense coverage areas. - Use
forEachFeatureAtPixelinstead of API calls for click detection — it’s instant and uses data already loaded in the vector tiles. - Clamp tile zoom with
maxZoomon the vector tile source so features remain visible at high zoom levels via over-zooming. - Handle angle wrapping for smooth rotation transitions across the 0/360 boundary.
The complete source code is available on GitHub. Happy mapping!
I hope this tutorial will create a good foundation for you. If you want tutorials on another GIS topic or you have any queries, please send an mail at contact@spatial-dev.guru.
