Skip to main content

The Click That Offers a Camera

Click a camera marker, arrow or field-of-view on the map and… nothing navigates. That's deliberate — an operator scanning the map mid-incident shouldn't be yanked into a camera view by a stray click. Instead a compact callout appears right at the marker: site name, the cameras at that site, the latest frame when real-time data has one, and a single Open camera action. That callout is CameraCallout (components/molecules/MapNotifications/CameraCallout/), and it's a nice little case study in driving React UI from non-React code.

It takes no props. Everything comes from graphicNotificationStore, which is what lets non-React code — the map's click handler — open it.

The click path​

FeatureMap.onCameraClicked(arrowHit, pointHit) reads the store imperatively via getState(), because it runs from an ArcGIS event handler rather than a render:

useGraphicNotificationStore
.getState()
.showGraphicNotification(cameras, siteId, { latitude, longitude });

An arrow hit passes that one camera. A point hit resolves the clicked camera's sid, then passes every camera at that site via getCamerasBySiteId. The third argument is the anchor — the hit feature's geometry when the hit carried one, otherwise the camera's catalogued coordinates. Two guards suppress the callout entirely: clicking the camera already open (?camera= in the URL), and clicking a single-camera site that is already open.

FeatureMap renders the component only on desktop — !isMobile && !isLandscape. On mobile and landscape the same click opens MoreOptionsDrawerContent in a bottom drawer instead, so the callout path and the drawer path never both fire.

What the store does, and what it doesn't​

showGraphicNotification normalises whatever camera model it is handed (CameraLocationAndImageModel or AllStaticCamera) into a flat CalloutCamera — cId→id, cn→name, sn→siteName, siteLabel, isRecentlyMoved→lastMoved, time→lastMovedTime, isOnline, ptz — sorts by lastMovedTime descending, stores the anchor, and sets open: true.

It does not pick the primary camera. That selection lives in the component, because it depends on the URL, which the store can't see.

closeGraphicNotification sets open: false and deliberately leaves cameras populated, so reopening at the same site doesn't need the data passed again.

Primary camera selection​

When cameras changes, a useEffect runs:

candidates = cameras where id ≠ ?camera= in URL
primaryCamera = candidates.find(lastMoved) ?? candidates[0] ?? cameras[0]

A recently-moved camera wins, because it is the one most likely to be showing something. The final fallback to cameras[0] covers the case where the only camera at the site is the one already open.

Anchoring and placement​

The component projects the anchor with view.toScreen() and adds the map container's own viewport offset, then re-projects on every change to view.extent, view.rotation or the view's size (a reactiveUtils.watch, throttled to one update per animation frame). Placement is the pure placeCallout(anchor, size):

  • centred horizontally on the marker, sitting above it with a 14 px gap;
  • flipped below when there isn't room above inside the map;
  • clamped 12 px inside the map's edges, with the caret pinned to the marker's x;
  • hidden (not closed) once the marker is panned more than 24 px past a map edge — it reappears when the marker comes back.

If no anchor is known (a caller passed null) it falls back to the bottom-right corner, so the callout is never lost.

What the reader sees​

A medium-density card, built from the same primitives the rest of the app uses for a camera so nothing in it is bespoke: useLatencyBadge, getOfflineBadgeRealTime (tiers), AIIndicatorBtn, MoveIcon/ViewIcon, AlertMessageButton, and the square co-located labels from the location view header. Top to bottom:

  • Header: site name (or the camera name when the feed has no site), a subtitle — 3 cameras · Bald Mountain South, or just the camera name — and, on multi-camera sites, the co-located squares beside the title, then the ✕.
  • Co-located squares (multi-camera sites only), in the header row: one square per camera labelled with its siteLabel — the same squares the location view header uses, with the same ordering rule as CoLocatedCameras: the selected camera is always the first, highlighted square and the others follow in label order, so swapping cameras changes the label in that first square rather than moving the highlight along the row. A dot on a square shows how recently that camera moved, coloured with the map's line-of-sight tiers (getLineOfSightColorFromDate: red under 5 min, through pale pink under 2 h; none beyond). Clicking one retargets the card, it does not navigate; ←/→ step through them. An orange dot marks a recently-moved camera; the camera already open is drawn dashed. One click on the site circle therefore reaches every camera there without hunting for each arrow on the map.
  • Latest frame of the selected camera (thumbnailImageUrl, falling back to imageUrl), stamped Updated 12s ago. The slot shows a wave skeleton while the callout's poll is in flight and until the frame has decoded, so a stale or half-painted image is never mistaken for live. Offline cameras follow the tile grid: from the one-hour tier the last frame stays underneath the same OfflineWrapper overlay the tiles use (camera name, Camera Offline (2h 5m), click to see the last image), gated by the same enable-offline-button flag; at seven days only the dark backdrop is shown. The image carries no controls of its own: arrows or swipe over it read as "this camera has several images", which it does not.
  • AI badge: the same AIIndicatorBtn chip the tiles use (AI - 60 / AI - 360, opens the AI alert modal) sits in the status row beside the connection strength — the tile only overlays it on the image for lack of anywhere else — and the frame carries the tile grid's red pulse for ~10 s when a detection turns on. The chip reads whichever of the callout's own poll and the shared store reports a detection (pickAiRecord), so it shows on the location view — where the fleet poll that normally feeds the store is off — and before the callout's first poll lands on the tile view. The pulse honours aiPulsingCameraIds from the fleet poll and, once per camera per open, is also triggered by the callout's own poll. All of it is gated by useHasAiAccess, as on tiles.
  • Health badges: Online / Offline 2h 5m (from the offline tiers), the latency badge, Patrol on, Simulated, and the Move/View access icon from RBACStore.canControlCamera.
  • Pointing: one line — pan, tilt and zoom from real-time data (static values as a fallback), zoom printed exactly as the camera reports it — and Moved 5m ago, coloured with the same line-of-sight age tiers as the map's LOS layer (grey tier: plain secondary text).
  • Actions: Open camera (pushes /?camera=<id>), Send ALERT (same gate as AlertMessageButton: the enable-notifications flag and the Notification.Alert role), and on the tile view the same four tile actions the mobile sheet offers — full screen, 360° view, 60° and 360° time lapse — dispatched through FeatureMap's onAction prop.

Send ALERT does not open the panel inside the card. It writes an alertRequest (camera id plus the card's viewport rect) to the store and closes the callout; CalloutAlertPanel, rendered by FeatureMap beside the callout, then opens the app's AlertSidePanel with the alert pin pre-placed on the camera. The panel is anchored to the page's own Send ALERT button (tagged data-send-alert-anchor on AlertMessageButton) whenever one is on screen — the camera title bar in the location view, the open details tile in the tile view — so an alert started from the map opens in exactly the same place as one started from the page. When the page has no such button (the plain tile view), an invisible proxy stands in at the spot that button occupies on the location page relative to the map column — 16 px down from the map's top, right edge 16 px left of the map — so the panel opens under the title-row line over the content column on both pages and never covers the map the operator needs for the pin. Either way the callout is already closed, so panel and card never overlap and the callout is not left following the map while the alert flow pans it.

The card stays visibility: hidden until the anchor has been projected and the card measured, so it appears in place on the first visible frame rather than flashing at a fallback corner.

Coordinates, elevation, model and sponsor deliberately stay on the camera page: the callout is for deciding whether to open a feed, not for reading its spec sheet.

It closes on: Open camera, a tile action, the ✕, Escape, a pointerdown outside the card, and any change to the ?camera= param. Pointer-downs inside MUI portals (the Send ALERT panel, the AI alert modal, tooltips) are ignored, since those flows belong to the callout. A click on another marker closes it on pointerdown and the map's click handler reopens it for the new camera. There is no auto-hide — a callout the operator opened stays until they dismiss it or act on it.

Triggering it from elsewhere​

Any code can open the callout. Use getState() from event handlers and callbacks registered outside React; use the hook only to read state inside JSX.

import { useGraphicNotificationStore } from '@/stores/graphicNotificationStore';

useGraphicNotificationStore
.getState()
.showGraphicNotification(cameras, siteId, { latitude, longitude });

Wrapping up​

A store-driven callout that non-React map code opens with one getState() call, a primary-camera election that favors whoever moved last, and chips that retarget before anything navigates. That completes the map chapter — next feature: camera movement.