Skip to main content

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:

  • visiblePins answers "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.
  • viewportVisiblePins answers "which tiles are scrolled into view in the list right now?" Just cId strings — 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.

Why the extent query ignores the map's filters

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.

Gotcha: "recently on screen," not "exactly on screen"

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:

  1. Active cameras you can see — detecting something, scrolled into view. Highest priority.
  2. Everything else you can see — your visible tiles stay fresh.
  3. 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:

CAM-001
CAM-002
CAM-003
CAM-004
CAM-005
CAM-006
CAM-007
CAM-008
CAM-009
CAM-010
CAM-011
CAM-012
CAM-013
CAM-014
CAM-015
CAM-016
CAM-017
CAM-018
CAM-019
CAM-020
CAM-021
CAM-022
CAM-023
CAM-024
CAM-025
CAM-026
CAM-027
CAM-028
CAM-029
CAM-030
Polling budget
tier 1 · active, on screen 0
tier 2 · rest of screen 0
tier 3 · active, off screen 0

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.