Skip to main content

Tuning the Map

Unlike the lessons before it, this page is a reference sheet — every tuning knob the map exposes, its current value, and which file to edit. Read How the Map Thinks first: changing a value here without that context is how symbology quietly breaks. Come back whenever a knob needs turning or the symptom table at the bottom needs consulting.

Almost everything lives in three files. Nothing here is runtime-configurable; these are source constants, and a change means a deploy.

FileOwns
util/map/featureMap/layerFactories.tsLayer definitions, renderers, every colour and scale
util/map/featureMap/geometryBuilders.tsFOV wedge and LOS line geometry
data/constants.tsLOS length, portal item ID, camera layer name, pulse duration

Arrows

Arrow SVGs are generated at module load, base64-encoded into data URLs, and cached — one per state. There is no arrow asset file to swap. (public/images/arrow-triangle.svg exists but nothing on this map reads it; ignore it.) To change the shape, edit the path inside getFilledArrowSvgUrl / getUnfilledArrowSvgUrl.

KnobCurrent valueWhere
AI-detection colour#ff0000ARROW_COLORS.RED
Online colour#000000ARROW_COLORS.BLACK
Offline colour (filled and outline)#8f8f8fARROW_COLORS.GREY
Symbol footprint20×20 px, yoffset 14pxcreateArrowsRenderer
Visibility scale threshold3000000ARROW_VISIBILITY_SCALE_THRESHOLD
Layer minScale10000000createArrowsLayer
Rotation sourcefield p, geographicrotationVariable

Arrow size is a stepped visual variable against $view.scale, not a smooth ramp, and it is not monotonic — the 2.5M step is smaller than the 5M step above it:

$view.scale50 000100 000500 0001 000 0002 500 0005 000 000
size (px)1111101068

Line of sight and field of view

LOS colour is driven by the losColorCode attribute, bucketed from image age by getLosColorCodeFromDate. Edit LOS_COLOR_MAP for the colours, or the thresholds in that function for the buckets — but change both together, since the code names encode the buckets.

Image agelosColorCodeColour
under 5 minunder5min#CB181D
5–30 minunder30min#FA6A4A
30–60 minunder60min#FDAE91
60–120 minunder120min#FEE5D9
over 120 minover120min#808080
no timestamp(unset)#808080 — renderer default
KnobCurrent valueWhere
LOS line width1.25, with a 2.25 grey #5F6369 halo stroke behind itLOS_WIDTH, createLosLineSymbol
Cursor-tracking LOS colourblack, [0, 0, 0, 1]CURSOR_LOS_COLOR
LOS length40233.6 m (25 miles)LOS_LENGTH in data/constants.ts
FOV fill and outline[87, 89, 132, 0.5], outline width 0.5FOV_COLOR
FOV wedge anglederived from camera model + zoomcalculateFovAngle

FOV width is not a constant: calculateFovAngle(cameraModel, zoomValue) looks the angle up per model, and falls back to 'Q6075-E' when the model is missing rather than failing. A camera with an unknown model draws a plausible, wrong wedge. If zoomValue is 0 or less it returns DEFAULT_FOV_ANGLE (60°) instead.

Clustering

Clustering applies to the AGOL camera-point layer and is off by default. Everything comes from DEFAULT_CLUSTERING_CONFIG in hooks/featureMap/useFeatureMapClustering.ts.

KnobCurrent valueEffect
enabledfalseOperators turn it on from the map buttons
clusterRadius120 pxLarger radius, fewer and bigger clusters
style.minSize / maxSize30 / 60 pxCluster circle diameter range
maxScale100000 (CLUSTER_DISABLE_SCALE)Clusters auto-disable at city level
style.backgroundColor#D9D9D9 at 0.5 opacity, white 2px borderResting cluster
hoverStyle.backgroundColor#FFCD00, black haloHover highlight
exclusionProperties['isRecentlyMoved']Recently-moved cameras never join a cluster

The exclusion is the non-obvious one. It compiles to an Arcade clusterFilter ($feature.isRecentlyMoved != true), so a camera that just moved always renders as its own point — which is the entire reason clustering is safe to enable during an incident.

Timing and scale constants

ConstantValueFile
ARROW_UPDATE_DEBOUNCE_MS100useFeatureMapPolling.ts
EXTENT_CHANGE_DEBOUNCE_MS200useFeatureMapExtent.ts
EXTENT_PERSIST_DEBOUNCE_MS500useFeatureMapExtent.ts
Timestamp-only diff grace period60000 mscameraChangeDetector.ts
AI_DETECTION_PULSE_DURATION_MS10_000data/constants.ts
AI pulse store clear delay10600 msuseFeatureMapPolling.ts
CLUSTER_DISABLE_SCALE100000useFeatureMapClustering.ts
ARROW_VISIBILITY_SCALE_THRESHOLD3000000layerFactories.ts

Environment

VariableConstantDefault
NEXT_PUBLIC_MASTER_MAP_PORTAL_ITEM_IDMASTER_MAP_PORTAL_ITEM_IDdb68c79771164142a90466c0cb64d712
NEXT_PUBLIC_CAMERA_LAYER_NAMECAMERA_LAYER_NAMEALERTCalifornia Cameras

CAMERA_LAYER_NAME is how the app finds the camera-point layer inside the WebMap by title. If the portal item is re-authored and that layer is renamed, the map loads with no site points, no clustering, and no extent filtering — and logs nothing obvious. Check this first when the basemap renders but cameras don't.

LAYERS_TO_IGNORE (topo labels + imagery labels + CLIENT_LAYERS) is what keeps basemaps and our own four layers out of the Layers Menu. Add a client-side layer and you must add its title to CLIENT_LAYERS, or it shows up as a user-toggleable layer.

Adding a layer

Prefer the WebMap portal: add the layer in ArcGIS Online, save, and it loads automatically with no code change. Only build a FeatureLayer in layerFactories.ts when the geometry is computed client-side from camera state — that is the whole distinction between our four layers and everything else on the map.

When it looks wrong

SymptomMost likely cause
Arrows in the ocean off West AfricaStatic store hasn't loaded; coordinates fell back to parseFloat('0')
No site points, basemap fineCAMERA_LAYER_NAME doesn't match the WebMap layer title
Arrows never rotatep arriving as a string, or not in degrees
All LOS lines greyimageTimestamp missing from the operational payload, so losColorCode is never set
All LOS lines one colourEvery timestamp in the same bucket, or the client clock is wrong — the buckets compare to Date.now()
Arrow state lags a stale imageTimestamp-only changes under 60 s are deliberately ignored
No red arrows despite AI hitsUser lacks the AI role; getArrowStateCode gates on hasAiAccess
Arrows vanish when zooming outWorking as designed past scale 3 000 000, unless isRecentlyMoved is set
Clusters never disappear when zooming inClustering was applied after layer.when() resolved, so maxScale was dropped
Cursor LOS blinksDelete and add issued as two applyEdits calls instead of one