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/redisEntry 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 onlyImport server code from /server in API routes; import hooks from /react in client components.
What spatial owns vs what you own
Spatial owns: ScreenPose, SpatialMeta reducers, SpatialServer bridge, world↔normalized coord helpers, hooks, and optional /ui display kit (calibration grid, room map, display shell).
You own: operator panels (pose editor, screen cards), concrete content layers conforming to SpatialContentLayer, anchor evaluators, API routes, identify Redis key, SSE stream wiring.
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)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');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 cadenceSESSION_TTL_MS30_000 — server prune + client stale filterPOST /heartbeat{ screenName, sessionId, clientWidthPx, clientHeightPx, devicePixelRatio, userAgent }// Display detects deletion from snapshot absence OR heartbeat 404
// useSelfScreen stops heartbeating when deletedAPI 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 channelPOST /screens/register{ name } — idempotent, validates screen namePUT /screens/update-pose{ name, pose } — normalizeScreenPose validates W/H > 0DELETE /screens/delete?name= — cascades sessions in metaPOST /heartbeatUpserts session on screen entryPOST /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 anchorPOST /spinRing only — { action: 'start' | 'pause' } on the ring-spin scalar anchorContent 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 map —
TopDownRoomMaphostsMapViewat(0,0)inworldW×worldHwith uniform scale. Screen pose rects are overlaid on top. - Wall display —
DisplaycallsprojectWorldFrameToViewportto 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
/>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
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.
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.
Switch to calibration mode: each display shows a test grid stretched to its pose. Align seams across monitors.
Use identify to flash a screen red when you need to match a physical monitor to a name.
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.tsColor 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=nwSnapshot 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
}