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.
| File | Owns |
|---|---|
util/map/featureMap/layerFactories.ts | Layer definitions, renderers, every colour and scale |
util/map/featureMap/geometryBuilders.ts | FOV wedge and LOS line geometry |
data/constants.ts | LOS 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.
| Knob | Current value | Where |
|---|---|---|
| AI-detection colour | #ff0000 | ARROW_COLORS.RED |
| Online colour | #000000 | ARROW_COLORS.BLACK |
| Offline colour (filled and outline) | #8f8f8f | ARROW_COLORS.GREY |
| Symbol footprint | 20×20 px, yoffset 14px | createArrowsRenderer |
| Visibility scale threshold | 3000000 | ARROW_VISIBILITY_SCALE_THRESHOLD |
Layer minScale | 10000000 | createArrowsLayer |
| Rotation source | field p, geographic | rotationVariable |
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.scale | 50 000 | 100 000 | 500 000 | 1 000 000 | 2 500 000 | 5 000 000 |
|---|---|---|---|---|---|---|
| size (px) | 11 | 11 | 10 | 10 | 6 | 8 |
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 age | losColorCode | Colour |
|---|---|---|
| under 5 min | under5min | #CB181D |
| 5–30 min | under30min | #FA6A4A |
| 30–60 min | under60min | #FDAE91 |
| 60–120 min | under120min | #FEE5D9 |
| over 120 min | over120min | #808080 |
| no timestamp | (unset) | #808080 — renderer default |
| Knob | Current value | Where |
|---|---|---|
| LOS line width | 1.25, with a 2.25 grey #5F6369 halo stroke behind it | LOS_WIDTH, createLosLineSymbol |
| Cursor-tracking LOS colour | black, [0, 0, 0, 1] | CURSOR_LOS_COLOR |
| LOS length | 40233.6 m (25 miles) | LOS_LENGTH in data/constants.ts |
| FOV fill and outline | [87, 89, 132, 0.5], outline width 0.5 | FOV_COLOR |
| FOV wedge angle | derived from camera model + zoom | calculateFovAngle |
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.
| Knob | Current value | Effect |
|---|---|---|
enabled | false | Operators turn it on from the map buttons |
clusterRadius | 120 px | Larger radius, fewer and bigger clusters |
style.minSize / maxSize | 30 / 60 px | Cluster circle diameter range |
maxScale | 100000 (CLUSTER_DISABLE_SCALE) | Clusters auto-disable at city level |
style.backgroundColor | #D9D9D9 at 0.5 opacity, white 2px border | Resting cluster |
hoverStyle.backgroundColor | #FFCD00, black halo | Hover 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
| Constant | Value | File |
|---|---|---|
ARROW_UPDATE_DEBOUNCE_MS | 100 | useFeatureMapPolling.ts |
EXTENT_CHANGE_DEBOUNCE_MS | 200 | useFeatureMapExtent.ts |
EXTENT_PERSIST_DEBOUNCE_MS | 500 | useFeatureMapExtent.ts |
| Timestamp-only diff grace period | 60000 ms | cameraChangeDetector.ts |
AI_DETECTION_PULSE_DURATION_MS | 10_000 | data/constants.ts |
| AI pulse store clear delay | 10600 ms | useFeatureMapPolling.ts |
CLUSTER_DISABLE_SCALE | 100000 | useFeatureMapClustering.ts |
ARROW_VISIBILITY_SCALE_THRESHOLD | 3000000 | layerFactories.ts |
Environment
| Variable | Constant | Default |
|---|---|---|
NEXT_PUBLIC_MASTER_MAP_PORTAL_ITEM_ID | MASTER_MAP_PORTAL_ITEM_ID | db68c79771164142a90466c0cb64d712 |
NEXT_PUBLIC_CAMERA_LAYER_NAME | CAMERA_LAYER_NAME | ALERTCalifornia 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
| Symptom | Most likely cause |
|---|---|
| Arrows in the ocean off West Africa | Static store hasn't loaded; coordinates fell back to parseFloat('0') |
| No site points, basemap fine | CAMERA_LAYER_NAME doesn't match the WebMap layer title |
| Arrows never rotate | p arriving as a string, or not in degrees |
| All LOS lines grey | imageTimestamp missing from the operational payload, so losColorCode is never set |
| All LOS lines one colour | Every timestamp in the same bucket, or the client clock is wrong — the buckets compare to Date.now() |
| Arrow state lags a stale image | Timestamp-only changes under 60 s are deliberately ignored |
| No red arrows despite AI hits | User lacks the AI role; getArrowStateCode gates on hasAiAccess |
| Arrows vanish when zooming out | Working as designed past scale 3 000 000, unless isRecentlyMoved is set |
| Clusters never disappear when zooming in | Clustering was applied after layer.when() resolved, so maxScale was dropped |
| Cursor LOS blinks | Delete and add issued as two applyEdits calls instead of one |
Related
- How the Map Thinks — why these values are shaped the way they are
- Meet the Map — run the arrow-state function live