Skip to main content

360° Viewer Guide

The @wemap/viewer package is a framework-agnostic 360° panorama viewer for Wemap maps. It loads the map's image graph (the 360° photos captured on site) and lets users look around and walk from photo to photo. It can also locate the point under a click and measure distances in the photos. Built on three.js, with no UI framework.

The companion @wemap/viewer-map package shows the viewer on a WemapMap: the image graph, the look direction, and a map that follows the viewer, Street View style.

Installation​

# The viewer on its own
npm install @wemap/core @wemap/geo @wemap/viewer

# The viewer on a map (maplibre-gl is a peer dependency of the map)
npm install @wemap/core @wemap/geo @wemap/map @wemap/viewer @wemap/viewer-map maplibre-gl

three and @tweenjs/tween.js are regular dependencies of the viewer: nothing else to install. If you use only the viewer, you never pull in maplibre.

Without a build step, load wemap-viewer.umd.js (Wemap.Viewer, three.js inlined) and wemap-viewer-map.umd.js (Wemap.ViewerMap). See the Installation guide for the load order.

Requirements​

  • core.init() first. Identity (emmid), auth and the VPS endpoint all come from @wemap/core; the viewer takes none of them.
  • A map with a VPS provider. Without one, open() rejects.
  • A sized container. The canvas fills its container and follows its size, so the container needs a height, like the map's:
<div id="viewer"></div>

<style>
#viewer { width: 100%; height: 60vh; }
</style>

Open the viewer​

import { core } from '@wemap/core';
import { WemapViewer } from '@wemap/viewer';
import '@wemap/viewer/style.css';

await core.init({ emmid: '31668', token: 'YOUR_TOKEN' });

const viewer = new WemapViewer(document.getElementById('viewer')!, {
onNavigate: (nodeId) => console.log('now at', nodeId),
});

// Open on a node, looking east. Resolves once its image is on screen.
await viewer.open(1059484, { heading: 90 });

open() takes a node id (a deep link, a click on the map graph) or a position. With a position, it opens on the nearest node, on the position's level when it has one:

import { Coordinates } from '@wemap/geo';

// Latitude, longitude, altitude (none), level 1.
await viewer.open(new Coordinates(43.60907, 3.91719, null, 1));

The first call fetches the image graph. Later calls move to the new node, and heading (with an optional pitch) turns the view with an animation. Without heading, the look direction is kept. Awaiting is optional: the ready event and onError report the same outcome.

Moving around​

  • Look around by dragging (with inertia), or with the arrow keys once the viewer has focus (press on it, or Tab to it). Zoom with the mouse wheel (zoom: false disables it).
  • Walk by clicking the floor: hovering shows a disc under the pointer and a cross on the nearest node; clicking walks there. Disable with navigation: false. Clicks are paused while the view moves to the next node.
  • From code: viewer.open(target, { heading }) (awaitable), or viewer.navigateTo(nodeId) (fire and forget). viewer.nearestNode(coordinates, level?) finds the node closest to a place. It needs the graph: viewer.loadGraph() fetches it without opening a photo, for example to check that a photo is close enough before opening it. viewer.lookAt(heading, pitch = 0) turns the view the short way round; dragging the panorama cancels it.

The look direction is a real compass heading, so it is kept when you change node: you keep facing the same way. Between nearby nodes, the view moves through the scene to the next capture point while the new photo fades in.

navigateTo updates currentNode and fires navigate right away, while the new image loads and fades in. If that image fails to load, the viewer goes back to the node still on screen (navigate fires again with it) and calls onError.

Locate a point​

In locate click mode, a click on the panorama gives the geo position of what is under it, from the VPS depth estimate, instead of walking. Use it to place a POI where the user points, for example:

viewer.setClickMode('locate');
viewer.on('locate', ({ coordinates, node }) => {
console.log(coordinates.lat, coordinates.lng, coordinates.alt, coordinates.level);
});

viewer.setClickMode('navigate'); // back to walking

The cursor becomes a crosshair, and clicks during a transition are ignored. The level is the clicked node's when the VPS gives none. Failures go to onError.

Measure a distance​

In measure click mode, two clicks on the panorama give the distance between the two points, from the VPS depth estimate. The viewer draws a dot on each point and a line between them. A third click clears them, as do changing mode and moving to another node.

viewer.setClickMode('measure');
viewer.on('measure', ({ distance }) => showLabel(`${distance.toFixed(2)} m`));

Show the viewer on a map​

@wemap/viewer-map connects a viewer to a WemapMap with three independent pieces; use only the ones you need:

  • ViewerGraphLayer draws the image graph (edges as lines, nodes as circles) and highlights the node being shown. Clicking it moves the viewer to the nearest node.
  • ViewerHeadingMarker shows a field-of-view cone at the current node, rotating live as the user looks around.
  • ViewerMapSync keeps the map on the viewer: when the viewer moves to a node, the map eases to it and switches to its level.

A side-by-side layout, map on the left and viewer on the right:

<div class="split">
<div id="map"></div>
<div id="viewer"></div>
</div>

<style>
.split { display: flex; height: 70vh; }
#map, #viewer { flex: 1; }
</style>
import { core } from '@wemap/core';
import { WemapMap } from '@wemap/map';
import { WemapViewer } from '@wemap/viewer';
import { ViewerGraphLayer, ViewerHeadingMarker, ViewerMapSync } from '@wemap/viewer-map';
import 'maplibre-gl/dist/maplibre-gl.css';
import '@wemap/map/style.css';
import '@wemap/viewer/style.css';
import '@wemap/viewer-map/style.css';

await core.init({ emmid: '31668', token: 'YOUR_TOKEN' });

const map = new WemapMap({ container: 'map' });
const viewer = new WemapViewer(document.getElementById('viewer')!);
await viewer.open(1059484, { heading: 90 });

// The image graph on the map; click an edge or a node to walk there.
const graph = new ViewerGraphLayer(map, viewer);
await graph.load();

// The look-direction cone.
const cone = new ViewerHeadingMarker(map, viewer);

// The map follows the viewer: re-centre and switch level on each move.
const sync = new ViewerMapSync(map, viewer, { zoom: 20 });

What the pieces draw shows only on the map's current level.

All three can be set up before or after viewer.open(). graph.load() loads the image graph itself, without opening a photo, and ViewerMapSync and ViewerHeadingMarker follow the viewer's current node as soon as they exist.

Map sync and levels​

ViewerMapSync switches the map to the level of the viewer's node on every move. Entering a building normally resets the map to that building's default level; while the sync follows levels, the viewer's node level is kept instead, so the graph and the cone stay visible.

OptionDefaultDescription
centertrueRe-centre the map on the new node
followLeveltrueSwitch the map to the node's level
zoomZoom to apply when re-centring (omit to keep the current one)

Graph layer and cone options​

ViewerGraphLayer optionDefaultDescription
nodestrueDraw the node circles
edgestrueDraw the edge lines
edgeColorWemap blueEdge colour
nodeColorWemap blueNode colour
currentColorWemap orangeHighlight of the current node
interactivetrueClick to walk, pointer on hover; false shows the graph as coverage only
ViewerHeadingMarker optionDefaultDescription
colorWemap orangeCone colour

Events​

Each constructor callback takes one listener. For more, subscribe with viewer.on(), which returns an unsubscribe function:

const off = viewer.on('heading', (heading) => compass.rotate(heading));
off();
EventOptionPayloadWhen
readyonReady—The first image is on screen (once)
navigateonNavigateViewerNodeA move to a node starts (click or code)
headingonHeadingChangenumber (°)The look heading changes
locateonLocateLocatedPointA click in locate mode was located
measureonMeasureMeasurementTwo clicks in measure mode were measured

onError reports a failure to load the graph, an image, a located point or a measurement. onNavigate receives the node id rather than the node.

Clean up​

The viewer and each map piece have a destroy(). WemapMap.remove() does not destroy the map pieces, so destroy them first:

graph.destroy();
cone.destroy();
sync.destroy();
viewer.destroy();
map.remove();

API reference​