Hit Tests and the Click Budget
A MapView reports where the pointer is; it does not know what a camera is. Every interaction on
the map is therefore a hitTest followed by our own decision about which of the dozen possible
hits matters, in which order, on which device. Get the order wrong and clicking a site opens one
camera instead of six; get the scope wrong and every mouse move walks a hundred layer views. This
page is the list of listeners, what each one hit-tests, and the rules that keep them from
interfering.
Hover: the hit test that pays rent
useFeatureMapHover.setupHoverHandlers registers one pointer-move listener. Every call passes
an include list to hitTest: the Velocity camera layers on the map (points, beams and their
persisted companions). Without include, each move hit-tests every layer
view in the WebMap, about a hundred of them, and that was a measurable fraction of main-thread
time during a pan. An isProcessing flag drops moves that arrive while the previous one is still
resolving.
The decision order is fixed: a point hit (Locations or the Camera Feed, or a persisted
companion of it) wins over a beam hit, because hovering a site should show every camera there.
A site hit is de-duplicated on its camera id, which is read straight off the hit graphic (stream
graphics carry every field; the feature layers load with outFields: ['*']); the site's cameras
come from getCamerasBySiteId, and the beam seam is called with them. In tile view that is a show
(replace the beam set); in location view it is an add that leaves the selected camera alone.
Leaving both resets the cursor and the hovered-cameras store. No hover or tap ever queries a
service: the feature-query fallback that used to run against the camera service was the single
largest source of ArcGIS 429s, and it went with that service.
nothing but the basemap
nothing
A click on the basemap does nothing on desktop; on touch, the tap handler returns early so the map interaction proceeds.
Every pointer move hit-tests two layer views: the camera service and the arrows. Switch the include list off and the same moves walk every layer view in the WebMap.
If a hit graphic does not carry cId, the handler falls back to queryFeatures on the camera
service for that ObjectId. Until the layer's outFields was set at init, that fallback ran on
every hovered site: one network request per hover against a rate-limited service, and half of all
ArcGIS 429s in production during August 2026. The fallback stays as a safety net. If you see it
firing in the network tab, something has reset the layer's outFields.
The hovered-camera store is written from the map through a wrapper that sets a flag for 50 ms, so the effect that mirrors list hover onto the map does not echo a map hover back through the store and clear what the map just drew. Hover is bidirectional, and that flag is the only thing keeping it from oscillating.
Click on desktop, tap on mobile
Two different listeners, never both:
- Desktop registers
addCameraClickHandleronclick. It hit-tests the whole view (clusters need it), and resolves in this order: a cluster aggregate zooms to level 14 over 1.5 s and stops; otherwise an arrow, LOS or FOV hit, or a Velocity beam normalised to carrycId, becomes the "arrow" hit; a non-aggregate camera-service hit, or a Velocity point, becomes the "point" hit, enriched by onequeryFeaturesfor the full feature.FeatureMap.onCameraClickedthen opens the callout for one camera (arrow) or every camera at the site (point), and suppresses it when the clicked camera is already the one in the URL. - Mobile and landscape register
setupTapHandlersonimmediate-click, which fires without waiting for a double-click, with the same include list as hover. A camera hit opens the bottom drawer with the tile actions; no hit means the map interaction proceeds untouched.
The listeners are registered in an effect keyed on mapView and isMapReady, and torn down with
it. The hover and tap setup functions are memoised on things that do not change per poll (the
real-time store is read at event time through getState(), not subscribed), because an earlier
version re-registered all three handlers on every SignalR frame and re-queried the visible pins
each time.
Clicks on portal layers
addLayerClickHandler registers a second click listener that hit-tests everything, closes any
native popup when a camera was under the pointer, and hands the top-most graphic from a named
layer to onLayerClicked. Today the only consumer is the Alerts layer: a click on an alert
polygon toggles the alert modal with the graphic's attributes. Three guards sit in front of it:
the area pick mode, the coordinates pick mode, and isAlertEditActive, because a click meant to
reposition an alert's pin can land on the alert's own still-visible marker and close the modal
mid-edit.
Pick modes own the next click
Two features arm a mode where the next map click belongs to them: the area filter's "Pick on map"
and the Get Coordinates button. Both follow the same template, in useAreaMapPick and
useCoordinateMapPick, and any future "arm, then tap" feature should copy it:
- Save and disable
view.popupEnabled; set a crosshaircursoron the container. - Register a
clicklistener that callsevent.stopPropagation()and does its work (an include-scoped hit test against visible boundary layers; or the map point and its client coordinates). - Disarm on completion, on Escape, and in an unmount cleanup, so a hidden map never comes back swallowing clicks.
- Restore the popup flag and the cursor.
Because handler order between separately registered listeners is not ours to rely on, the other click handlers also check the pick stores before acting. The two modes are mutually exclusive: arming one disarms the other. The area pick hit-tests only layers that are visible and on the map, and says so up front when none is, rather than letting the operator tap an empty basemap.
Right-click, long-press, and the callout
The coordinates card opens three ways to the same state. A native contextmenu listener on the
view container converts the mouse position to a map point with view.toMap. ArcGIS's hold
gesture, filtered to pointerType === 'touch', covers phones and tablets, which have no
right-click. And the pick mode above is the discoverable entry. All three set the same
coordPopover* state and drop a small crosshair graphic at the point.
The camera callout is the other direction: React following a map point. CameraCallout projects
its anchor with view.toScreen(), adds the container's own offset, and re-projects on a
reactiveUtils.watch of extent, rotation and size, throttled to one update per animation frame,
so the card stays glued to its marker through a pan. Placement is a pure function the feature
lesson documents. One gotcha applies to every React surface positioned over the map: the view's
container lives inside the <arcgis-map> element's shadow root, where the app's stylesheets do
not reach, so sightings and what3words portal their chips into the light-DOM region labelled
"Feature Map" instead.
Cluster hover
Removed in October 2026 together with the legacy camera feature service it clustered; a StreamLayer has no feature reduction.
The rules
- Always pass
includetohitTeston hover-rate events. - Read attributes off the hit graphic; a feature query on hover is a rate-limit bug.
- Point hits beat arrow hits. Clusters beat everything and never open a camera.
- One click listener per concern, each checking the pick stores before acting.
- A mode that swallows clicks disarms on unmount and on Escape, without exception.
- React overlays on the map portal into the light DOM and follow the view with
reactiveUtils.
Wrapping up
The map hears pointer moves, clicks, taps, holds and context menus; we decide what they mean by
hit-testing a deliberately short list of layers in a fixed priority. Pick modes temporarily own
the next click and clean up after themselves. Everything React draws over the map follows it
through toScreen and a throttled watch.
Next: Two Filters, Two Properties.