Skip to main content

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 / LEGACY_CAMERA_LAYER_TITLES — 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:

  1. Optimistically flip isLayerOn in layersOnMap — the map responds instantly through the visibility pipeline
  2. markUserUpdated() — flag a pending user change
  3. Persist via useUpdateLayerVisibilityMutation — this is why layer choices follow the operator across sessions
  4. 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​

  1. Add a LayerModel to data/layers.ts — label, type: 'url', the FeatureServer dataLink, isLayerOn: false, and a popup template if it needs one. Nest it under a type: 'group' node to place it in the tree.
  2. Role-gated? Add 'My Layer': 'MyRole.Permission' to layersToAccessRequirements.
  3. Should never appear in the menu at all? Its label goes in LAYERS_TO_IGNORE or LEGACY_CAMERA_LAYER_TITLES in data/constants.ts instead.

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.