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.
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.
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.
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):
PictureMarkerSymbolfrom/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
SimpleMarkerSymbolcircle 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 geodesicCircle. - 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,
TextSymbollabels 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
cursoron 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.