Skip to main content

Meet the Map

The map is the centerpiece of the operator's screen: every camera in the network drawn as a rotating arrow over a California basemap, with fire and weather layers underneath. When a camera pans, its arrow rotates within seconds. When an AI detection fires, the arrow turns red and pulses. Hover any camera and a purple wedge sweeps out — the camera's field of view — with a line showing exactly what it's looking down on.

This lesson teaches you to read the map, and gives you the two mental splits that tell you where any map behavior lives in code. The machinery is the next lesson, How the Map Thinks.

Reading the arrows

Each arrow encodes three things at a glance: direction (rotation = the camera's pan), activity (visible at wide zoom only if recently moved — a still camera fades out to reduce clutter), and health, encoded by fill:

  • Red, filled, pulsing — live AI detection hits
  • Black, filled — online: latest image under a minute old
  • Grey, filled — short offline: image between 1 minute and 24 hours old
  • Grey, outline only — long offline: image at least a day old

That classification is one pure function — getArrowStateCode — and two of its behaviors are guessed wrong more often than not. Run it yourself:

getArrowStateCode — four states, one of them role-gated
// Mirrors getArrowStateCode from util/map/featureMap/layerFactories.ts.
function getArrowStateCode(imageTimestamp, hasAiDetectionHits, hasAiAccess) {
if (hasAiAccess && hasAiDetectionHits) return 'aiDetection';   // red filled
if (!imageTimestamp) return 'online';        // nothing to measure -> treat as fresh
const age = Date.now() - new Date(imageTimestamp).getTime();
if (age < 60000) return 'online';            // < 1 min   -> black filled
if (age < 86400000) return 'shortOffline';   // < 24 h    -> grey filled
return 'longOffline';                        // >= 24 h   -> grey outline
}

// --- edit these -----------------------------------------------------------
const ago = m => new Date(Date.now() - m * 60000).toISOString();

const cases = [
{ label: 'image 10s old',              ts: ago(0.17), ai: false, role: true },
{ label: 'image 10 minutes old',       ts: ago(10),   ai: false, role: true },
{ label: 'image 3 days old',           ts: ago(4320), ai: false, role: true },
{ label: 'no timestamp at all',        ts: null,      ai: false, role: true },
{ label: 'AI hit, user HAS AI role',   ts: ago(5),    ai: true,  role: true },
{ label: 'AI hit, user LACKS AI role', ts: ago(5),    ai: true,  role: false },
];

render(<pre style={{margin: 0}}>{cases.map(c =>
c.label.padEnd(26) + ' -> ' + getArrowStateCode(c.ts, c.ai, c.role)
).join('\n')}</pre>);
Output

The two surprises, spelled out: AI red is role-gated — a camera with live AI hits draws a perfectly ordinary arrow for a user without the AI role, so you cannot reason about arrow color from the camera record alone. And a missing timestamp means "online," not "offline" — with nothing to measure, the code deliberately assumes fresh.

Hover or select a camera and two more graphics appear: the FOV wedge (purple, semi-transparent, width depends on the camera model's lens) and the LOS line, colored by how old the camera's latest image is. Move your cursor across a camera's image in the tile view and a third graphic — a black cursor-tracking LOS — sweeps across the map in sync. That one's a favorite demo in onboarding: the map and the image are two views of the same geometry.

Split #1: what this repo draws vs. what AGOL draws

Everything on the map comes from one of two places, and knowing which is the first debugging question:

  • Drawn by this repo (client-side layers, created in util/map/featureMap/layerFactories.ts): the camera arrows, the FOV wedges, the LOS lines, and the cursor-tracking line.
  • Drawn by ArcGIS Online (the AGOL WebMap): the basemap, fire and weather layers, and — the one that fools people — the camera site circles under the arrows. Their symbology is authored in the AGOL portal item, not in code.

If the thing you want to restyle is in the second group, you're filing a portal change, not a pull request. See AGOL Layers for how those load and authenticate.

Split #2: slow data vs. fast data

The map reads from the same two stores you met in the state chapter: staticCameraStore for coordinates and camera models (loaded once), realTimeCameraStore for pan/tilt/zoom and image freshness (updated every poll). Arrow position comes from the slow store; arrow rotation and color come from the fast one. Every map bug about "wrong place" is static-store data; every bug about "wrong state" is real-time data.

Using it

There is exactly one FeatureMap for the lifetime of the app — MapPortalContext mounts it once and portals it wherever a map is needed, so the ArcGIS view survives navigation. In the rare case you're wiring a new surface:

import FeatureMap from 'components/organisms/TileView/FeatureMap';

<FeatureMap
onAction={(action, feature) => {
/* selectCamera, etc. */
}}
/>;

The component itself is nearly empty: every behavior — layers, hover, selection, polling, clustering, extent-filtering — is a hook in hooks/featureMap/, one file per concern. The geometry math lives in util/map/featureMap/ (geometryBuilders.ts, layerFactories.ts, cameraChangeDetector.ts), and the TypeScript contracts in types/featureMapTypes.ts.

Wrapping up

You can now read every pixel of the map: arrows encode direction, activity, and a role-gated four-state health code; wedges and lines appear on hover; circles under arrows belong to AGOL, not us. Two splits — ours vs. AGOL's, slow vs. fast — locate any behavior you'll ever need to change.

Next: How the Map Thinks — the poll-tick journey that keeps 1,400 arrows honest without dropping frames.