Skip to main content

How the Map Thinks

Here's the problem this lesson solves. Every poll returns the full fleet — ~1,400 cameras, every few seconds. Redraw the fleet each time and the map drops frames; that's precisely what the legacy map did, and precisely why it was replaced. Yet an operator panning a camera expects its arrow to rotate now.

The answer is a pipeline that spends effort only on what changed. Let's ride one poll tick through it.

The journey of one poll tick

Stage 1 — the store diff protects React. You met this in One Camera, Two Clocks: when a poll changes nothing, realTimeCameraStore hands back the same Map reference and no component re-renders. A no-op poll dies here, before the map hears about it.

Stage 2 — the snapshot diff protects ArcGIS. Re-rendering React cheaply is not enough — applyEdits on a FeatureLayer is expensive too. So useFeatureMapPolling keeps one PTZSnapshot per camera and asks util/map/featureMap/cameraChangeDetector.ts which cameras actually moved. Only those get edited. In practice a poll selects between zero and a handful.

The part that trips people up is the timestamp grace period. Cameras push fresh images constantly, so imageTimestamp differs on nearly every camera on nearly every poll — if that alone counted as change, the diff would select the whole fleet and buy nothing. The rule: when the timestamp is the only difference, the camera counts as unchanged until the drift reaches 60,000 ms. Watch the rule decide:

detectPTZChanges — which cameras get an applyEdits?
// Mirrors detectPTZChanges. The real one compares snapshots with microdiff;
// this compares the same fields by hand so the snippet has no dependencies.
const FIELDS = ['p','t','z','cMo','imageTimestamp','hasAiDetectionHits','isRecentlyMoved'];
const GRACE_MS = 60000;

function detectPTZChanges(prev, cameras) {
const changed = [], log = [];
for (const cam of cameras) {
  const before = prev.get(cam.cId);
  if (!before) { changed.push(cam.cId); log.push(cam.cId + ': NEW'); continue; }

  const diff = FIELDS.filter(f => before[f] !== cam[f]);
  if (diff.length === 0) { log.push(cam.cId + ': unchanged'); continue; }

  if (diff.length === 1 && diff[0] === 'imageTimestamp') {
    const a = Date.parse(before.imageTimestamp), b = Date.parse(cam.imageTimestamp);
    const delta = (isNaN(a) || isNaN(b)) ? Infinity : Math.abs(b - a);
    if (delta >= GRACE_MS) { changed.push(cam.cId); log.push(cam.cId + ': timestamp +' + delta + 'ms'); }
    else log.push(cam.cId + ': timestamp only, ' + delta + 'ms apart -> SKIPPED');
    continue;
  }
  changed.push(cam.cId); log.push(cam.cId + ': ' + diff.join(', '));
}
return { changed, log };
}

// --- edit these -----------------------------------------------------------
const snap = (p, ts) => ({ p, t: 0, z: 1, imageTimestamp: ts, hasAiDetectionHits: false, isRecentlyMoved: false });
const T0 = '2026-01-01T12:00:00Z';

const previous = new Map([
['CAM-001', snap(45, T0)], ['CAM-002', snap(90, T0)],
['CAM-003', snap(180, T0)], ['CAM-004', snap(270, T0)],
]);

const current = [
{ cId: 'CAM-001', ...snap(90, T0) },                      // operator panned it
{ cId: 'CAM-002', ...snap(90, '2026-01-01T12:00:20Z') },  // new image 20s later
{ cId: 'CAM-003', ...snap(180, '2026-01-01T12:01:30Z') }, // new image 90s later
{ cId: 'CAM-004', ...snap(270, T0) },                     // nothing moved
];

const r = detectPTZChanges(previous, current);
render(<pre style={{margin: 0}}>{r.log.join('\n') + '\n\n' + r.changed.length + ' of ' + current.length +
' sent to applyEdits: [' + r.changed.join(', ') + ']'}</pre>);
Output
Two consequences that look like bugs (and aren't)

An arrow can lag its own image age by up to a minute. A camera that should flip from online to shortOffline purely because time passed won't redraw until something else changes or the timestamp drifts a full 60 s from the snapshot. That price was paid deliberately.

The snapshot is wider than pan/tilt/zoom — it carries cMo, imageTimestamp, hasAiDetectionHits, and isRecentlyMoved, because arrow appearance depends on all of them. Add a field to arrow symbology without adding it to PTZSnapshot and the arrow silently stops updating. No error. Just a stale icon.

Stage 3 — the edit is an attribute write. Which brings us to the deepest rule on this map.

Symbology is attribute-driven, always

No code in this repo ever sets a symbol on an individual feature. Each client layer has a UniqueValueRenderer keyed on one string attribute — updates just write that attribute (arrowState for arrows, via getArrowStateCode, which you ran in the previous lesson), and the GPU repaints. The same pattern colors LOS lines through getLosColorCodeFromDate, which buckets image age into five codes or returns undefined for the renderer's default grey.

The hooks: one concern per file

FeatureMap.tsx renders one MapView and composes everything from hooks/featureMap/. When you're changing a behavior, this is the "which file" answer:

HookOwnsWorth knowing
useFeatureMapCameraLayersCreating the four client layers + every mutate functionExported as useFeatureMapLayers, despite the filename
useFeatureMapAGOLLayersWebMap layers: legend, popups, order, hit-testingDrops layers whose loadError smells like auth (401/403/"token")
useFeatureMapPollingStore updates → layer edits100 ms debounce; pending edits accumulate, nothing lost mid-debounce
useFeatureMapHoverBidirectional hover, map ↔ camera listTracks hover origin so a map hover doesn't echo back through the store
useFeatureMapSelectionPersistent FOV/LOS for the selected cameraSurvives hover-clear via setPersistentFovLosCameraIds
useFeatureMapCursorTrackingThe black LOS line following your cursorReads curserLocationOnImageInDegrees — the typo is in the store
useFeatureMapExtentFiltering visiblePins to the viewport200 ms debounce to query, 500 ms to persist
useFeatureMapClusteringClustering config, toggle, custom cluster popupOff by default; recently-moved cameras are excluded from clusters

(useFeatureMapAreaFilter, useFeatureMapPolygons, useAreaMapPick, and useAreaFilterPopupAction also live there — they belong to area-boundary alerts, a separate feature.)

The AI-detection pulse

When polling sees a camera go from no AI hits to having them — and the user holds the AI role — onPulseAiDetectionHit fires, and two things happen in parallel.

On the map, addPulseAnimationToAiDetectionHit clones the arrows renderer and swaps only the aiDetection entry for a CIMSymbol hosting /ai-detection-pulse.gif, with ArcGIS driving playback — no JS animation loop. One timer restores the static renderer after 10 s, and each new hit resets it, so the pulse window always extends 10 s from the latest hit. Note the granularity: the swap is renderer-level, so every AI-detection arrow pulses, not just the triggering ones — the cameraIds argument is accepted and ignored, because going per-camera would mean applyEdits plus a per-value CIM symbol on every hit.

Separately, polling writes the triggering ids into aiDetectionStore (cleared after 10.6 s), which is how camera tiles outside the map ride the same event without re-detecting it.

Rules that keep this fast

  1. Update an attribute; never delete-and-add to redraw. It blinks, and it defeats the diff.
  2. Issue delete + add as one applyEdits call. Two calls flicker — the cursor-LOS line is the scar tissue behind this rule.
  3. Never set a symbol on a feature. Change the attribute, or change the renderer.
  4. Never subscribe to zoom to resize or hide things. That's what visual variables are for.
  5. useShallow on multi-field store selections — you know this one from the state chapter.
Arrows in the Gulf of Guinea always mean missing static data

extractCameraGeometryDataFromOperational builds coordinates with parseFloat(staticCamera?.coord?.lonDd || '0'). A camera the static store hasn't loaded yet is placed at 0°N 0°E — off the coast of West Africa — rather than skipped. If you ever see arrows there, you're looking at operational data racing ahead of static data.

Wrapping up

One poll tick: store diff (protects React) → snapshot diff with a 60-second timestamp grace (protects ArcGIS) → attribute writes (the GPU does the rest). Hooks own one concern each; renderers own all appearance; and the five rules above are the difference between this map and the one it replaced.

Continue with AGOL Layers — the half of the map that arrives from ArcGIS Online — or jump to Tuning the Map.