SpatialWork · Integration guide

Embedding SpatialWork

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.

The shortest version that works

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>

Where the link comes from

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 slash

The 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.


How the channel works

YOUR PAGE Dashboard message listener your panels, tables, records, filters IFRAME SpatialWork viewer published scene clickable objects live chart data ready · loaded · version object-click · viewpoint goto-viewpoint · focus · select goto-version · reset-camera Any site can embed No registration: the viewer talks to whichever page frames it. Your page checks the viewer's origin.
Plain 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.

Messages you receive

Every message from the viewer carries source: 'spatialwork'. Check that, and check e.origin, before trusting anything.

TypeWhenPayload
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 }

The object-click payload in full

FieldMeaningStability
idInternal object identifierSurvives renaming — the most stable key
nameWorking name, unique within the sceneChanges if someone renames the object
baseNameThe name as authored; duplicates share itNot unique
objectTypeModel, Chart, Group…Stable
dataYour own keys for the object: the fields its data binding filters on, plus any name tag — see belowSurvives renames — the recommended key
pathAncestor names, outermost first — useful for grouping by zone or floorChanges if regrouped
pointWhere the click landed, in world metres — null for a chart or a host-driven selectPer click
fromHosttrue when the click came from your own select, not from a person clicking the 3D viewPer click

Where data comes from

Key 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.

SourceExampleArrives as
The object's data binding — automaticA sensor bound to the rows $[?(@.assetId=='99962')] of your APIdata: { assetId: "99962" }
A name tag — set by the scene authorAn object named Pump A [asset=PUMP-01;panel=maintenance]name: "Pump A",
data: { asset: "PUMP-01", panel: "maintenance" }

The ready payload in full

FieldMeaning
projectThe project's name, for a heading or breadcrumb
version{ id, name } of the version this frame is currently showing
versionsEvery version the author marked shown: [{ id, name, current }]
objectCountHow many objects the scene contains
objectsEvery clickable object up front: [{ id, name, baseName, objectType, data }] — this is what lets you wire your rows to the scene before anyone clicks anything
viewpointsSaved camera positions: [{ index, name }]

Lifecycle and ordering

Four behaviours worth designing around, because they are deliberate rather than incidental.

1. ready fires before the scene finishes loading

The 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.

2. There is no handshake

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.

3. Switching version reloads the frame

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.

4. Viewpoint and version messages report both directions

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.

The fromHost flag, in one line

object-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.


Messages you can send

Post these to the iframe's contentWindow, with the viewer's origin as the target — never '*'.

TypePayloadEffect
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

How select finds the object

The three payload shapes are tried in a fixed order, and the first that matches wins:

#PayloadMatches againstUse it when
1{ id }The internal object idYou 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 nameYour 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.

What a select actually does

It 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'
  );
}

A complete two-way example

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);
  }
}

Older integrations: geosense:loaded and geosense:selected

Pages 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.


Before you go live

No registration needed — but check the origin on your side

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.

Rate limits

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.

The link is the credential

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.

What the viewer needs from the page

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.

Troubleshooting

SymptomMost likely cause
Scene renders, no messages arriveMost 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 allThe scene was unpublished — the link returns "not published". Re-publish it.
Frame stays blank; console says it refused to be framedThe 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 nothingThe 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 ignoredThe target origin in your postMessage does not match the viewer's origin, so the browser silently drops it.
Charts stop updating, console shows 429The 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 staleThe scene snapshot is frozen at publish time; only chart data refreshes, and only if the author set an interval.
select does nothing, no errorCheck 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 screenThe object exists in the scene data but its geometry has not finished streaming. Send the select after loaded arrives.
Object ids stopped matchingA second ready arrived — someone switched version and the frame reloaded. Re-read ready.objects; ids are per-version.
goto-version does nothingCheck 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 clickYou 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 clearsIt 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.