The Map and the List
Pan the map, and the camera tile grid rearranges itself to match. Scroll the tile grid, and — invisibly — the app starts fetching fresh imagery for exactly the tiles you can see, and stops fetching for the ones you scrolled past.
Those are two different superpowers, and they're built on two lists that sound confusingly alike. Getting them straight is the entire point of this lesson, because between them they decide what renders and what polls:
visiblePinsanswers "which cameras fall inside the current map extent?" Full camera objects — typically hundreds, up to the whole ~1,400-camera fleet at state-level zoom.viewportVisiblePinsanswers "which tiles are scrolled into view in the list right now?" JustcIdstrings — typically a few dozen.
Both live in stores/pinsStore.ts, the bridge between the ArcGIS map and the tile grid. When
you're chasing "a camera is missing from the grid," it's visiblePins. When you're chasing "a
tile shows a stale image," it's viewportVisiblePins, because that list decides what gets polled.
How visiblePins gets its cameras
Three writers, in the order you'd meet them on a fresh page load:
Step 1 — the static fetch seeds it. Before the map has drawn anything,
queries/useCamerasAllStatic.ts succeeds and calls setVisiblePins(data.data) with the whole
fleet. First paint of the grid shows everything.
Step 2 — the map settling refines it. hooks/featureMap/useFeatureMapExtent.ts registers a
watcher on view.stationary — ArcGIS-speak for "the user stopped panning/zooming." When the map
settles, the hook queries the camera layers for every cId inside the visible extent, resolves
those ids into full camera objects through staticCameraStore.getCamerasByIds, and replaces
visiblePins. Pans and zooms are debounced; the very first query after setup fires immediately
(on mobile it waits once, MOBILE_INITIAL_DELAY_MS, to let the map settle).
Step 3 — SignalR keeps entries fresh. When a new frame is pushed, SignalRIImage calls
updateVisiblePins(latestImageMetadata), which rewrites the image URL, timestamp, and PTZ strings
on the one matching entry — no re-query, just a touch-up.
The extent query runs with where = '1=1' — it deliberately ignores the layer's
definitionExpression, so visiblePins holds every camera in the extent even while the map is
visually hiding some. Display filtering happens later, in CameraListComponent via
shouldCameraBeFiltered. That's why toggling a filter doesn't re-query the map: the list already
has everything and just filters in memory.
How viewportVisiblePins tracks your scroll
No map involved here at all — this is pure DOM observation. CameraListComponent attaches an
IntersectionObserver with rootMargin: '200px 0px 200px 0px' to every element carrying
data-camera-id. A tile entering that zone (including 200px of runway above and below the
viewport, so imagery is ready before you reach it) adds its id; leaving removes it.
On mount, and whenever the sorted/filtered tile list changes, a setTimeout(..., 0) re-observes
everything and seeds the list from getBoundingClientRect.
The seeding step merges into the previous list rather than replacing it —
new Set([...prev, ...initialIds]). Ids from a previous filter can linger until the observer
fires an un-intersect for them. Treat this list as recently on screen; never build logic that
assumes it's an exact snapshot.
What the two lists drive
Here's where the design pays off. viewportVisiblePins flows into hooks/useCameraFilters.ts,
which turns it into the polling set for useCamerasAllOperational — capped at 50 cameras per
poll. When you're zoomed in and the extent holds fewer than 50, it simply polls what's on screen.
But zoom out to the whole state and the extent holds 1,400 cameras. Which 50 matter? The hook tiers them:
- Active cameras you can see — detecting something, scrolled into view. Highest priority.
- Everything else you can see — your visible tiles stay fresh.
- Active cameras in the extent you haven't scrolled to — this tier is why a camera that starts detecting a fire off-screen still gets fresh imagery before you scroll to it.
Scroll the toy version and watch the budget re-allocate:
Scroll the list — the budget re-allocates instantly, and tier 3 is why a camera that starts detecting off screen still gets fresh imagery.
visiblePins, meanwhile, feeds the grid itself — filteredAndSortedPins is a useMemo over it —
plus the render guard that keeps the grid hidden until there's something to show.
Going deeper
Wrapping up
One store, two lists, two jobs. The map writes visiblePins ("in the extent") every time it
settles; your scrollbar writes viewportVisiblePins ("on the screen") through an
IntersectionObserver. The first decides what the grid renders; the second decides what the app
polls — tiered, capped at 50, with off-screen detecting cameras deliberately kept warm.
That completes the state chapter. To see who's pushing data into these stores in real time, continue to the SignalR deep dive — or head back to Features to see the stores in action on the map.