Skip to main content

One Camera, Two Clocks

Here's a trap that catches nearly everyone in their first month. The static camera model — the one with coordinates and names — also carries pan, tilt, and zoom fields. They're right there. They typecheck. And they are frozen at whatever the camera was doing when the page loaded.

The real PTZ values live somewhere else, updating every few seconds while your load-time snapshot quietly goes stale.

The app avoids this trap with a clean split: every camera has a slow half and a fast half, each owned by its own store, each updating on its own clock. This lesson walks through both — and through the three update rules that make the fast half cheap enough to run against ~1,400 cameras.

The slow clock: staticCameraStore

stores/staticCameraStore.ts holds the half of a camera that changes on the order of days: coordinates, elevation, camera name (cn), site id and name, model, provider, and the capability flags the UI gates on — ptz, canControl, canUsePatrolMode, isRealCam.

Because this data almost never changes, its update strategy is the simplest one possible: every static fetch rebuilds everything from scratch. setStaticCameraData(data) throws away the old state and replaces it in a single set. No merging, no diffing. When writes are rare, the dumbest strategy is the right one.

But reads are anything but rare — every hover, every click, every map poll looks cameras up. So the store builds two indexes at write time:

  1. staticCameraData — camera by cId, for "give me this camera"
  2. staticCameraBySiteMap — cameras by sid, for "give me every camera at this mountain top"

Why bother? Because "click a camera, show every camera at this physical site" is a hot path in the FeatureMap, and without the index it would be a scan over 1,400 entries on every click. Measure the difference yourself — it runs live, on your machine:

Map lookup vs array scan, at fleet scale
const CAMERA_COUNT = 1400;   // production scale — try 100, then 10000
const RUNS = 100;
const cameras = [];
for (let i = 0; i < CAMERA_COUNT; i++) cameras.push({ cId: 'cam-' + i, sid: 'site-' + (i % 400) });
const ids = cameras.slice(0, 100).map(function (c) { return c.cId; });

// The store builds both of these once, in setStaticCameraData.
const byId = new Map(cameras.map(function (c) { return [c.cId, c]; }));
const bySite = new Map();
cameras.forEach(function (c) { bySite.set(c.sid, (bySite.get(c.sid) || []).concat(c)); });

function ms(fn) { const t = performance.now(); for (let i = 0; i < RUNS; i++) fn(); return performance.now() - t; }
const find = ms(function () { ids.forEach(function (id) { cameras.find(function (c) { return c.cId === id; }); }); });
const get  = ms(function () { ids.forEach(function (id) { byId.get(id); }); });
const filt = ms(function () { cameras.filter(function (c) { return c.sid === 'site-7'; }); });
const site = ms(function () { bySite.get('site-7'); });

const speedup = (slow, fast) => fast < 0.05 ? '>' + (slow / 0.05).toFixed(0) : (slow / fast).toFixed(0);
render(<pre style={{margin: 0}}>{[CAMERA_COUNT + ' cameras, 100 lookups × ' + RUNS + ' runs',
'',
'getCameraById:       find() ' + find.toFixed(1) + 'ms   Map.get() ' + get.toFixed(1) + 'ms   → ' + speedup(find, get) + '× faster',
'getCamerasBySiteId:  filter() ' + filt.toFixed(1) + 'ms   Map.get() ' + site.toFixed(1) + 'ms   → ' + speedup(filt, site) + '× faster',
].join('\n')}</pre>);
Output

The exact milliseconds vary run to run — the ratio is the durable result. Building the indexes costs O(n) once per fetch; it pays for itself on the first hover.

One field here isn't per-camera at all

globalPanoOffsetDegrees is the fallback rotational correction for external-vendor panorama cameras that have no calibration of their own. Consumers read it through a three-step chain — the camera's own panoOffsetDegrees, then the global value, then 0:

const offset = isExternalPano
? (cameraSelected?.panoOffsetDegrees ?? globalPanoOffsetDegrees ?? 0)
: 0;

If you add a new pano consumer, follow the same chain — reaching straight for the global value silently ignores a camera's own calibration. The full story is in Pano calibration and bearing.

The fast clock: realTimeCameraStore

stores/realTimeCameraStore.ts is the single source of truth for everything that changes minute to minute: pan, tilt, zoom, the latest image URL and timestamp, latency, patrol mode, and the AI-detection flags. Two writers feed it, at two very different rates:

  1. PollingusePageUpdates polls for the selected camera, and CameraListComponent polls for the tiles currently scrolled into view. Both land in updateRealTimeCameraData.
  2. SignalR — when the hub pushes a fresh frame, IImage/SignalRIImage call updateSingleCameraFromSignalR for that one camera.

Everything else in the app only reads: the map hover/selection/polling hooks, the camera tiles, the PTZ buttons, the latency badge, the AI indicator.

Now, think about what a naive implementation would do. A poll response arrives every ten seconds. You build a fresh Map, set it, and — because a new Map is a new reference — every subscribed tile re-renders. Ten seconds later, again. Most of those polls changed nothing.

The store's answer is three update rules. They're subtle, they're deliberate, and each one will surprise you the first time you trace it.

Rule 1: if nothing changed, the same reference comes back

The diff (in util/cameras/realTimeCameraDiffAnalyzer.ts) runs microdiff per camera, allocates the new Map lazily on the first real change, and skips set entirely when there were none. An identical poll allocates nothing, triggers nothing, re-renders nothing.

Rule 2: a poll can never remove a camera

The merge only ever calls .set() on the map. A camera missing from a response is left exactly as it was — a partial or filtered response must not make cameras blink out of the UI. The flip side: a camera the backend has genuinely retired stays in the store until a full page reload.

Rule 3: a null in the payload never wipes a stored value

The update is spread over the existing entry, then the fields that matter (p, t, z, time, imageTimestamp, imageUrl, and friends) are null-coalesced back to their previous values. A backend that momentarily reports p: null must not erase a pan angle the UI already knows.

All three rules in one runnable model:

The merge: lazy copy, reference identity, no deletes
const FIELDS = ['p', 't', 'z', 'imageTimestamp'];

function apply(cameraMap, data) {
let hasChanges = false, newMap = null;   // lazy: a no-op poll allocates nothing
data.forEach(function (up) {
  const old = cameraMap.get(up.cId);
  const next = old ? Object.assign({}, old, up) : up;
  // Rule 3: a null must not wipe a field an earlier poll filled in.
  if (old) FIELDS.forEach(function (f) { if (up[f] == null) next[f] = old[f]; });
  if (!old || FIELDS.some(function (f) { return old[f] !== next[f]; })) {
    if (!newMap) newMap = new Map(cameraMap);
    newMap.set(up.cId, next);
    hasChanges = true;
  }
});
// Rule 1 (same ref back) + Rule 2 (absent cameras survive):
return { updatedMap: newMap || cameraMap, hasChanges: hasChanges };
}

const store = new Map([['cam-1', { cId: 'cam-1', p: 45,  t: 10, z: 2, imageTimestamp: 'T12' }],
                     ['cam-2', { cId: 'cam-2', p: 90,  t: 0,  z: 1, imageTimestamp: 'T12' }],
                     ['cam-3', { cId: 'cam-3', p: 180, t: 5,  z: 3, imageTimestamp: 'T12' }]]);
const polls = [
['identical data',           [{ cId: 'cam-1', p: 45,   t: 10, z: 2, imageTimestamp: 'T12' }]],
['cam-2 panned',             [{ cId: 'cam-2', p: 135,  t: 0,  z: 1, imageTimestamp: 'T12' }]],
['null pan (must not wipe)', [{ cId: 'cam-1', p: null, t: 10, z: 2, imageTimestamp: 'T12' }]],
['cam-3 missing entirely',   [{ cId: 'cam-1', p: 45,   t: 10, z: 2, imageTimestamp: 'T12' }]],
];
render(<pre style={{margin: 0}}>{polls.map(function (p) { const r = apply(store, p[1]);
return p[0].padEnd(28) + 'changed=' + String(r.hasChanges).padEnd(7) +
  'sameRef=' + String(r.updatedMap === store).padEnd(7) + 'size=' + r.updatedMap.size;
}).join('\n') + '\n\nsameRef=true is what stops React re-rendering every subscribed tile.'}</pre>);
Output

The production version diffs every field with microdiff rather than the four above, but the shape is identical.

Gotcha: the SignalR path compares only four fields

updateSingleCameraFromSignalR early-returns when p, t, z, and imageTimestamp all match what's stored. A push carrying a new imageUrl with the same timestamp is silently dropped. It also bails if the camera isn't already in the map — SignalR can never introduce a camera the polling path hasn't seen first.

Going deeper

Watching the rules in DevTools. Every store registers with Redux DevTools as ALERTCalifornia/<storeName>, with trace on in development. Two checks worth doing by hand when debugging: a poll that changed nothing should produce no action at all (if you see one per tick, something upstream is handing the store fresh object identities), and a camera that vanished from the UI did not vanish from the store — check the snapshot before hunting for a delete path that doesn't exist. The camera stores add a serialize.replacer so DevTools can display a Map at all; that conversion is display-only.

Wrapping up

Every camera is split across a slow store and a fast one. The slow one rebuilds from scratch and pre-indexes for reads; the fast one diffs carefully so that no-change polls are free, absent cameras survive, and nulls can't erase knowledge. When PTZ looks stale, you're reading the slow clock; when a "removed" camera won't go away, you've met Rule 2.

Next: the store that connects the map to the tile grid — and the difference between "in the viewport" and "on the screen": The Map and the List.