Cameras From the Stream
Every camera the map draws comes from ArcGIS Velocity. The WebMap carries, per environment,
four kinds of output: Locations (a persisted feature layer of camera points),
Camera Feed (the live point stream, heading arrow included), Viewshed (the
field-of-view wedge stream) and Proboscis (the line-of-sight beam stream, coloured by how
recently the camera moved). The two beam streams have persisted feature-layer twins written
by the same Velocity analytics. Velocity pushes camera state over WebSocket, so the app edits
no features: useFeatureMapLayers discovers the layers, puts them on the map, and keeps
their filters in step with the application — the camera filter, the beam gate, the area
scope, and the stream → persisted handoff.
The legacy pipeline — the ALERTCalifornia Cameras feature service plus client-built "Camera
Arrows", "Field of View" and "Line of Sight" layers kept current by diffing the SignalR
store — was removed in October 2026. The feature service is still in every WebMap, so the
app keeps it parked (never loaded), off the Layers menu and out of the area-filter picker
(LEGACY_CAMERA_LAYER_TITLES in data/constants.ts). The one client-side graphic left is
the image-cursor line of sight, a single graphic on a GraphicsLayer.
| Kind | Service (per <ENV>) | Class | Draws |
|---|---|---|---|
| Locations | <ENV>_Camera_Locations_Persistence / _Persistence_Locations / _Status_Persistence | FeatureLayer | Every camera's last-known point and arrow |
| Camera Feed | <ENV>_Camera_Feed[_vN] | StreamLayer | Live points as cameras publish |
| Viewshed | <ENV>_Camera_[Feed_]Viewshed[_vN] | StreamLayer | The FOV wedge (hovered / selected only) |
| Proboscis | <ENV>_Camera_[Feed_]Proboscis[_vN] | StreamLayer | The LOS beam (hovered / selected / moved) |
A WebMap without a Locations layer draws no cameras: discovery logs an error naming the
item, velocityLayersAvailable stays false, and everything else on the map works. The
development WebMap (db68c797…) is in that state today; UAT (ca34508c…) and production
(e7f22810…) carry full sets.
Discovery does not trust titles
util/map/featureMap/velocityLayers.ts classifies WebMap layers by the service name
parsed from the layer URL first (…/services/UAT_Camera_Proboscis/StreamServer), and only
then by title. GIS renames the WebMap titles ("Locations", "Viewshed") at will; the service
name survives. Stream layers are matched loosely by title because nothing else in the
WebMap is a stream; feature layers only on a strict match, so the legacy camera services can
never be claimed. When both a stream and a persisted feature output exist for the same kind,
the stream is claimed and the persisted output of the same environment (the UAT_ /
Production_ prefix) becomes its companion (below).
The beta and production WebMaps can carry two sets — one in a hidden group — and a decommissioned stream may still be referenced. So discovery keeps every classified layer as a ranked candidate per kind (inside a visible group first, then streams before persisted outputs, then WebMap order) and claims the best. If a claimed service fails to load (10 s bound, off the map-init critical path) the next candidate is promoted and the failed one is retired, so nothing retries a dead service. Unclaimed candidates are parked — off the map they hold no WebSocket and the SDK stops retrying them — and hidden from the Layers menu. A claimed layer inside a GroupLayer that AGOL left hidden gets its ancestors made visible.
The camera id on every velocity layer is deviceId (values equal the app's cId); the
field is resolved from the layer schema once it has loaded, and every cId IN (...) the app
builds is rewritten through translateCameraIdWhere before it touches a velocity layer.
Discovery publishes velocityLayersAvailable, the titles on the map and every candidate
title to stores/velocityLayersStore.ts, which the Layers menu and the layer-visibility sync
read.
Startup
usePreparedWebMap parks every toggleable WebMap layer before the view exists (see
Layers That Load When Asked), the velocity layers included. In
the view-ready handler initializeLayers runs discovery and adds the cursor-line layer;
nothing waits on a stream. Once isMapReady flips, ensureVelocityLayersOnMap — serialised
and idempotent — loads the claimed set, parks the unclaimed candidates, prepares the persisted
layers (filters first, see below), restores the claimed set and its companions at their WebMap
positions, orders each companion beneath its stream, attaches the handoff listeners before
a stream can deliver anything, filters once more, and bumps velocityLayersAppliedVersion so
layer-view effects (the area scope) run against layers that are actually on the map. The
FeatureMap then computes the visible pins for the initial extent.
Companions: something to draw while a stream connects
A StreamLayer starts empty and only holds features received after it connected, so without
help the map would show nothing until each camera next publishes. Velocity also writes each
camera's last feature to a persisted feature service, and that layer is the warm-start.
Companions load with their stream, take the same filters, re-query their service once a
minute unless the WebMap set an interval, draw beneath the stream, and appear in the Layers
menu. A companion whose service is dead is dropped, not swapped in.
The two never draw the same camera at once. On every filter pass the persisted layer excludes
each camera its stream has delivered a drawable event for on the current connection and
still holds, so the persisted feature hands off to the live one the moment it arrives, and
comes back if the stream purges it or reconnects empty. The stream's data-received and
connection-status events and the companion's refresh event re-run the pass. "Drawable"
matters: on connect the Velocity stream replays each track's latest observation with
latitude/longitude tagged as Web Mercator, which the SDK draws at the equator, invisibly;
isDrawableStreamGeometry spots the mis-tag so those events never trigger a handoff.
The same pairing covers Locations and Camera Feed — the arrows' warm-start, scoped to
every camera rather than the beam set. Because the WebMap gives Locations a plain point
symbol, the app clones the Camera Feed renderer onto it (rotation by position_pan) when it
carries that field, so last-known arrows show before the first event.
Beams are hover / selection gated, not always on
The streams publish a viewshed and a proboscis for every camera. Left alone that is a map
full of wedges, so Viewshed and Proboscis carry a filter built from a beam set
(velocityBeamIdsRef): deviceId IN ('cam-a', …), or 1=0 when the set is empty. The set
is maintained through showFovLosForCameras (tile-view hover; the selected camera on its
location page), addFovLosForCameras (location-view hover), hideFovLosForCameras and
clearAllFovLos. The selected camera is pinned: a hover clear never removes its beams.
Recently moved cameras keep their proboscis on the map on every page: useFeatureMapSelection
re-derives the recently-moved set from the operational store on each update and pushes it as
the persistent set, which the camera-layers hook unions into the Proboscis filter only
(the LOS line, never the FOV wedge), re-applying only when membership changed.
One filter pass composes everything
applyVelocityLayerFilters writes, per layer, one clause built by composeVelocityWhere:
- Locations / Camera Feed — the camera filter (Active / AI detected / location page,
built by
shouldCameraBeFilteredascId IN (…)and translated todeviceId). - Viewshed / Proboscis — the camera filter AND the beam clause; a persisted companion additionally excludes the cameras its stream already draws.
- Streams only — the area scope. A StreamLayer in SDK 5 has no
filterproperty of its own (the 4.x connection filter becamedefinitionExpression/geometryDefinition, which go to the Velocity service), souseFeatureMapAreaFilterpublishes its where clause oncameraAreaFilterStore.mapAreaWhereand this pass ANDs it into the stream's onelayerView.filter. Feature layers take the area scope onlayerView.filterdirectly, from the area hook, and the camera filter as theirdefinitionExpression.
Feature layers are filtered before they go back on the map: the WebMap gives them no definition expression, so a persisted beam layer restored first would draw every camera's last-known beam for a frame until the gate landed.
Interaction
Hover, tap and click all hit-test with an include list of exactly the velocity layers on the
map (findVelocityLayerEntry resolves a hit graphic's layer, companions included, and
supplies its id field). A hit on a point (Locations, Camera Feed) acts on the whole site;
a hit on a beam (viewshed, proboscis, stream or persisted) acts on that camera. The
cursor-tracking line starts at the point marker's arrow tip, read off the marker's frame and
the renderer's size stops at the current scale (getPointMarkerTipOffsetPx).
What the SignalR store still drives
Camera positions and headings are the stream's, so useFeatureMapPolling no longer diffs PTZ
values. It scans the operational store for AI-hit transitions, which feed the camera-tile
pulse (aiDetectionStore) and the map pulse: pulseAiDetectionHit puts a bloom
featureEffect on the point layer views for the detecting cameras for
AI_DETECTION_PULSE_DURATION_MS. The store also feeds the recently-moved set above and the
camera filter.
Layers menu
The Layers menu lists the velocity layers that are on the map (claimed, plus companions) with
checkboxes that toggle plain visibility, never persisted; usePageUpdates never parks or
restores them, since that is what connects and disconnects a stream. Unclaimed candidates and
the legacy camera service are hidden.