The Layers Menu
Open the layers drawer and you're looking at a tree: groups that expand into more groups, leaves with checkboxes, badges counting what's on. Behind it sits a genuinely nice piece of recursive React — and three behaviors (state merging, RBAC pruning, expansion memory) that you'd only discover by reading the source. Let's walk it.
One tree, two sources of truth
The store hands the menu two things that must be combined: layersTree, the static nested
structure of groups and layers, and layersOnMap, the flat live list carrying each layer's
current isLayerOn. LayersMenu merges the live flags into the tree in a useMemo
(layerTreeWithState) so accordion counts stay accurate, then prunes anything in
LAYERS_TO_IGNORE / HIDDEN_CAMERA_LAYERS — plus any group left empty by the pruning.
Sorting picks a rendering strategy, not just an order: Categories walks the nested tree,
while the two Alphabetical modes bypass the tree entirely and render the flat layersOnMap
sorted. Search is deferred (useDeferredValue) so typing never blocks a render, and in tree
mode it prunes whole groups whose subtree matched nothing.
The recursive walk
RecursiveLayerNode routes every node by shape:
type === 'group'with children →LayerCategoryAccordian, which recurses- a leaf with
dataLink→LayerOption— a real, toggleable layer - a leaf without
dataLink→LayerCategory— a display-only label
But before any of that: canUserAccessLayer(node). A layer whose label appears in
layersToAccessRequirements (in data/layers.ts) requires the matching role from the user's
session — and an inaccessible node renders null, vanishing from the tree and every count.
Operators without the role never learn the layer exists.
Accordion intelligence
Two small behaviors make the accordions feel right:
The count badge tells the truth. Top-level groups show selected/total, computed by a
recursive walk of the full subtree that skips inaccessible nodes, groups, and display-only
labels — so the badge counts exactly the checkboxes a this user could actually toggle.
Nested groups show plain names; the badge is level === 0 only.
Expansion respects the user. A group with any active layer starts expanded — helpful
orientation. But once the user manually collapses it, a userToggledRef locks their choice:
later state changes won't spring it back open. Auto-behavior yields to human intent.
The toggle, optimistically
Checking a box does four things, in an order chosen for perceived speed:
- Optimistically flip
isLayerOninlayersOnMap— the map responds instantly through the visibility pipeline markUserUpdated()— flag a pending user change- Persist via
useUpdateLayerVisibilityMutation— this is why layer choices follow the operator across sessions resetUserUpdated()on success
LayerOption also surfaces layer health inline: a spinner while the layer loads, and a
greyed-out row with a ! badge (tooltip explains) when it failed.
How-to: adding a new layer
- Add a
LayerModeltodata/layers.ts—label,type: 'url', the FeatureServerdataLink,isLayerOn: false, and a popup template if it needs one. Nest it under atype: 'group'node to place it in the tree. - Role-gated? Add
'My Layer': 'MyRole.Permission'tolayersToAccessRequirements. - Should never appear in the menu at all? Its label goes in
LAYERS_TO_IGNOREorHIDDEN_CAMERA_LAYERSindata/constants.tsinstead.
Remember from Layers from the Portal: if the layer comes from the AGOL WebMap, prefer adding it in the portal — it loads with no code change, and the menu picks it up automatically.
Wrapping up
Static tree + live flags, merged and pruned; a recursive router that turns node shape into component choice; counts and expansion that respect both RBAC and the user's hands; and optimistic toggles persisted per operator. When a layer is "missing," check RBAC pruning and the ignore lists before anything else.
Next feature: Expand mode.