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: falsedisables 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), orviewer.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:
ViewerGraphLayerdraws the image graph (edges as lines, nodes as circles) and highlights the node being shown. Clicking it moves the viewer to the nearest node.ViewerHeadingMarkershows a field-of-view cone at the current node, rotating live as the user looks around.ViewerMapSynckeeps 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.
| Option | Default | Description |
|---|---|---|
center | true | Re-centre the map on the new node |
followLevel | true | Switch the map to the node's level |
zoom | Zoom to apply when re-centring (omit to keep the current one) |
Graph layer and cone options
ViewerGraphLayer option | Default | Description |
|---|---|---|
nodes | true | Draw the node circles |
edges | true | Draw the edge lines |
edgeColor | Wemap blue | Edge colour |
nodeColor | Wemap blue | Node colour |
currentColor | Wemap orange | Highlight of the current node |
interactive | true | Click to walk, pointer on hover; false shows the graph as coverage only |
ViewerHeadingMarker option | Default | Description |
|---|---|---|
color | Wemap orange | Cone 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();
| Event | Option | Payload | When |
|---|---|---|---|
ready | onReady | — | The first image is on screen (once) |
navigate | onNavigate | ViewerNode | A move to a node starts (click or code) |
heading | onHeadingChange | number (°) | The look heading changes |
locate | onLocate | LocatedPoint | A click in locate mode was located |
measure | onMeasure | Measurement | Two 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
WemapViewer:open,navigateTo,lookAt,setClickMode, events and options.ViewerGraphLayer,ViewerHeadingMarker,ViewerMapSync.