Layer 2@syncframe/spatial

Spatial

Multi-display calibration on top of @syncframe/core. Register screens, assign each a world-coordinate viewport, track presence, and render shared content cropped per display. Optional /ui ships display chrome; content layers stay in your app. Live examples: the dot demo (bouncing circle) and color ring demo (spinning petals across four quadrants).

Overview

Layer 1 syncs scalar or vector state with anchors and a server clock. Layer 2 adds a screen registry: named displays, poses in a shared world canvas, session heartbeats, and a global calibration/content render mode. Content motion still uses core anchors — spatial only owns where each screen looks in world space.

Prerequisites: @syncframe/core (protocol + hooks) and a production store such as @syncframe/redis. See core docs and redis adapter docs.

// Monorepo / workspace today npm publish TBD
pnpm add @syncframe/core @syncframe/redis

Entry points

@syncframe/spatial/serverTypes, pure reducers, coord math, SpatialServer — React-free
@syncframe/spatial/reactuseSpatialSnapshot, useSelfScreen, useDisplaySurface
@syncframe/spatial/uiChromeFreeDisplay, TopDownRoomMap, SpatialContentLayer contract, presentation kiosk mode
@syncframe/spatialFull barrel (pulls React) — convenience only

Import server code from /server in API routes; import hooks from /react in client components.

What spatial owns vs what you own

01

Spatial owns: ScreenPose, SpatialMeta reducers, SpatialServer bridge, world↔normalized coord helpers, hooks, and optional /ui display kit (calibration grid, room map, display shell).

02

You own: operator panels (pose editor, screen cards), concrete content layers conforming to SpatialContentLayer, anchor evaluators, API routes, identify Redis key, SSE stream wiring.

03

Core still owns: clock sync, anchor CRUD, snapshot pub/sub, useAnchor for motion channels.

Data model

Spatial state lives in meta.spatial on a SyncServer instance. Snapshots are still core CoreSnapshot JSON over SSE — spatial is a structured meta blob plus whatever anchor channels your content needs.

interface SpatialMeta {
  worldBbox: { width: number; height: number };  // default 1920×1080
  renderMode: 'calibration' | 'content';
  contentLayerId?: string;  // consumer sets via SpatialServer.initialMeta (not a lib default)
  screens: Record<string, ScreenEntry>;
  identifyTrigger?: { screenName: string; at: number } | null; // often merged at SSE build
}

interface ScreenEntry {
  pose: ScreenPose;
  createdAt: string;
  sessions: Record<string, ScreenSession>;
}

interface ScreenPose {
  worldX: number;
  worldY: number;
  worldWidth: number;
  worldHeight: number;
}

Each screenName is a stable id (1–32 chars, alphanumeric + _-). New screens get a default pose covering the full world bbox at the origin — operators spread them manually. Sessions are per browser tab; pose is per name.

Wire SpatialServer

Bind one SyncServer namespace per spatial app. The site runs two isolated rooms: dot-demo (dot demo) and ring-demo (color ring demo). SpatialServer read-modify-writes the spatial key under that server's meta.

import { SyncServer } from '@syncframe/core/server';
import { RedisStore, RedisTransport } from '@syncframe/redis';
import { SpatialServer, ensureScreen } from '@syncframe/spatial/server';

const sync = new SyncServer({
  store: new RedisStore({ redis }),
  transport: new RedisTransport({ redis, createSubscriber }),
  namespace: 'dot-demo',
});

const spatial = new SpatialServer({ sync });

await spatial.ensureInitialized();
await spatial.apply((m) => ensureScreen(m, 'wall-left'));
await spatial.publish();
// Pure reducers (testable, framework-agnostic)
import {
  ensureScreen,
  updatePose,
  deleteScreen,
  heartbeat,
  setRenderMode,
  setWorldBbox,
  setContentLayerId,
  pruneStaleSessions,
  parseSpatialMeta,
} from '@syncframe/spatial/server';

// spatial.apply((m) => updatePose(m, name, pose) ?? m)
// spatial.apply((m) => heartbeat(m, name, session) ?? m)
Always mutate through spatial.apply() so the full spatial object is written back to meta.

Coordinate math

A ScreenPose defines which rectangle of world space a display renders. Map world points into normalized 0..1 coordinates within that pose, then scale to viewport pixels in your renderer (independent X/Y scale — stretch to fill the monitor).

import { worldToScreen, screenToWorld } from '@syncframe/spatial/server';

const pose = { worldX: 100, worldY: 200, worldWidth: 400, worldHeight: 300 };

// World (300, 350) → normalized (0.5, 0.5) — center of this pose's bbox
worldToScreen({ x: 300, y: 350 }, pose);

// Pixel placement (consumer — map normalized or world units to clientWidth/Height):
const scaleX = viewportWidth / pose.worldWidth;
const screenX = (worldX - pose.worldX) * scaleX;

Content layers crop world shapes to each pose via mapWorldShapeToScreenPixels from @syncframe/spatial/ui: evaluate motion in world space at clock.serverNow(), then affine-stretch into the viewport.

React hooks

Hooks subscribe to your SSE stream (core CoreSnapshot). Multiple hooks on the same stream URL share one EventSource via subscribeSnapshotStream in core.

// Operator — registry + render mode, no heartbeat
import { useSpatialSnapshot, listScreenNames, isScreenOnline } from '@syncframe/spatial/react';

const { spatial, snapshot, connected } = useSpatialSnapshot({
  streamEndpoint: '/api/dot/stream',
});

// Display — registry + pose + heartbeat + identify
import { useDisplaySurface } from '@syncframe/spatial/react';

const { pose, isCalibration, isContent, deleted, identifyTrigger } = useDisplaySurface({
  screenName: 'wall-left',
  streamEndpoint: '/api/dot/stream',
  apiBase: '/api/dot',
  heartbeat: true,
});

// Content motion — separate core anchor channel
import { useAnchor } from '@syncframe/core/react';

const dotAnchor = useAnchor('dot', '/api/dot/stream');
Partition scope is server-side: your stream route binds SyncServer.namespace. Hooks only pass channel id + stream URL.

Presence & heartbeats

Each display tab generates a sessionId and POSTs heartbeats every 10s with viewport size and user agent. Sessions expire after 30s without a heartbeat. Every heartbeat triggers a snapshot publish so the operator UI stays fresh.

HEARTBEAT_INTERVAL_MS10_000 — client cadence
SESSION_TTL_MS30_000 — server prune + client stale filter
POST /heartbeat{ screenName, sessionId, clientWidthPx, clientHeightPx, devicePixelRatio, userAgent }
// Display detects deletion from snapshot absence OR heartbeat 404
// useSelfScreen stops heartbeating when deleted

API routes (consumer pattern)

Each site demo implements thin Next.js routes under its own prefix — /api/dot/* and /api/ring/*. Each mutating route calls spatial.apply(...) then spatial.publish(). The stream route merges identify triggers and prunes stale sessions before fan-out.

GET /streamSSE CoreSnapshot; : open preamble; ensureAnchor for content channel
POST /screens/register{ name } — idempotent, validates screen name
PUT /screens/update-pose{ name, pose } — normalizeScreenPose validates W/H > 0
DELETE /screens/delete?name= — cascades sessions in meta
POST /heartbeatUpserts session on screen entry
POST /render-mode{ mode: 'calibration' | 'content' }
POST /identify{ name } — transient trigger (demo: separate Redis key, 5s TTL)
POST /controlDot only — { action: 'start' | 'pause' | 'reset' } on the dot anchor
POST /spinRing only — { action: 'start' | 'pause' } on the ring-spin scalar anchor

Content layer contract

Each content module exports a SpatialContentLayer. evaluateFrame is required: it returns world-space rects (WorldFrame) with motion baked into x/y. MapView and Display paint those rects; the spatial package supplies pose-crop projection only.

  • Top-down mapTopDownRoomMap hosts MapView at (0,0) in worldW×worldH with uniform scale. Screen pose rects are overlaid on top.
  • Wall displayDisplay calls projectWorldFrameToViewport to crop each rect to the screen pose bbox and stretch into viewport pixels.
type WorldShapePaint =
  | { kind: 'solid'; color: string }
  | { kind: 'image'; url: string };

interface WorldShape {
  x: number; y: number; width: number; height: number;
  paint: WorldShapePaint;
  label?: string; opacity?: number;
}

interface SpatialContentLayer {
  id: string;
  label: string;
  evaluateFrame: (ctx: WorldEvalContext) => WorldFrame;
  MapView: ComponentType<WorldPreviewContext>;
  Display: ComponentType<ContentLayerDisplayProps>;
}

// @syncframe/spatial/ui — projection only, no draw opinions
import { projectWorldFrameToViewport } from '@syncframe/spatial/ui';

WYSIWYG: map and wall share the same evaluateFrame and the same paint helpers. A scrolling pano returns one wide image rect with scroll offset in x; projection crops it per screen. The dot demo uses solid rects in lib/dot-render.tsx; the color ring demo places twelve outlined petals in lib/ring-render.tsx.

Display UI kit (/ui)

Import display chrome from @syncframe/spatial/ui. ChromeFreeDisplay is the unified display shell: calibration grid or content layer, heartbeat, identify flash.

import { ChromeFreeDisplay, TopDownRoomMap } from '@syncframe/spatial/ui';
import { dotLayer } from '@/lib/dot-layer';

// Operator map — consumer MapView; screen overlays on top
<TopDownRoomMap
  spatial={spatial}
  snapshot={snapshot}
  clock={clock}
  MapView={dotLayer.MapView}
  selectedScreenName={selected}
/>

Presentation displays (kiosk)

Physical monitors run a fullscreen URL with no site chrome. Pass presentation to ChromeFreeDisplay: loading and deleted states render as black (PresentationBlank), not status text. In content mode only the layer renders; in calibration mode the grid + HUD still appear.

// display/layout.tsx — hide site header/footer
<div data-presentation className="fixed inset-0 bg-black"></div>

// Thin page glue: register screenName, then one spatial component
<ChromeFreeDisplay
  screenName="left"
  streamEndpoint="/api/dot/stream"
  apiBase="/api/dot"
  clockEndpoint="/api/clock"
  contentLayer={dotLayer}
  presentation
/>
Dot: /demo/dot/display?screenName=left. Ring: /demo/ring/display?screenName=nw. Registration is app glue; rendering is entirely ChromeFreeDisplay + contentLayer.

Identify flash

Operator hits identify on a screen; server stores a short-lived trigger; SSE merges it into meta.spatial.identifyTrigger; matching displays flash red for 1s. Client dedupes on trigger.at so replays do not re-flash.

// Demo: Redis key syncframe:{namespace}:spatial:identify (EX 5s)
// Merged in buildDotDemoSnapshot() — not persisted in meta JSON

// Bundled in ChromeFreeDisplay; also importable from @syncframe/spatial/ui
<IdentifyFlash trigger={identifyTrigger} screenName={screenName} />

Calibration workflow

01

Open the display URL on each monitor — e.g. /demo/dot/display?screenName=wall-left or the ring's fixed quadrants /demo/ring/display?screenName=nw. The page auto-registers the name.

02

On the operator page, confirm screens show online (heartbeat). Edit poses in world coordinates — each pose is the world rectangle that display maps to its viewport.

03

Switch to calibration mode: each display shows a test grid stretched to its pose. Align seams across monitors.

04

Use identify to flash a screen red when you need to match a physical monitor to a name.

05

Switch to content mode and start motion (dot control, ring spin, or your layer). Motion is evaluated on every client at serverNow(); only the pose crop differs per display.

Site demos

Two live consumers share the same spatial stack with separate Redis namespaces and content layers. Use the dot demo for free-form screen registration and bouncing motion; use the color ring demo for a fixed four-quadrant layout and scalar spin.

Dot demo

The dot demo is a bouncing-circle layer on a dot anchor channel. Motion lives in lib/dot.ts; the SpatialContentLayer module is lib/dot-layer.tsx (shared evaluateFrame for map + display).

// dot-layer.tsx — data + consumer renderers
export const dotLayer: SpatialContentLayer = {
  id: 'dot',
  evaluateFrame: evaluateDotFrame,
  MapView: DotMapView,      // lib/dot-render.tsx — solid + image paint
  Display: DotDisplay,      // DotViewport + offset decay
};

// Motion math (no React): apps/site/lib/dot.ts
useDotContentLayer() wires the layer for operator + display. Display omits debug labels in presentation mode.

Color ring demo

The color ring demo is a second spatial room (ring-demo) with a 500×500 white world, four pre-seeded quadrant screens (nw, ne, sw, se), and a scalar ring-spin anchor driving CCW rotation. Motion lives in lib/ring.ts; the content layer is lib/ring-layer.tsx (useRingContentLayer() adds client-side spin smoothing).

// ring-layer.tsx — twelve petals from one evaluateFrame
export const ringLayer: SpatialContentLayer = {
  id: 'ring',
  evaluateFrame: evaluateRingFrame,
  MapView: RingMapView,     // lib/ring-render.tsx
  Display: RingViewport,
};

// Operator: POST /api/ring/spin { action: 'start' | 'pause' }
// Displays: /demo/ring/display?screenName=nw
Identify-only operator — poses are fixed at initial meta. Separate from dot-demo; no shared Redis state.

Snapshot shape on the wire

{
  "anchors": {
    "dot": {
      "at": 1730000000000,
      "value": { "x": 120, "y": 80 },
      "motion": { "kind": "linear2dBouncing" }
    }
  },
  "meta": {
    "spatial": {
      "worldBbox": {
        "width": 1920,
        "height": 1080
      },
      "renderMode": "content",
      "contentLayerId": "dot",
      "screens": {
        "wall-left": {
          "pose": {
            "worldX": 0,
            "worldY": 0,
            "worldWidth": 960,
            "worldHeight": 1080
          },
          "createdAt": "2026-06-07T12:00:00.000Z",
          "sessions": {
            "abc123": {
              "sessionId": "abc123",
              "clientWidthPx": 1920,
              "clientHeightPx": 1080,
              "devicePixelRatio": 2,
              "lastSeenAt": "2026-06-07T12:00:10.000Z"
            }
          }
        }
      },
      "identifyTrigger": null
    }
  },
  "contentData": null
}

Next steps