geoview

Layer Set Architecture

** Audience:** Core GeoView developers

For API Users: See Layer Sets Guide for using Layer Sets in your applications

This document describes the internal architecture and implementation details of the Layer Set system in GeoView.

Overview

Layer Sets are a core architectural pattern in GeoView that manage synchronized collections of layer-specific data. They provide a reactive, event-driven mechanism for tracking legends, feature queries, and layer state across the application.

Architecture Principles

1. Abstract Base Pattern

All Layer Sets extend AbstractLayerSet, which provides:

export abstract class AbstractLayerSet {
  protected resultSet: TypeResultSet = {};

  protected onRegisterLayerConfigCheck(layerConfig: ConfigBaseClass): boolean;
  protected abstract onPropagateToStore(
    resultSetEntry: TypeResultSetEntry,
    type: PropagationType,
  ): void;
}

2. Result Set Structure

Each Layer Set maintains a resultSet object keyed by layer path:

type TypeResultSet = {
  [layerPath: string]: TypeResultSetEntry;
};

interface TypeResultSetEntry {
  layerPath: string;
  layerName: string;
  layerStatus: "processing" | "processed" | "error";
  queryStatus?: "init" | "processed" | "error";
  featureInfo?: TypeFeatureInfo;
  items?: TypeLegendItem[];
  children?: string[];
  error?: unknown;
}

3. Event-Driven Synchronization

Layer Sets use an event-driven system to track layer state changes and propagate updates:

Inbound Events (Layer Sets listen to):

Outbound Events (Layer Sets emit):

Layer Set Implementations

LegendsLayerSet

Purpose: Tracks layer status progression and fetches legend/symbology data for all layers.

Store Connection: Propagates to layer-state store via LayerSetController

Registration Condition:

/**
 * Overrides the behavior to apply when checking for condition to register a layer in its set.
 *
 * @param layer - The layer
 * @returns True when the layer should be registered to this legends-layer-set
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected override onRegisterLayerCheck(layer: AbstractBaseGVLayer): boolean {
  // Always register layers for the legends-layer-set, because we want 'the box' in the UI to show the layer status progression
  return true;
}

Key Features:

Event Flow:

  1. Layer config added → Register in resultSet with legendQueryStatus: 'init'
  2. Layer created → Track layer status progression (init → loading → loaded → processed)
  3. Layer reaches ‘processed’ status → Query legend via layer.queryLegend()
  4. Legend query starts → Set legendQueryStatus: 'querying'
  5. Legend data received → Store legend data, set legendQueryStatus: 'queried', propagate to store
  6. Style changes → Re-query legend automatically

Legend Data Flow: styleConfig, icons, and items

Every legend entry in the store (TypeLegendLayer) carries three related-but-distinct fields that the UI uses to decide what to render. Confusing them leads to “empty legends”, “icons but no labels”, or duplicate WMS-image renders, so this section documents how they are produced and how the UI distinguishes the three legend modes.

The three fields
Field Type Origin Purpose
styleConfig TypeLayerStyleConfig (per geometry) Returned by the layer’s getLegend() (the rendering style — simple, uniqueValue, classBreaks) Drives map rendering AND tells the UI that class-based items are available. Always present for vector / ESRI_IMAGE layers when the service exposes a classification.
icons TypeLegendLayerItem[] Computed by GeoUtilities.getLayerIconImage() from the TypeLegend payload Per geometry type entry containing a primary iconImage (data URL or 'no data') plus an iconList of nested items. Holds the WMS / WMTS / static-image legend graphic when the service returns one.
items TypeLegendItem[] Flattened by GeoUtilities.getLayerItemsFromIcons() from icons[*].iconList Flat per-class entries ({ name, icon, isVisible, geometryType }) used to render the clickable per-class legend list under the layer.

The pipeline: gv-layer.getLegend()TypeLegend { type, styleConfig, legend }LegendsLayerSet.#propagateToStoreLegendQueryStatus()GeoUtilities.getLayerIconImage() + getLayerItemsFromIcons() → store fields styleConfig, icons, items.

The three legend display modes

The UI (legend-layer-container.tsx, legend-layer.tsx, layer-details.tsx) uses two predicates (layerHasClassItems, layerHasLegendImage — both in types.ts) to pick exactly one of these modes per layer:

Mode Predicate Triggered when UI rendered
Class-based items layerHasClassItems(items, styleConfig, minItems?) items.length >= minItems (default 1) and styleConfig is set Per-class clickable list (ItemsList / renderItems)
Service legend image layerHasLegendImage(schemaTag, items, icons, styleConfig) WMS/WMTS only; no class items; icons[0].iconImage exists and is not the string 'no data' Single image (WMSLegendImage / renderWMSImage)
Nothing Neither predicate matches (no icons, no items, no styleConfig) Nothing rendered

layerHasClassItems takes an optional minItems argument. CollapsibleContent uses minItems = 2 so that a layer with a single class item collapses (no point in expanding a list of one), while the rest of the UI uses the default 1 to detect any presence of class data.

Mode selection — the key insight

The mode chosen depends on which gv-layer produced the TypeLegend and what its service returned:

The 'no data' sentinel

GeoUtilities.getLayerIconImage() writes the literal string 'no data' into iconImage when a non-vector legend has no usable canvas. All consumers that render a <img> for the legend must check for and exclude this sentinel (layerHasLegendImage does this). Forgetting the check produces a broken-image icon in the UI and — because React 19 emits a <link rel="preload" as="image" href="no data"> for every <img> it sees — can break downstream consumers like the PDF/PNG export pipeline (createCanvasMapUrls) by adding a zero-size element to the rendered HTML.

styleConfig is the rendering authority — not the legend

styleConfig is the rendering style. The icons and items are presentation artifacts derived from it. Two practical consequences:

  1. A vector layer always has a styleConfig even if it has no classes (simple style → exactly one item). When you see “no class items”, check styleSettings.type before assuming the data is missing.
  2. Toggling an item’s visibility must propagate back to styleConfig.info[*].visible so the renderer hides the class on the map. The reverse is not true — modifying styleConfig does not automatically update items; the legend must be re-queried (or the store updated explicitly — see setStoreLayerItemVisibility).
WMTS-specific quirk

WMTS layers use gv-wmts.ts#getLegendFromCapabilities(). It first tries the standard LegendURL from the OGC capabilities. If that fails, it falls back to ArcGIS’s /legend?f=json endpoint when the metadata URL looks like an ArcGIS-backed WMTS (/MapServer/WMTS or /ImageServer/WMTS). In that fallback, the returned TypeLegend.type is set to ESRI_IMAGE (not WMTS) so it flows through the same vector-style pipeline as ESRI Image / Dynamic. The legend appears as class-based items even though the layer is rendered as WMTS tiles. The store’s schemaTag remains WMTS (from the config), but legendSchemaTag reflects what came back from the legend pipeline.

Common pitfalls

FeatureInfoLayerSet

Purpose: Query and manage features at specific map locations.

Store Connection: Propagates to feature-info-state store via propagateStoreFeatureInfoDetails()

Registration Condition:

/**
 * Overrides the behavior to apply when a feature-info-layer-set wants to check for condition to register a layer in its set.
 *
 * @param layer - The layer
 * @returns True when the layer should be registered to this feature-info-layer-set
 */
protected override onRegisterLayerCheck(layer: AbstractBaseGVLayer): boolean {
  // Return if the layer is of queryable type and source is queryable
  return super.onRegisterLayerCheck(layer) && AbstractLayerSet.isQueryableType(layer) && AbstractLayerSet.isSourceQueryable(layer);
}

Query Method:

async queryLayers(location?: TypeLocation, extent?: Extent): Promise<void> {
  // For each registered layer:
  // 1. Call layer.queryAtCoordinate() or queryAtPixel()
  // 2. Update resultSet[layerPath].featureInfo
  // 3. Set queryStatus to 'processed' or 'error'
  // 4. Propagate to store
}

Visibility and Scale Gating:

This keeps feature-query behavior aligned with rendering visibility, including min/max scale constraints and near-threshold tolerance bands.

Key Features:


AllFeatureInfoLayerSet

Purpose: Query and manage ALL features from layers (no spatial filter).

Store Connection: Propagates to feature-info-state store via propagateFeatureInfoDataTableToStore()

Registration Condition:

/**
 * Overrides the behavior to apply when a feature-info-layer-set wants to check for condition to register a layer in its set.
 *
 * @param layer - The layer
 * @returns True when the layer should be registered to this all-feature-info-layer-set
 */
protected override onRegisterLayerCheck(layer: AbstractBaseGVLayer): boolean {
  // Exclude raster image layers that don't support tabular feature queries
  if (layer instanceof GVEsriImage) return false;

  // Return if the layer is of queryable type and source is queryable
  return (
    super.onRegisterLayerCheck(layer) &&
    AbstractLayerSet.isQueryableType(layer) &&
    !(layer instanceof GVWMS) &&
    AbstractLayerSet.isSourceQueryable(layer)
  );
}

Query Method:

async queryLayers(location?: undefined, extent?: Extent): Promise<void> {
  // For each registered layer:
  // 1. Call layer.getAllFeatures() or queryInExtent()
  // 2. Update resultSet[layerPath].featureInfo.features (all features)
  // 3. Set queryStatus to 'processed'
  // 4. Propagate to store
}

Key Features:

Performance Considerations:


HoverFeatureInfoLayerSet

Purpose: Query features under mouse cursor for hover tooltips.

Store Connection: Propagates to feature-info-state store via setStoreMapHoverFeatureInfo()

Registration Condition:

/**
 * Overrides the behavior to apply when a hover-feature-info-layer-set wants to check for condition to register a layer in its set.
 *
 * @param layer - The layer
 * @returns True when the layer should be registered to this hover-feature-info-layer-set
 */
protected override onRegisterLayerCheck(layer: AbstractBaseGVLayer): boolean {
  // Return if the layer is of queryable type and source is queryable
  return (
    super.onRegisterLayerCheck(layer) &&
    AbstractLayerSet.isQueryableType(layer) &&
    !(layer instanceof GVWMS) &&
    AbstractLayerSet.isSourceQueryable(layer)
  );
}

Query Method:

async queryLayers(location: TypeLocation): Promise<void> {
  // Debounced query at hover location
  // Similar to FeatureInfoLayerSet but optimized for hover
}

Key Features:

Optimization:

Layer Set Lifecycle

1. Creation

Layer Sets are instantiated when LayerApi is created:

// In LayerApi constructor
this.legendsLayerSet = new LegendsLayerSet(this);
this.featureInfoLayerSet = new FeatureInfoLayerSet(this);
this.allFeatureInfoLayerSet = new AllFeatureInfoLayerSet(this);
this.hoverFeatureInfoLayerSet = new HoverFeatureInfoLayerSet(this);

2. Layer Registration

When a layer config is added:

// AbstractLayerSet
protected registerLayerConfig(layerConfig: ConfigBaseClass): void {
  if (this.onRegisterLayerConfigCheck(layerConfig) && !(layerConfig.layerPath in this.resultSet)) {
    // Create default entry
    this.resultSet[layerConfig.layerPath] = { layerPath: layerConfig.layerPath, layerStatus: 'processing' };

    // Propagate to store
    this.onPropagateToStore(this.resultSet[layerConfig.layerPath], 'config-registration');

    // Emit event
    this.#emitLayerSetUpdated(this.resultSet[layerConfig.layerPath], 'config-registration');
  }
}

3. Layer Creation

When actual layer is created (OpenLayers layer instantiated):

protected registerLayer(layer: AbstractBaseGVLayer): void {
  const layerPath = layer.getLayerPath();

  if (layerPath in this.resultSet) {
    // Update status
    this.resultSet[layerPath].layerStatus = 'processing';

    // Layer-specific initialization
    this.onRegisterLayer(layer);

    // Propagate
    this.onPropagateToStore(this.resultSet[layerPath], 'layer-registration');
    this.#emitLayerSetUpdated(this.resultSet[layerPath], 'layer-registration');
  }
}

4. Data Updates

When layer data changes (style loaded, features queried, etc.):

protected updateResultSetEntry(layerPath: string, updates: Partial<TypeResultSetEntry>): void {
  if (layerPath in this.resultSet) {
    Object.assign(this.resultSet[layerPath], updates);

    // Propagate
    this.onPropagateToStore(this.resultSet[layerPath], 'resultSet');
    this.#emitLayerSetUpdated(this.resultSet[layerPath], 'resultSet');
  }
}

5. Layer Removal

When a layer is removed:

protected unregisterLayer(layerPath: string): void {
  if (layerPath in this.resultSet) {
    const entry = this.resultSet[layerPath];

    // Clean up
    delete this.resultSet[layerPath];

    // Propagate removal
    this.onPropagateToStore(entry, 'remove');
    this.#emitLayerSetUpdated(entry, 'remove');
  }
}

Store Integration

Propagation Pattern

Each Layer Set implements onPropagateToStore() to update the Zustand store. Each concrete layer set has a private #propagateToStore() method that calls the appropriate store propagation function directly:

  /**
   * Propagates the resultSetEntry to the store.
   *
   * @param resultSetEntry - The result set entry to propagate to the store
   */
  #propagateToStore(resultSetEntry: TypeFeatureInfoResultSetEntry): void {
    // Propagate
    propagateStoreFeatureInfoDetails(this.getMapId(), resultSetEntry);
  }

Propagation functions are exported from the store state files (e.g., propagateStoreFeatureInfoDetails from feature-info-state.ts, propagateFeatureInfoDataTableToStore from the same file) and are called by both layer sets and LayerSetController.

Store Structure

Store slices mirror Layer Set resultSets:

// In Layer State store slice
export interface ILayerState {
  highlightedLayer: string;
  selectedLayer: TypeLegendLayer;
  selectedLayerPath: string | undefined | null;
  legendLayers: TypeLegendLayer[];
  displayState: TypeLayersViewDisplayState;
  layerDeleteInProgress: string;
  selectedLayerSortingArrowId: string;
  layersAreLoading: boolean;
  setDefaultConfigValues: (geoviewConfig: TypeMapFeaturesConfig) => void;

  actions: {
   ...
  };

  setterActions: {
    ...
  };
}

React Integration

React components subscribe to store slices:

function LegendPanel() {
  const layersList = useLayerLegendLayers();
  return (
    <div>
      {Object.values(layersList).map((entry) => (
        <LegendLayer key={entry.layerPath} entry={entry} />
      ))}
    </div>
  );
}

Query Coordination

Feature Info Query Flow

User Click
  ?
MapViewer.onMapSingleClick event
  ?
FeatureInfoLayerSet.queryLayers(location)
  ?
For each registered layer:
  ?
  Layer.queryAtCoordinate(location)
    ?
    OGC GetFeatureInfo / WFS GetFeature / Vector query
    ?
  Parse response ? TypeFeatureInfoEntry[]
  ?
Update resultSet[layerPath].featureInfo
  ?
Propagate to store
  ?
React components re-render with new features

Event Management

Layer Set Events

Layer Sets emit events through EventHelper static methods:

class AbstractLayerSet {
  /** Callback delegates for the layer set updated event. */
  #onLayerSetUpdatedHandlers: LayerSetUpdatedDelegate[] = [];

  /**
   * Registers a callback to be executed whenever the layer set is updated.
   */
  onLayerSetUpdated(callback: LayerSetUpdatedDelegate): void {
    EventHelper.onEvent(this.#onLayerSetUpdatedHandlers, callback);
  }

  /**
   * Unregisters a callback from being called whenever the layer set is updated.
   */
  offLayerSetUpdated(callback: LayerSetUpdatedDelegate): void {
    EventHelper.offEvent(this.#onLayerSetUpdatedHandlers, callback);
  }

  /**
   * Emits an event to all registered handlers.
   */
  #emitLayerSetUpdated(event: LayerSetUpdatedEvent): void {
    EventHelper.emitEvent(this, this.#onLayerSetUpdatedHandlers, event);
  }

  /**
   * Called to emit layer set updated event with layerPath and resultSet.
   */
  protected onLayerSetUpdatedProcess(layerPath: string): void {
    this.#emitLayerSetUpdated({ layerPath, resultSet: this.resultSet });
  }
}

// Event type definition
type LayerSetUpdatedEvent = {
  layerPath: string;
  resultSet: TypeResultSet;
};

Best Practices for Core Developers

1. Extending Layer Sets

When creating a new Layer Set:

export class MyCustomLayerSet extends AbstractLayerSet {
  // Registration condition
  protected onRegisterLayerConfigCheck(layerConfig: ConfigBaseClass): boolean {
    // Return true to register this layer type
    return layerConfig.geoviewLayerType === "myCustomType";
  }

  // Store propagation
  protected onPropagateToStore(
    resultSetEntry: TypeResultSetEntry,
    type: PropagationType,
  ): void {
    // Update store slice
    this.#setMyCustomLayerSetEntry(resultSetEntry.layerPath, resultSetEntry);
  }
}

2. Query Implementation

Implement queries with error handling and status updates:

async queryLayers(location?: TypeLocation): Promise<void> {
  for (const [layerPath, entry] of Object.entries(this.resultSet)) {
    try {
      // Set querying status
      this.updateResultSetEntry(layerPath, { queryStatus: 'init' });

      // Get layer
      const layer = this.layerApi.getGeoviewLayerIfExists(layerPath);
      if (!layer) continue;

      // Perform query
      const features = await layer.queryAtCoordinate(location);

      // Update result
      this.updateResultSetEntry(layerPath, {
        queryStatus: 'processed',
        featureInfo: {
          queryType: 'at_coordinate',
          features,
          queriedLocation: location
        }
      });
    } catch (error) {
      // Handle error
      this.updateResultSetEntry(layerPath, {
        queryStatus: 'error',
        error
      });
    }
  }
}

3. Performance Optimization

4. Type Safety

Use TypeScript strictly:

interface MyCustomResultSetEntry extends TypeResultSetEntry {
  myCustomData: MyCustomType;
}

export class MyCustomLayerSet extends AbstractLayerSet {
  // Override with specific type
  resultSet: { [layerPath: string]: MyCustomResultSetEntry } = {};
}

Debugging Layer Sets

Console Inspection

// In browser console
const mapViewer = cgpv.api.getMapViewer("mapId");
const legendsLayerSet = mapViewer.layer.legendsLayerSet;

// Inspect result set
logger.logDebug(legendsLayerSet.resultSet);

// Check specific layer
logger.logDebug(legendsLayerSet.resultSet["myLayer"]);

// Listen to updates
legendsLayerSet.onLayerSetUpdated((sender, payload) => {
  logger.logDebug("Updated:", payload);
});

See Also

For API Users:

For Core Developers: