Skip to main content

Streams, Beams, and the Handoff

Everything on the previous pages draws cameras from our own stores: a poll arrives, we diff it, we edit a client layer. There is a second way. The GIS team publishes four real-time layers from ArcGIS Velocity for each environment: camera points, a viewshed polygon and a proboscis beam per camera, and a camera feed, all pushed over WebSocket as cameras move. The real-time cameras button in the map's FAB tray switches the map to them. This page is what that switch does and the properties of StreamLayer that shaped it, written so that the next engineer does not rediscover them the hard way. The deep dive Two Sources, One Map is the exhaustive reference; this is the guided tour.

What a StreamLayer is, and is not​

A StreamLayer connects to a StreamServer over wss:// and holds the features it has received on the current connection. Four consequences follow, and every one of them appears in the code:

  • Hiding does not disconnect. visible = false leaves the socket open. To stop the stream you remove the layer from its collection; to restart it you add it back. The catalog's park and restore do exactly that.
  • It starts empty. A camera's beam appears when the feed next publishes that camera. On a fresh connection that can be minutes, which is why there are persisted companions.
  • No feature reduction. Clustering is disabled while the source is active.
  • No filter property in SDK 5. The 4.x connection filter became definitionExpression and geometryDefinition, which go to the Velocity service. The only client-side filter is the layer view's filter, and every clause has to be composed into that one place.

And one surprise found in the field: on connect, the service replays each track's latest observation, but those snapshot geometries arrive as latitude/longitude tagged as Web Mercator, so the SDK draws them metres from the equator, invisibly. isDrawableStreamGeometry spots the mis-tag (projected spatial reference, coordinates within ±180/±90) so a replayed feature is never counted as "live".

Discovery does not trust titles​

The WebMap may carry two complete sets, UAT in a hidden group beside Production in a visible one, and the GIS team renames titles freely. So discoverVelocityLayers classifies every WebMap layer by the service name parsed from its URL first, and only then by title; stream layers match loosely (nothing else in the WebMap is a stream), feature layers only on a strict match so the legacy camera services can never be claimed.

classifyVelocityLayer — mirrors util/map/featureMap/velocityLayers.ts
const SERVICE = {   // VELOCITY_LAYER_MATCHERS in data/constants.ts; stream titles are the loose fallback
locations:  /(^|_)camera_(?:persistence_locations|locations_persistence)(?:_v\d+)?$/i,
viewshed:   /(^|_)camera_(?:feed_)?viewshed(?:_v\d+)?$/i,
proboscis:  /(^|_)camera_(?:feed_)?proboscis(?:_v\d+)?$/i,
cameraFeed: /(^|_)camera_feed(?:_v\d+)?$/i,
};
const STREAM_TITLE = { locations: /locations?/i, viewshed: /viewshed/i, proboscis: /proboscis/i, cameraFeed: /camera[\s_.-]?feed/i };
const serviceName = url => (/\/services\/([^/?#]+)\/(?:StreamServer|FeatureServer|MapServer)/i.exec(url || '') || [])[1] ?? null;
function classify(layer) {   // feature layers need the strict service-name match
const name = serviceName(layer.url);
for (const kind in SERVICE) if (name && SERVICE[kind].test(name)) return kind;
for (const kind in STREAM_TITLE) if (layer.type === 'stream' && STREAM_TITLE[kind].test(layer.title)) return kind;
return null;
}
const svc = (host, name, kind) => 'https://' + host + '/x/rest/services/' + name + '/' + kind;
const layers = [
{ title: 'Viewshed',             type: 'stream',  url: svc('us4-iot.arcgis.com', 'Production_Camera_Feed_Viewshed_v2', 'StreamServer') },
{ title: 'Locations',            type: 'feature', url: svc('services8.arcgis.com', 'UAT_Camera_Locations_Persistence', 'FeatureServer') },
{ title: 'UAT Camera Locations', type: 'feature', url: svc('services8.arcgis.com', 'UAT_Camera_Locations', 'FeatureServer') },
{ title: 'Some Other Feed',      type: 'stream',  url: svc('us4-iot.arcgis.com', 'ADSB', 'StreamServer') }];
render(<pre style={{margin: 0}}>{layers.map(l => l.title.padEnd(22) + ' → ' + (classify(l) ?? 'not velocity')).join('\n')}</pre>);
Output

Every classified layer is kept as a ranked candidate for its kind (inside a visible group first, then streams before persisted outputs, then WebMap order); the best is claimed. If a claimed service fails to load, within a 10 s bound and off the map-init critical path, the next candidate is promoted and the failed one is retired so no later switch retries a dead service. Unclaimed candidates are kept off the map, so they hold no socket. Discovery publishes availability synchronously; a WebMap without a Locations layer draws no cameras and says so once in the console.

Putting the layers on the map​

ensureVelocityLayersOnMap in useFeatureMapLayers is the one place the layers reach the map. It runs once the view is ready and again after a fallback swap; calls are serialised through a promise chain and it is idempotent.

The switch and the handoff, as a filmFive acts, ~23 seconds, scrub anywhere. The filter strings under the stage are the real ones.
Proboscis stream · layerView.filter—companion · layerView.filter—

act 1/5 · The switchThe operator switches to real-time cameras. The client side goes quiet FIRST: camera service, arrows, FOV and LOS hidden, the pulse cancelled, the FOV/LOS layers emptied — so nothing still in flight can land on top of the stream. Then the four Velocity layers are restored into their WebMap group. Restoring is what opens the WebSocket.

The claimed layers are loaded (10 s bound each, with fallback promotion), the unclaimed candidates parked, the persisted layers prepared and filtered, the claimed set restored into its original group at its original index with each companion beneath its stream, and the handoff listeners attached before a stream can deliver anything. Removing a layer from its collection is what disconnects a stream; visible = false does not.

Beams are hover-gated, through the same seam​

The streams publish a viewshed and a proboscis for every camera; drawn as-is that is a map full of wedges. So the beam layers carry a layer-view filter built from a beam set: deviceId IN (…), or 1=0 when the set is empty. The set is maintained by the very functions that draw client FOV/LOS (showFovLosForCameras replaces it, addFovLosForCameras adds, the hide functions remove), so hover and selection behave identically under both sources without any hook knowing which is live. The selected camera on its location page is pinned into the set so a hover-clear in the same commit cannot remove it. Per kind, the proboscis (the LOS line) also unions the recently-moved persistent set, matching the legacy "LOS only, always" behaviour; the viewshed (the wedge) stays hover and selection only.

composeVelocityWhere builds each layer's single clause: the camera filter translated from cId to the layer's resolved id field, AND the beam set for beam layers, AND the handoff exclusion, AND for streams only the area scope read from cameraAreaFilterStore.mapAreaWhere. Feature-layer companions take the camera filter as a definitionExpression instead, leaving their layer-view filter free for the area hook, exactly like the legacy camera layer.

The persisted warm-start and the handoff​

Velocity also writes each camera's last feature to persisted feature services. A persisted output of the same environment as a claimed stream (UAT_ or Production_, read off the service name) becomes its companion: loaded with it, filtered with it, reordered beneath it, refreshed once a minute, and listed in the menu. A companion from another environment's set is only ever a fallback. The map therefore has something to draw while the stream connects.

The film's fourth and fifth acts are exactly this exchange: a data-received event with drawable geometry adds the camera to the received set, the filters re-apply, and the companion excludes it; a connection-status change empties the set and the companion covers again.

The rule the operators gave us was "showing both is bad": never draw the same camera twice. So on every filter pass the persisted layer excludes each camera the stream has delivered a drawable event for on its current connection and still holds. A replayed snapshot does not count, a purged feature hands back, and a reconnect clears the received set so the companion covers until the features re-arrive. Because the WebMap gives the Locations persistence layer a plain point symbol, the app clones the Camera Feed stream's renderer onto it when it carries the rotation field, so the last-known arrow shows before the first event.

What the Layers Menu does​

The layers on the map (claimed, plus persisted companions) appear in the menu with checkboxes that toggle plain visibility, never persisted; the unclaimed candidates and the legacy camera service are hidden.

Wrapping up​

A StreamLayer disconnects only on removal, starts empty, cannot cluster and has one client-side filter, on its layer view. Discovery claims by service name with ranked fallbacks; the switch is serialised and suspends before it connects; beams are gated by the same seam as client FOV/LOS; and persisted companions warm-start the streams, handing each camera off the moment a drawable event arrives.

Next: Hit Tests and the Click Budget, how the map listens.