Skip to main content

Icons Without Image Files

Look for the arrow icon in public/ and you will find arrow-triangle.svg, which nothing on the map reads. The arrows, the coloured lines of sight, the cluster circles and the AI pulse are all produced in code, as renderer configuration, and the GPU does the painting. This page is the catalogue of how each visual is made, and the one rule behind all of them: appearance is a function of an attribute, never a property set on a feature.

The rule, and why it holds​

A FeatureLayer draws every feature with its layer's renderer. A UniqueValueRenderer maps the value of one field to a symbol; a SimpleRenderer uses one symbol for all; visual variables adjust rotation, size or opacity from a field or an Arcade expression. To change how a camera looks, we change its attribute with applyEdits({ updateFeatures }) and the renderer repaints it. We never call graphic.symbol = … on a feature layer, because that is exactly the per-graphic object allocation the previous map implementation drowned in. Four symbol types do all the work.

Historical, as of October 2026

The arrows renderer, the LOS colour ramp, the FOV fill, the AI pulse picture marker and the cluster symbols below belonged to the legacy client layers, which were removed. Camera points, arrows, wedges and beams are now the Velocity layers' symbols, authored in the portal; the app still draws the cursor line (a two-stroke CIM line, kept here) and pulses AI hits with a bloom featureEffect on the Velocity point layers.

Arrows: four generated SVGs​

createArrowsRenderer keys a UniqueValueRenderer on the arrowState string field. The four symbols are PictureMarkerSymbols whose url is a data:image/svg+xml;base64 string built at module load by getFilledArrowSvgUrl and getUnfilledArrowSvgUrl: one 200 × 200 path, filled or stroked, in red, black or grey. Each is 20 × 20 px with a 14 px yoffset so the arrow sits above the site circle rather than on it: red filled for aiDetection, black filled for online (also the default), grey filled for shortOffline, grey outline for longOffline.

The arrows renderer, on a workbenchDrag the scale out past 3,000,000, then click an arrow and change the attributes the renderer keys on.
county · size 10.3 px36 / 36 arrows drawn9 recently moved
click an arrow

Drag the scale out past 3,000,000 and watch the Arcade gate keep only the cameras that moved recently. Then pick an arrow and change the attributes the renderer keys on.

Three visual variables ride on top. Rotation reads the p field with rotationType: 'geographic', so a pan of 90° points the arrow east. Size is a stepped function of $view.scale with six stops between 50,000 and 5,000,000 (and it is not monotonic: the 2.5M stop is 6 px, the 5M stop 8 px). Opacity is an Arcade expression evaluated by the GPU:

var scaleThreshold = 3000000; // ARROW_VISIBILITY_SCALE_THRESHOLD
var currentScale = $view.scale;
var recentlyMoved = IIf(
IsEmpty($feature.isRecentlyMoved),
0,
$feature.isRecentlyMoved,
);
When(
currentScale <= scaleThreshold,
1, // zoomed in: everything
recentlyMoved == 1,
1, // zoomed out: only cameras that just moved
0,
);

Zooming costs no JavaScript at all, and isRecentlyMoved is stored as an integer precisely because Arcade compares it numerically. The lab above runs exactly these two rules.

Lines of sight: a two-stroke CIM line​

LOS colour encodes the age of the camera's latest image, bucketed by getLosColorCodeFromDate into five losColorCode values (under 5, 30, 60 and 120 minutes, then over), or undefined when no timestamp exists, which falls through to the renderer's default grey. Each symbol is a CIMSymbol holding a CIMLineSymbol with two CIMSolidStroke layers: the coloured stroke at LOS_WIDTH 1.25 on top and a UtilityGrey halo one unit wider beneath it, so the line reads on imagery and topo alike. CIM draws symbol layers last-to-first, which is why the halo is listed second. The cursor-tracking line uses the same builder in black.

Field of view and the cursor line​

The FOV wedge is the one SimpleRenderer on our layers: a SimpleFillSymbol in [87, 89, 132] at 50% with a half-point outline of the same colour. Its shape is where the work is: a donut-wedge polygon from createFovPolygon whose inner arc starts 10 px from the camera (converted to metres at the current view resolution, so it stays clear of the arrow at any zoom) and whose outer arc is 25 miles out, latitude-corrected for Web Mercator. The cursor line starts 7 px from the point under the standard source and, under Velocity, at the tip of the stream marker, read off the marker's own CIM frame by getPointMarkerTipOffsetPx.

The wedge and the lineChange the view resolution and watch which distance holds still on screen; raise the latitude and see the Web Mercator correction.

Zoom with the resolution slider: the outer arc shrinks and grows (it is 25 miles of ground), while the dashed inner ring stays exactly 10 px so the wedge never overlaps the arrow. Raise the latitude and the wedge reaches further than the thin dashed arc — Web Mercator stretches with cos(latitude), so the builder multiplies the radius by 1.25 at 37° N.

The AI pulse: an animated picture marker​

When a detection starts, activateArrowsPulse clones the arrows renderer and replaces only the aiDetection entry with a CIMSymbol whose single layer is a CIMPictureMarker pointing at /ai-detection-pulse.gif, 40 px, offset 30, with animatedSymbolProperties set to loop over 2.5 s with ease-in-out. The SDK drives the animation; there is no JavaScript loop. After the pulse window deactivateArrowsPulse installs a fresh static renderer. Because the swap is at renderer level, every AI-state arrow pulses, not only the triggering one. That trade is deliberate: a per-camera pulse would mean an edit plus a per-value CIM symbol on every hit.

Clusters​

createClusterConfig builds a FeatureReductionCluster for the camera service: radius 120 px, circles from 30 to 60 px, disabled below scale 100,000, a simple-marker circle in #D9D9D9 at 50% with a white 2 px outline, and a centre label from the Arcade expression Text($feature.cluster_count, '#,###'). The clusterFilter is Arcade too ($feature.isRecentlyMoved != true), so a camera that just moved is never swallowed by a circle. Hover highlighting uses the layer's highlightOptions in #FFCD00 with a black halo, applied through the layer view's highlight().

The rest: static markers, kept honest​

A few graphics use plain symbols because they are one-offs:

  • Recenter marker (after a search): PictureMarkerSymbol from /images/distance.svg, 44 px, removed on click after a one-second grace so the initiating click does not dismiss it.
  • Coordinates crosshair: a 7 px white SimpleMarkerSymbol circle with a blue outline.
  • Alert beacon: a diamond SVG from util/map/alertBeaconSvg.ts, shared by the in-app pin and the emailed capture so the two always match; the radius ring is a geodesic Circle.
  • Area scope: ALERTCalifornia yellow fills and outlines, dashed for the buffer.
  • Sightings: the app's blue for points and footprints, violet for a cross-fix, TextSymbol labels with a dark halo; deliberately never a fire colour.
  • what3words: a 3 m square in Esri red with a white and a dashed red edge, nothing else.
  • Pick cursors: the crosshair shown while a pick mode is armed is a CSS cursor on the view container, not an ArcGIS symbol.

What is not ours: the site circle under each arrow and the Velocity point marker (a CIM vector marker with a scale-driven size variable) are portal renderers. Restyle those in ArcGIS Online.

Wrapping up​

Appearance is attribute-driven. Arrows are four generated SVGs under a UniqueValueRenderer with rotation, stepped size and an Arcade opacity gate; lines are two-stroke CIM symbols coloured by image age; the wedge is a simple fill on a computed donut; the AI pulse is an animated picture marker swapped in at renderer level; clusters are a feature reduction with Arcade labels. To add a state, extend the renderer and the diff snapshot together.

Next: From Poll to Pixel, how those attributes get written.