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:
// 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>);
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:
| Hook | Owns | Worth knowing |
|---|---|---|
useFeatureMapCameraLayers | Creating the four client layers + every mutate function | Exported as useFeatureMapLayers, despite the filename |
useFeatureMapAGOLLayers | WebMap layers: legend, popups, order, hit-testing | Drops layers whose loadError smells like auth (401/403/"token") |
useFeatureMapPolling | Store updates → layer edits | 100 ms debounce; pending edits accumulate, nothing lost mid-debounce |
useFeatureMapHover | Bidirectional hover, map ↔ camera list | Tracks hover origin so a map hover doesn't echo back through the store |
useFeatureMapSelection | Persistent FOV/LOS for the selected camera | Survives hover-clear via setPersistentFovLosCameraIds |
useFeatureMapCursorTracking | The black LOS line following your cursor | Reads curserLocationOnImageInDegrees — the typo is in the store |
useFeatureMapExtent | Filtering visiblePins to the viewport | 200 ms debounce to query, 500 ms to persist |
useFeatureMapClustering | Clustering config, toggle, custom cluster popup | Off 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
- Update an attribute; never delete-and-add to redraw. It blinks, and it defeats the diff.
- Issue delete + add as one
applyEditscall. Two calls flicker — the cursor-LOS line is the scar tissue behind this rule. - Never set a symbol on a feature. Change the attribute, or change the renderer.
- Never subscribe to zoom to resize or hide things. That's what visual variables are for.
useShallowon multi-field store selections — you know this one from the state chapter.
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.