SpatialWork · Integration guide
Put a live 3D scene inside your own web page, and let clicks in that scene drive your dashboard. One iframe, one message listener, no SDK and no build step.
Two things are always required: the iframe, and a listener. The scene will render from the iframe alone — but it can only drive your page if your page is listening. This is exactly what the Embed button copies.
<iframe id="sw" width="100%" height="600" style="border:0" allow="fullscreen"></iframe>
<script>
const SW_ORIGIN = 'https://spatialwork.hiverlab.com'; // no trailing slash
const sw = document.getElementById('sw');
window.addEventListener('message', (e) => {
// Only the scene in THIS iframe, from the viewer's exact origin.
if (e.origin !== SW_ORIGIN || e.source !== sw.contentWindow) return;
if (e.data?.source !== 'spatialwork') return;
const { type, payload } = e.data;
if (type === 'ready') console.log('scene ready', payload.objects); // before assets finish
if (type === 'loaded') console.log('scene loaded', payload.objects); // everything on screen
if (type === 'object-click') console.log('clicked', payload); // { id, name, data, fromHost, … }
if (type === 'version') console.log('now showing', payload); // after a switch; the frame reloaded
});
// Set src only after the listener exists, so no message can arrive before you listen.
sw.src = 'https://spatialwork.hiverlab.com/p/YOUR_CODE';
// The other direction: click an object from your page. It highlights, the camera
// flies to it, and the same object-click comes back with fromHost: true.
function selectInScene(q) { // { id } | { name } | { data: { asset: 'PUMP-01' } }
sw.contentWindow.postMessage({ source: 'spatialwork-host', type: 'select', payload: q }, SW_ORIGIN);
}
// Switch version (ids and names are in ready.versions). The frame reloads.
function showVersion(q) { // { id } | { name: 'Level 5' }
sw.contentWindow.postMessage({ source: 'spatialwork-host', type: 'goto-version', payload: q }, SW_ORIGIN);
}
</script>
Open the scene in SpatialWork, press Share to publish it, then Embed to copy the snippet above with your link filled in. The link (/p/…) is stable — editing and re-publishing keeps it, so you can hardcode it.
SW_ORIGIN has no trailing slashThe browser reports e.origin as https://spatialwork.hiverlab.com. Writing 'https://spatialwork.hiverlab.com/' makes the check fail on every message, so your page silently receives nothing.
postMessage, both directions. Nothing to install: the viewer announces itself when ready, reports every click and viewpoint change — including the ones a person makes — and accepts a small set of camera and selection commands from the host.Every message from the viewer carries source: 'spatialwork'. Check that, and check e.origin, before trusting anything.
| Type | When | Payload |
|---|---|---|
| ready | As soon as the scene can be interacted with | { project, version, versions[], objectCount, objects[], viewpoints[] } |
| loaded | Once every object in the scene has finished loading | { objects[] } |
| object-click | An object is clicked — in the 3D scene, or by your own select |
{ id, name, baseName, objectType, data, path, point, fromHost } |
| select-miss | A select you sent matched no object |
{ query } |
| version | Right after ready, when the frame was reached by switching version — by your goto-version or by a person using the version picker |
{ id, name, fromHost } |
| version-miss | A goto-version you sent matched no shown version |
{ query } |
| viewpoint | The camera moves to a saved viewpoint — whether you asked for it or a person picked it in the panel or pressed a number key | { index, name, fromHost } |
| Field | Meaning | Stability |
|---|---|---|
| id | Internal object identifier | Survives renaming — the most stable key |
| name | Working name, unique within the scene | Changes if someone renames the object |
| baseName | The name as authored; duplicates share it | Not unique |
| objectType | Model, Chart, Group… | Stable |
| data | Your own keys for the object: the fields its data binding filters on, plus any name tag — see below | Survives renames — the recommended key |
| path | Ancestor names, outermost first — useful for grouping by zone or floor | Changes if regrouped |
| point | Where the click landed, in world metres — null for a chart or a host-driven select | Per click |
| fromHost | true when the click came from your own select, not from a person clicking the 3D view | Per click |
data comes fromKey your dashboard on data, not on name: the name is the scene author's label, while data holds your own identifiers, so a rename or a re-export upstream will not silently break the integration. It is filled from two places.
| Source | Example | Arrives as |
|---|---|---|
| The object's data binding — automatic | A sensor bound to the rows $[?(@.assetId=='99962')] of your API | data: { assetId: "99962" } |
| A name tag — set by the scene author | An object named Pump A [asset=PUMP-01;panel=maintenance] | name: "Pump A", data: { asset: "PUMP-01", panel: "maintenance" } |
field=='value' condition in the filter is included, and the key is identical to the one your system already uses. If the filter lists several values for one field (@.assetId=='99959' || @.assetId=='99960'), they arrive comma-joined: "99959,99960".data appears for every object in ready.objects and loaded.objects, so you can map your records to the scene before anyone clicks.ready payload in full| Field | Meaning |
|---|---|
| project | The project's name, for a heading or breadcrumb |
| version | { id, name } of the version this frame is currently showing |
| versions | Every version the author marked shown: [{ id, name, current }] |
| objectCount | How many objects the scene contains |
| objects | Every clickable object up front: [{ id, name, baseName, objectType, data }] — this is what lets you wire your rows to the scene before anyone clicks anything |
| viewpoints | Saved camera positions: [{ index, name }] |
Four behaviours worth designing around, because they are deliberate rather than incidental.
ready fires before the scene finishes loadingThe bridge opens before assets are requested, so one slow model can never stop your page from
hearing ready. The consequence: when ready arrives the object list is
complete and addressable, but the geometry behind it may still be streaming in. A
select sent immediately will resolve the object and post its echo; the camera flight
and the highlight box, which need real bounds, settle once that object has loaded. If you need
the scene fully on screen first, wait for loaded, which follows once every asset is in.
If your listener connects late, connect brings back ready, and
loaded too when loading has already finished.
The viewer does not wait to be greeted — it posts ready as soon as it can. If your
listener attaches late and misses it, send connect and a fresh ready comes
back. That is the only reason connect exists; you never need it if your listener is
registered before the iframe loads.
A version is its own page: the picker and goto-version both load the same link with
a different ?v=, so the iframe reloads and the app boots again. After the reload you
get a second ready, whose version field names the version now
showing, immediately followed by a version message whose
fromHost says whether you asked for it or a person picked it. Treat it as "the frame
reloaded, re-sync everything": your cached object ids from the previous ready belong
to the old version and may not exist in the new one. A plain browser refresh sends
ready but no version, because nothing was switched.
To open a specific version from the start, load the frame as usual and send
goto-version once the first ready arrives.
A viewpoint message is sent when anyone moves the camera to a saved
viewpoint — your goto-viewpoint or reset-camera command
(fromHost: true), or a person clicking the viewpoint panel or pressing a number key
(fromHost: false). That lets a dashboard keep a "current view" indicator honest
instead of only tracking the jumps it caused itself. Camera flights that are not saved
viewpoints — the ones focus and select perform — deliberately stay
silent, because they do not correspond to anything in your viewpoint list.
fromHost flag, in one lineobject-click, viewpoint and version all carry it. false means a
person did this in the 3D view; true means it happened because you asked. Ignore it
and one handler serves both; check it to avoid re-doing work you already did before sending the
command.
Post these to the iframe's contentWindow, with the viewer's origin as the target — never '*'.
| Type | Payload | Effect |
|---|---|---|
| goto-viewpoint | { index } or { name } | Flies the camera to a saved viewpoint |
| focus | { id } | Flies to an object and frames it |
| select | { id } or { name } or { data:{k:v} } | Clicks the object for you: highlights it, flies to it, and echoes object-click back. Add focus: false to highlight without moving the camera |
| deselect | {} | Drops the highlight |
| reset-camera | {} | Returns to the first viewpoint |
| goto-version | { id } or { name } | Switches to another version from ready.versions. The frame reloads, then sends ready and version with fromHost: true. Asking for the version already showing just answers version |
| connect | {} | Asks for a fresh ready — useful if you attached late |
select finds the objectThe three payload shapes are tried in a fixed order, and the first that matches wins:
| # | Payload | Matches against | Use it when |
|---|---|---|---|
| 1 | { id } | The internal object id | You stored the id from a previous object-click or from ready.objects. Exact and rename-proof. |
| 2 | { name } | The unique working name, then the authored name | Your records key on a human name. Breaks if someone renames the object in the scene. |
| 3 | { data: {k: v} } | Every listed key/value against the object's data (binding keys and name tag) | Recommended. Your own business key, e.g. { assetId: '99962' } or { asset: 'PUMP-01' }. Survives renames and re-exports. |
All keys in a data query must match (values compare as strings), and the first
object in scene order wins if several qualify. An empty { data: {} } deliberately
matches nothing rather than selecting an arbitrary object. Anything unmatched comes back as
select-miss carrying your original query, so a broken key is visible in the console
instead of failing silently.
select actually doesIt is not a camera command with a highlight bolted on — it raises the same internal event a
real click raises. Everything downstream reacts identically: the highlight box, any Interactive
object in the scene showing live readings for that object, and the outbound
object-click. That is why there is one echo rather than a separate confirmation
message, and why fromHost is the only thing distinguishing it from a person's click.
function focusInScene(objectId) {
document.getElementById('sw').contentWindow.postMessage(
{ source: 'spatialwork-host', type: 'focus', payload: { id: objectId } },
'https://spatialwork.hiverlab.com'
);
}
Clicking in 3D selects a row in your table; clicking a row clicks the object in 3D — it highlights, the camera flies to it, and the same object-click comes back so both directions run through one handler.
const VIEWER = 'https://spatialwork.hiverlab.com';
const frame = document.getElementById('sw');
const byName = new Map(); // name → your record
window.addEventListener('message', (e) => {
if (e.origin !== VIEWER || e.data?.source !== 'spatialwork') return;
const { type, payload } = e.data;
if (type === 'ready') {
// Fires on first load AND again after a version switch (which reloads the
// frame). Rebuild rather than merge: ids belong to the version that sent them.
console.log(`${payload.project} · ${payload.version?.name}: ${payload.objectCount} objects`);
byName.clear();
// The scene tells you what is in it, so your rows can be wired up front —
// no need to wait for someone to click an object to learn its id.
for (const o of payload.objects) byName.set(o.data?.asset || o.name, o);
buildViewpointButtons(payload.viewpoints);
}
if (type === 'viewpoint') {
// Sent for your own goto-viewpoint (fromHost: true) and for a person picking
// one in the panel (fromHost: false) — so this stays correct either way.
markCurrentView(payload.index);
}
if (type === 'object-click') {
const key = payload.data?.asset || payload.name; // prefer your own key
highlightRow(key);
// payload.fromHost is true when YOU asked for this click. Guard the work you
// have already done, or ignore the flag entirely and let one path do both.
if (!payload.fromHost) loadDetails(key);
}
if (type === 'version') {
// The frame reloaded onto another version (your goto-version, or a person's pick).
// ready.objects above has already been rebuilt; just reflect the choice.
markCurrentVersion(payload.id);
}
if (type === 'select-miss') console.warn('no such object', payload.query);
if (type === 'version-miss') console.warn('no such version', payload.query);
});
// your version dropdown → the scene
function onVersionPick(versionId) {
frame.contentWindow.postMessage(
{ source: 'spatialwork-host', type: 'goto-version', payload: { id: versionId } },
VIEWER
);
}
// your table → the scene
function onRowClick(record) {
frame.contentWindow.postMessage(
{
source: 'spatialwork-host',
type: 'select',
// Any one of these addresses the object: your own tag key is the most
// durable, since it survives a rename in the scene.
payload: { data: { asset: record.assetTag } },
// payload: { id: record.sceneObjectId }
// payload: { name: 'Pump A' }
},
VIEWER
);
}
function buildViewpointButtons(viewpoints) {
for (const vp of viewpoints) {
const b = document.createElement('button');
b.textContent = vp.name;
b.onclick = () => frame.contentWindow.postMessage(
{ source: 'spatialwork-host', type: 'goto-viewpoint', payload: { index: vp.index } },
VIEWER
);
document.getElementById('views').appendChild(b);
}
}
geosense:loaded and geosense:selectedPages built against the earlier GeoSense contract may listen for these. The viewer still sends them, at the same moments as loaded and object-click, but they are being retired. Switch to the SpatialWork names: loaded carries the same objects (inside payload), and object-click carries everything geosense:selected did and more.
Any site can embed a published scene and exchange messages with it; there is nothing to request from us. Because the viewer answers whichever page frames it, the one check that matters is yours: accept messages only when e.origin is the viewer's origin and e.source is your iframe, and always pass the viewer's origin (never '*') as the target of your own postMessage.
The public link and its live data are rate-limited per visitor: 60 page loads a minute per link, and 90 data requests a minute per chart. Normal use stays far below this. Above it, the viewer's requests get HTTP 429 for the rest of that minute — so don't reload the frame in a loop, and keep chart refresh intervals at a second or more.
A published scene needs no login, so anyone holding the URL can view it — and embedding puts that URL in your page's HTML, where any visitor can read it. Treat a published scene as public information.
If a scene should not be public, do not publish it; use an authenticated SpatialWork session instead.
WebGL, and enough height to be usable — 600px is a sensible minimum, and full-height works well. Add allow="fullscreen" if you want the expand control to work. No cookies or third-party storage are required.
| Symptom | Most likely cause |
|---|---|
| Scene renders, no messages arrive | Most often SW_ORIGIN ends in a /, so the origin check rejects everything. Otherwise the iframe's src was set before the listener was added and ready was missed (send connect), or your check compares e.source against a different iframe. |
| Nothing renders at all | The scene was unpublished — the link returns "not published". Re-publish it. |
| Frame stays blank; console says it refused to be framed | The URL is an editor or the project dashboard (for example /spatial/?project=… or /geo/editor.html), which only load on SpatialWork's own site. Embed the published link (/p/…) instead. |
| Clicks do nothing | The click landed on empty space or on an object excluded from clicking. Ground and scenery can be configured to pass clicks through. |
| Commands to the viewer are ignored | The target origin in your postMessage does not match the viewer's origin, so the browser silently drops it. |
| Charts stop updating, console shows 429 | The page hit the rate limit — usually a reload loop, or many copies of the same scene open from one network. It clears within a minute. |
| Data looks stale | The scene snapshot is frozen at publish time; only chart data refreshes, and only if the author set an interval. |
select does nothing, no error | Check the console for select-miss: the key did not match. If there is no select-miss either, the message never arrived — the target origin in your postMessage is wrong, or you posted to a different frame. |
select echoes but nothing moves on screen | The object exists in the scene data but its geometry has not finished streaming. Send the select after loaded arrives. |
| Object ids stopped matching | A second ready arrived — someone switched version and the frame reloaded. Re-read ready.objects; ids are per-version. |
goto-version does nothing | Check for version-miss: only versions listed in ready.versions (the ones the author marked shown) can be opened, and names must match exactly. |
Two object-click messages per row click | You are handling your own echo. Guard on payload.fromHost, or send select and let the echo be your single source of truth rather than updating state before sending. |
| The highlight never clears | It holds until the next selection by design. Send deselect when your table's selection is cleared — clicking empty space in the 3D view does not clear it. |