From Poll to Pixel
This chapter describes the legacy rendering pipeline — the camera feature service plus the
client-built arrows, FOV and LOS layers edited from the SignalR store. That pipeline was removed;
cameras are drawn by the ArcGIS Velocity layers alone and the app edits no camera features. The
diffs, edit chain, ObjectID allocation and stationary refreshes below no longer exist in the code.
What survives: the AI-hit transition scan in useFeatureMapPolling (now the only thing the store
drives on the map) and the cursor line. See Streams, Beams, and the Handoff
and Cameras From the Stream.
Every few seconds the fleet's operational state lands in realTimeCameraStore, through a poll or
a SignalR delta. The map has to turn that into attribute edits on three feature layers, for the
cameras that actually changed, without ever blinking or dropping a frame. The map lesson
How the Map Thinks teaches the two diffs with live
playgrounds; this page follows the same tick down to the ArcGIS calls, then covers the edit paths
that lesson does not: hover and selection, the recently-moved lines, the zoom refreshes and the
extent query.
One tick, as ArcGIS sees it
Cameras push images constantly, so 1086 of them differ only by imageTimestamp. Without the 60 s grace every one of those would be an edit per tick; with it, only the 30 whose timestamp drifted a full minute count. The price is an arrow that can lag its own image age by up to a minute.
Two points in that funnel are the ones that bite. First, the arrow edit is a query then an update, never a delete-and-add: an update keeps the feature's ObjectID and lets the renderer repaint in place, while a delete-and-add flickers and re-indexes. Second, the pending map is keyed by camera id, so a camera that changes twice inside the 100 ms debounce produces one edit with the latest values, and nothing is lost when the timer resets.
The scale guard appears twice on purpose. The polling hook drops changed cameras that would be
invisible at the current scale before extracting geometry, and updateArrowsForCameras filters
again, because it is also called from paths that did not go through the poll. Editing an invisible
feature burns worker time for no pixels.
Building the arrows
buildArrowsFromStaticData runs once the map is ready and the static store has loaded, and again
whenever the source switch resumes the client layers. It clears the layer with one
queryFeatures('1=1') plus applyEdits({ deleteFeatures }), then adds one graphic per camera with
ObjectID: index + 1, the pan, the computed arrowState and the integer isRecentlyMoved. On
phones (detected by user agent) it adds in batches of 200 with a 10 ms yield between them, because
a single edit of 1,400 graphics was enough to crash a mobile WebView mid-render. An in-flight guard
makes concurrent callers share one build; without it a second build could delete the batches the
first had added so far and both would keep adding, leaving duplicate arrows.
The FOV/LOS chain
Every client-side FOV or LOS mutation is a query followed by an applyEdits. Two of them
interleaving, a selection show racing a hover hide, or two selection effects firing for the same
camera, leave duplicate or missing graphics, because the second clear runs before the first add
has landed. So every public entry point (showFovLosForCameras, addFovLosForCameras,
addLosOnlyForCameras, refreshLosOnlyForCameras, the hide and clear functions) runs through one
FIFO promise chain, runFovLosEdit, and the internal helpers that do the actual queries are only
ever called from inside a queued operation.
Unchained: each operation queries, awaits, then edits — and a second show starts before the first has landed. Its clear deletes what its query SAW, not what is there, and the hover re-fire leaves camera A on the layer twice. This is the duplicate-wedge bug the chain exists to prevent.
Each operation also has its own clearing and ObjectID rule. A show clears everything except
the persistent lines and then the named cameras, and numbers its graphics from index + 1. An
add clears only the named cameras, queries the current maximum ObjectID and numbers from above
1,000 so it cannot collide with what is there. A refresh clears nothing: it deletes the old line
and adds the new one in one applyEdits, keeping each feature's existing ObjectID.
The ObjectID rule exists because client feature layers do not allocate ids for you. The refresh path is the one that earned the single-call rule: deleting the old line and adding the new one as two edits produced a visible blink on every zoom.
Hover and selection, as edits
Hover and selection do not know about layers; they call the seam above with camera geometry. In tile view a hover is a show (clear, then draw the hovered site's cameras); in location view it is an add (the selected camera's wedge must survive), and a hover-clear hides only the hovered ids minus the selected camera, because the hover ref can be stale from a tile-view hover that preceded the click. The selection effect claims the camera before awaiting the show, since the effect re-runs on every dependency change and a re-entry during the await would start a second show for the same camera.
Geometry for both comes from extractCameraGeometryDataFromOperational when the real-time store
has the camera, falling back to the static record. Note the two different time fields: the LOS
colour reads time (the last movement), the arrow state reads imageTimestamp (the last image).
They are not interchangeable.
Zoom changes and reactiveUtils.when
Both the wedge and the lines carry a pixel offset converted to metres at the resolution they were
drawn at, so a zoom leaves them starting at the wrong distance from the arrow. Two effects in the
selection hook register reactiveUtils.when(() => mapView.stationary, …) with initial: false:
one redraws the selected camera's FOV/LOS, the other refreshes the recently-moved lines when the
resolution has drifted by more than 0.0001 and there is something to refresh. when fires once
per transition to stationary, which is what makes it cheap; a continuous watch on scale would
run during every frame of a pinch. The last-drawn resolution is only recorded when lines are
actually drawn, so a poll landing mid-zoom cannot convince the refresh that nothing changed.
The extent query that feeds the grid
useFeatureMapExtent is the bridge from map to tile list. On each stationary, debounced 200 ms,
it queries the camera feature service with geometry = view.extent, spatialRelationship: 'intersects', outFields: ['cId'], no geometry back, and where: '1=1' to override the
layer's definition expression: the tile grid applies its own display filter, and visiblePins
must contain every camera in view so polling can detect status changes on filtered-out cameras.
It then adds cameras whose LOS or FOV intersects the extent (their point may be off-screen but
their line is not), resolves the ids against the static store, and skips the store write when the
set is unchanged, because a fresh array re-rendered the whole grid and discarded the image data
merged into the pins. The same handler persists the extent 500 ms later, as scale (continuous)
plus zoom (rounded, for older readers), and never while in location view.
Under the Velocity source there is no service query; the hook derives the set geometrically from static coordinates against the extent converted to WGS84.
The sixty-second refresh
FeatureMap calls refreshCameraLayer() on an interval of 60 s while the map is ready and the
standard source is active. That is a layer.refresh() on the camera service: a re-fetch of the
display features so a newly commissioned camera's circle appears without a reload. It is the one
network call in this pipeline that is not driven by a store change, and it is skipped under
Velocity because the stream pushes.
The rules this page leaves you with
- Update attributes; never delete-and-add to change appearance.
- One
applyEditsper logical change. Delete + add that must happen together go in one call. - Every FOV/LOS mutation goes through
runFovLosEdit. No exceptions, no "quick" direct edits. - Allocate ObjectIDs explicitly, and above the existing maximum when adding.
- Prefer
reactiveUtils.when(stationary)towatch(scale)for anything that redraws. - Query the camera service with
where: '1=1'when the answer must ignore the display filter.
Wrapping up
A tick is a store diff, a snapshot diff with a minute of timestamp grace, a 100 ms debounce, a
query for ObjectIDs and a single update edit. Hover and selection are shows and adds through one
FIFO chain with explicit ids; zoom refreshes ride stationary; the extent query feeds the grid
while deliberately ignoring the map's own filter.
Next: When the AI Speaks, the one event that changes a renderer instead of an attribute.