spatiall-studio-sdk
v0.2.5
Published
TypeScript SDK for integrating a 3D/panorama viewer for Spatiall Studio data into any web-based project.
Maintainers
Readme
Spatiall Viewer SDK
A lightweight SDK that provides an easy-to-use interface for integrating a 3D/panorama viewer for Spatiall Studio data into any web-based project.
Overview
SpatiallViewer is a lightweight JavaScript SDK that:
- Automatically loads the underlying viewer engine
- Provides a simple class-based API
- Works with any JavaScript framework (React, Vue, Next.js, Vanilla JS, etc.)
- Uses ES modules for modern JavaScript integration
Installation
From npm
bun add spatiall-studio-sdk
# or: npm install spatiall-studio-sdkThen import it by package name:
import { SpatiallViewer } from 'spatiall-studio-sdk';Using a Local Build (bun link)
To develop against an unpublished local build, link the SDK in two steps.
First, register the SDK as a global link. Run this in the SDK repo — it builds dist/ and then registers the package globally:
# In the spatiall-viewer SDK repo
bun run linkThen link it into your own project:
# In your project
bun link spatiall-studio-sdkNow import as normal:
import { SpatiallViewer } from 'spatiall-studio-sdk';Changes to the SDK source are picked up after rebuilding it with bun run build in the SDK repo. For the full local-dev workflow, see DEVELOPMENT.md in the source repository.
How to Use
In client applications, import the SDK from the package name. The dist/ folder is only used by the internal example inside this repository.
1. Include in Your HTML
The SDK uses ES modules. Create your HTML with a module script:
<!DOCTYPE html>
<html>
<head>
<title>My Project</title>
</head>
<body>
<div id="viewer"></div>
<!-- Load your app as an ES module -->
<script type="module" src="app.js"></script>
</body>
</html>2. Import and Initialize
For an example integration, see /example.
TypeScript:
import { SpatiallViewer, type InitializeOptions } from 'spatiall-studio-sdk';
document.addEventListener('DOMContentLoaded', async () => {
const viewer = new SpatiallViewer({
apiKey: 'your-api-key',
targetElement: document.getElementById('viewer') ?? document.body,
});
// Loads the viewer engine. The viewer login itself happens on the first
// navigation, once a coordinate is known (see note below).
await viewer.initialize();
// Resolves the publication for the coordinate, logs into the publication
// with the returned token, and navigates to the photo location.
await viewer.setFocusPosition(6.5963340441074845, 53.085994556165765);
viewer.on('NAVIGATION_CHANGED', (data) => {
console.log('Moved to', data.x, data.y);
});
});Alternatively, the coordinates can be passed directly into initialize to resolve, log in, and navigate to a starting photo on load:
import { SpatiallViewer, type InitializeOptions } from 'spatiall-studio-sdk';
document.addEventListener('DOMContentLoaded', async () => {
const viewer = new SpatiallViewer({
apiKey: 'your-api-key',
targetElement: document.getElementById('viewer') ?? document.body,
});
const options: InitializeOptions = {
initialX: 6.5963340441074845,
initialY: 53.085994556165765,
};
await viewer.initialize(options);
});Or plain JavaScript (the published types still provide editor IntelliSense):
import { SpatiallViewer } from 'spatiall-studio-sdk';
const viewer = new SpatiallViewer({
apiKey: 'your-api-key',
targetElement: document.getElementById('viewer'),
});
await viewer.initialize({
initialX: 6.5963340441074845,
initialY: 53.085994556165765,
});If you call
initialize()without initial coordinates, the viewer login is deferred to the firstgoTo/setFocusPosition. Each navigation re-resolves; crossing into a different publication transparently re-logs into it.
Exported Types
import type {
SpatiallViewerOptions,
InitializeOptions,
NavigationChangedPayload,
ViewDirectionChangedPayload,
SpatiallViewerEventMap,
MeasurementFeature,
FetchPhotoLocationsOptions,
PhotoLocation,
PhotoLocationCollection,
PhotoLocationProps,
} from 'spatiall-studio-sdk';Framework Integration
React Example
import { useEffect, useRef } from 'react';
import { SpatiallViewer } from 'spatiall-studio-sdk';
export function MapViewer() {
const divRef = useRef(null);
useEffect(() => {
let viewer = null;
const initViewer = async () => {
viewer = new SpatiallViewer({
apiKey: 'your-api-key',
targetElement: divRef.current,
});
await viewer.initialize();
await viewer.setFocusPosition(6.5963340441074845, 53.085994556165765);
};
initViewer();
return () => {
if (viewer) viewer.destroy();
};
}, []);
return <div ref={divRef} style={{ width: '100%', height: '100vh' }} />;
}Next.js Example
'use client';
import { useEffect, useRef } from 'react';
export default function MapViewer() {
const divRef = useRef(null);
useEffect(() => {
let viewer = null;
const initViewer = async () => {
// Dynamic import for client-side only
const { SpatiallViewer } = await import('spatiall-studio-sdk');
viewer = new SpatiallViewer({
apiKey: 'your-api-key',
targetElement: divRef.current,
});
await viewer.initialize();
await viewer.setFocusPosition(6.5963340441074845, 53.085994556165765);
};
initViewer();
return () => {
if (viewer) viewer.destroy();
};
}, []);
return <div ref={divRef} style={{ width: '100%', height: '100vh' }} />;
}API Reference
Constructor
new SpatiallViewer(options);Options:
apiKey(string, required): Organization API key. Sent to the resolve endpoint asAuthorization: Api-Key <apiKey>targetElement(HTMLElement, optional): DOM element where the viewer will be rendered. Defaults todocument.bodyif not specifiedapplicationName(string, optional): Custom application name. Defaults to "Spatiall Viewer"defaultCrs(string, optional): Coordinate reference system this viewer instance uses whenever no other one is given — both forx/yarguments you pass in and for the coordinates the viewer reports back in its events (default: '4326'); Supported srids: 4326; 28992.
Set defaultCrs once and the whole instance speaks that CRS:
const viewer = new SpatiallViewer({ apiKey, targetElement, defaultCrs: '28992' });
await viewer.initialize();
// No crs argument needed — 28992 is the default now.
await viewer.setFocusPosition(233413, 581099);
viewer.on('NAVIGATION_CHANGED', ({ x, y, crs }) => {
console.log(x, y, crs); // 233413 581099 '28992'
});Methods
initialize(options)
Asynchronously initializes the viewer. This is the main entry point.
await viewer.initialize({
initialX: 6.5963340441074845, // Optional
initialY: 53.085994556165765, // Optional
initialCrs: '4326', // Optional, defaults to the instance's `defaultCrs`
});Parameters:
options(Object, optional)initialX(number): Initial X coordinate to navigate toinitialY(number): Initial Y coordinate to navigate toinitialCrs(string): Coordinate reference system ofinitialX/initialYonly — it does not change the CRS of anything else. Defaults to the instance'sdefaultCrs(itself '4326' unless set on the constructor); Supported srids: 4326; 28992.
goTo(x, y, [crs])
Navigate to a specific location.
await viewer.goTo(6.5963340441074845, 53.085994556165765, '4326');Parameters:
x(number): X coordinatey(number): Y coordinatecrs(string, optional): Coordinate reference system (defaults to the instance'sdefaultCrs, itself '4326' unless set)
addOverlay(geojson, name, [sourceSrs], color)
Draw a vector overlay layer from GeoJSON. The layer becomes a named overlay resource in the viewer, so calling addOverlay again with the same name replaces the previous geometry rather than stacking a second copy.
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {},
geometry: {
type: 'Polygon',
coordinates: [
[
[6.5963, 53.0859],
[6.5965, 53.0859],
[6.5965, 53.0861],
[6.5963, 53.0861],
[6.5963, 53.0859],
],
],
},
},
],
};
await viewer.addOverlay(geojson, 'my-layer', '4326', '#cc0000');Parameters:
geojson(GeoJSON, required): A GeoJSONGeometry,Feature, orFeatureCollection. Coordinates are interpreted insourceSrs.name(string, required): Unique layer name. This is also the overlay resource name used byremoveOverlay, so reusing a name replaces that layer.sourceSrs(string, optional): EPSG code of the GeoJSON coordinates (e.g.'4326'for lon/lat). Defaults to'28992'(RD / Rijksdriehoek).color(string, required): Layer color as a hex value (#rgbor#rrggbb). Used for a solid outline and a semi-transparent polygon fill.
Notes:
- The viewer must be initialized first; calling
addOverlaybeforeinitialize()throws. - The call waits for the viewer to be ready and the workspace to open before drawing, so it is safe to call right after
initialize().
setFocusPosition(x, y, [crs], [year])
Navigate to a specific location.
await viewer.setFocusPosition(6.5963340441074845, 53.085994556165765, '4326');
// Open the 2024 panorama at the location:
await viewer.setFocusPosition(6.5963340441074845, 53.085994556165765, '4326', 2024);Parameters:
x(number): X coordinatey(number): Y coordinatecrs(string, optional): Coordinate reference system (defaults to the instance'sdefaultCrs, itself '4326' unless set)year(number, optional): Capture year of the imagery to show at the location. How the viewer gets there, and what it shows when that year has no imagery nearby, depends on the viewer implementation.
removeOverlay(name)
Remove a previously drawn overlay layer by name.
await viewer.removeOverlay('my-layer');Parameters:
name(string, required): The layer name passed toaddOverlay.
If no layer with that name exists, the call is a no-op.
fetchPhotoLocations(options)
Fetch photo capture locations within a map viewport as GeoJSON, so you can show them on your own map (Mapbox, MapLibre, Leaflet, OpenLayers, …) and let users click one to open that photo in the viewer.
Returns a GeoJSON FeatureCollection of Point features ([lon, lat]), each with PhotoLocationProps: { id, utcTimestamp, year, pan, tilt, roll }.
const photos = await viewer.fetchPhotoLocations({
bbox: [4.8, 52.7, 4.83, 52.72], // current map view: [minLon, minLat, maxLon, maxLat]
year: 2023, // optional: a single campaign year
});Parameters:
options(Object, required)bbox([minLon, minLat, maxLon, maxLat], required): the current map view, in WGS84 (EPSG:4326).year(number, optional): restrict to one recording campaign year.limit(number, optional): max features to return (default5000).signal(AbortSignal, optional): cancel an in-flight request, e.g. when the map moves again.
Supported CRS:
- WGS84 (EPSG:4326)
The dataset is very large, so always pass the current bbox and re-fetch when the map moves. A point carries no photo URL — open its photo by passing its coordinates to setFocusPosition:
// Reload photo locations whenever the map stops moving. Read the current
// view bounds in the form of:
// [minLon, minLat, maxLon, maxLat].
map.on('moveend', async () => {
const bbox = [minLon, minLat, maxLon, maxLat];
const photos = await viewer.fetchPhotoLocations({ bbox });
map.getSource('photos').setData(photos); // a GeoJSON source you added once
});
// Click a point -> open its photo in the viewer.
map.on('click', 'photos', (e) => {
const [lon, lat] = e.features[0].geometry.coordinates;
viewer.setFocusPosition(lon, lat);
});on(event, callback)
Subscribe to a viewer event. The callback receives that event's payload (see Events below).
viewer.on('NAVIGATION_CHANGED', ({ x, y }) => {
console.log('Moved to', x, y);
});off(event, callback)
Remove a previously registered handler. Pass the same function reference used with on.
viewer.off('NAVIGATION_CHANGED', handler);Parameters:
event(string): Event namecallback(function): The handler reference to remove
destroy()
Destroy the viewer and clean up resources.
viewer.destroy();Events
Subscribe with on(event, callback) and remove handlers with off(event, callback). Payload types are described by the exported SpatiallViewerEventMap.
NAVIGATION_CHANGED
Fires when the focus position changes, including on every goTo / setFocusPosition.
- Payload:
{ x: number, y: number, crs: string }—x/yare expressed in thedefaultCrsgiven to the constructor ('4326' when none was), andcrsstates which CRS that is. If the viewer cannot reproject intodefaultCrs, the coordinate is reported as-is andcrsnames the CRS it really is in, so the payload is always self-describing.
VIEWDIRECTION_CHANGED
Fires when the camera orientation changes.
- Payload:
{ yaw: number, pitch: number }— both angles in degrees.yawis the compass heading, in the range[0, 360):0is north,90east,180south,270west. It never returns exactly360.pitchis the vertical angle, in the range[-90, 90]:0is level with the horizon, positive values look up, negative values look down.
MEASUREMENT_CREATED
Fires when the user completes a measurement; in-progress measurements are not emitted.
- Payload:
MeasurementFeature— a 3D GeoJSONFeaturewhoseproperties.modeis'point','line', or'polygon'and whose coordinates are[longitude, latitude, height].
YEAR_CHANGED
Fires when the capture year of the panorama shown by the viewer changes, including once at startup with the year of the first photo opened.
- Payload:
{ year: number | null }— the capture year of the panorama currently shown, ornullwhen it can't be determined. The event only fires when the year actually changes, so navigating between photos of the same year stays quiet.
Notes
- The SDK automatically handles loading the viewer engine from the server
- Make sure CORS is properly configured if serving from different domains
- Your integration should include a way to set the api key by the user: Do not ship your own api key into the integration.
- The SDK creates all necessary DOM elements internally - users only need to provide a target container
Migration Guide
Navigation, event subscription, and teardown are otherwise unchanged, so most integrations are a small edit.
At a glance
| Area | POC build | This release |
| -------------- | ------------------------------------------------------------------ | ------------------------------------------------------ |
| Distribution | Copy sdk/spatiall-viewer.js into your project | Install the spatiall-studio-sdk package |
| Import | import { SpatiallViewer } from '/path/to/sdk/spatiall-viewer.js' | import { SpatiallViewer } from 'spatiall-studio-sdk' |
| Authentication | username + password | apiKey |
| Publication | Hard-coded in SDK_CONFIG | Resolved per coordinate from your API key |
| initialize() | Logs in and renders immediately | Loads the engine; renders on first navigation |
| Language | Plain JavaScript | TypeScript (ships type definitions) |
1. Install the package instead of copying a file
The SDK is no longer a file you vendor into your project. Install it (see Installation above) and import it by package name:
- import { SpatiallViewer } from '/path/to/sdk/spatiall-viewer.js';
+ import { SpatiallViewer } from 'spatiall-studio-sdk';2. Replace username / password with apiKey
Credential-based login has been removed. The constructor now takes a single apiKey:
const viewer = new SpatiallViewer({
- username: 'your-username',
- password: 'your-password',
+ apiKey: 'your-api-key',
targetElement: document.getElementById('viewer'),
});Constructing without an apiKey now throws SpatiallViewer requires an apiKey (previously SpatiallViewer requires username and password).
Provide a way for the end user to supply the key — do not ship your own API key in the integration.
3. Stop configuring the host and publication
In the POC you pointed the SDK at a customer/environment by editing ORBIT_HOST and PUBLICATION_NAME in SDK_CONFIG. That step is gone, and the viewer is no longer tied to a single publication for the whole session:
If your integration edited SDK_CONFIG, remove that entirely.
4. initialize() no longer logs in on its own
Because a publication can only be resolved once a coordinate is known, initialize() now just loads the engine. It does not log in or render anything by itself. (It also no longer waits on the POC's fixed 2-second startup delay.)
To reproduce the POC's "renders immediately" behavior, pass initial coordinates so the SDK resolves, logs in, and navigates in one step:
await viewer.initialize({
initialX: 6.5963340441074845,
initialY: 53.085994556165765,
});If you call initialize() without coordinates, nothing is shown until your first goTo() / setFocusPosition(), at which point login happens lazily. See the note under Import and Initialize above.
5. goTo() will become setFocusPosition().
goTo() is deprecated; please replace with setFocusPosition(). For now, both methods will work.
Contributing / Development
To develop on the SDK itself (build, lint, run the example), see DEVELOPMENT.md in the source repository.
