Integrating Mapillary Street-Level Imagery with OpenLayers in Vue 3

EmailTwitterLinkedInFacebookWhatsAppShare

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 APIPinia 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 -- --typescript
cd mapilayers
npm 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:

  1. maxZoom: 14 with clamped tileUrlFunction — Mapillary tiles only go up to zoom 14. Setting maxZoom tells OpenLayers to reuse z=14 tiles at higher zoom levels (over-zooming) instead of making failing requests.
  2. Point-only style filter — The Mapillary vector tiles contain points, lines, and polygons. We return undefined for 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: false to avoid the image_ids must have at most 30 elements API error that occurs in dense coverage areas.
  • Use forEachFeatureAtPixel instead of API calls for click detection — it’s instant and uses data already loaded in the vector tiles.
  • Clamp tile zoom with maxZoom on 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.

Leave a ReplyCancel reply

Discover more from Spatial Dev Guru

Subscribe now to keep reading and get access to the full archive.

Continue reading

Discover more from Spatial Dev Guru

Subscribe now to keep reading and get access to the full archive.

Continue reading