Skip to main content

From Poll to Pixel

Historical, as of October 2026

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​

One poll tick as a funnelSet how many cameras panned, how many have a new image, the scale and the burst — then fire a tick and count what reaches applyEdits.
1,400 in → 44 edits

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.

Why the chain existsRace a hover show against a selection add with the chain off, then on.

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​

  1. Update attributes; never delete-and-add to change appearance.
  2. One applyEdits per logical change. Delete + add that must happen together go in one call.
  3. Every FOV/LOS mutation goes through runFovLosEdit. No exceptions, no "quick" direct edits.
  4. Allocate ObjectIDs explicitly, and above the existing maximum when adding.
  5. Prefer reactiveUtils.when(stationary) to watch(scale) for anything that redraws.
  6. 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.