layer_format_vector.mjs

/**
## /layer/formats/vector

The module exports the "vector" format method for the Mapp layer decorator method.

@requires /layer/featureFormats
@requires /layer/featureStyle
@requires /layer/styleParser

@module /layer/formats/vector
*/

/**
@global
@typedef {Object} layer-cluster
Point layer cluster configuration.
@property {number} [distance] The distance threshold [pixel] for OL source clustering.
@property {string} [label] The field used for labeling the clusters.
@property {number} [resolution] The resolution parameter for PostGIS database cluster queries.
@property {boolean} [hexgrid] Use hexgrid clustering query for PostGIS.
*/

/**
@function vector

@description
Creates Openlayers layer and source for vector format mapp layer.

@param {layer} layer The Mapp layer object.
@property {string} [layer.srid] The spatial reference system identifier (SRID) for the layer.
@property {Object} [layer.params={}] Parameters for the layer data query.
@property {function} layer.setSource Method pass features to layer source.
@property {function} layer.reload Method to reload layer data.
@property {Array} [layer.features] An array of features for the layer.
@property {boolean} [layer.fade] - Flag indicating whether to apply a fade effect to the layer.
@property {layer-cluster} [layer.cluster] Point layer cluster configuration.
@property {Object} [layer.source] OpenLayers vector source.
@property {layer-style} layer.style The mapp-layer style configuration.
@property {number} [layer.zIndex] The zIndex for the layer canvas element.
*/
export default function vector(layer) {
  if (!layer.srid) {
    console.warn(`Vector Layer: ${layer.key} missing SRID.`);
    return;
  }

  if (!layer.qID) {
    console.warn(`Vector Layer: ${layer.key} missing qID property.`);
    return;
  }

  // Update feature style config.
  mapp.layer.styleParser(layer);

  if (layer.properties) {
    console.warn(
      `Layer: ${layer.key}, layer.properties{} are no longer required for wkt & geojson datasets.`,
    );
  }

  // Set default layer params if nullish.
  layer.params ??= {};
  layer.params.fields ??= [];

  layer.featureFormat ??= layer.format;

  clusterConfig(layer);

  layer.reload = reload;
  layer.setSource = setSource;

  // Create Openlayer Vector Layer
  layer.L = new ol.layer[(layer.vectorImage && 'VectorImage') || 'Vector']({
    className: `mapp-layer-${layer.key}`,
    key: layer.key,
    style: layer.styleFunction || mapp.layer.featureStyle(layer),
    zIndex: layer.zIndex,
  });

  layer.setSource(layer.features);
}

/**
@function setSource
@this layer
@async 
@description
The setSource method receives the features to be set on a vector layer source. Providing a null argument will remove any features by setting the layer source to null.

The method will trigger methods to reset and process featureFields to update dynamic legends.

The clusterSourceChange method is triggered for cluster layer.

@param {array} [features] The features to be added to the vector layer source.
*/
async function setSource(features) {
  // The layer datasource is empty.
  if (features === null) {
    this.Features = [];
    this.L.setSource(null);
    mapp.layer.featureFields.reset(this);
    mapp.layer.featureFields.process(this);
    return;
  }

  if (!features) return;

  this.Features = await mapp.layer.featureFormats[this.featureFormat](
    this,
    [features].flat(),
  );

  this.source = createSource(this, this.Features);

  if (this.fade) mapp.layer.fade(this);

  // Geojson is a cluster layer.
  if (this.cluster?.distance) {
    // Create cluster source.

    this.cluster.source = new ol.source.Cluster({
      distance: this.cluster.distance,
      geometryFunction: (F) => clusterGeometry(F, this),
      source: this.source,
    });

    this.L.setSource(this.cluster.source);

    this.cluster.source.on('change', (e) => clusterSourceChange(e, this));

    return;
  }

  // Set source if not cluster
  this.L.setSource(this.source);
}

/**
@function clusterGeometry

@description
The clusterGeometry method is the geometryFunction for the OL cluster source. Features which are not displayed by the featureStyle method return a null geometry and will not be considered for clustering. These are features excluded by the layer featureSet or featureLookup, and features without a style from a categorized or graduated layer theme [eg. a category toggled off in a filterOnly theme].

A cluster without displayed features will not be created and a cluster with a single displayed feature will be rendered as a single location.

@param {Object} F The OL feature in the cluster source.
@param {layer} layer The Mapp layer object.
@property {layer-style} layer.style The layer style config.
@property {Object} [style.theme] The layer theme.
@returns {Object} The feature geometry or null if the feature is not displayed.
*/
function clusterGeometry(F, layer) {
  // Assign properties as the featureStyle method. The OL feature properties must not be mutated.
  const properties = mapp.layer.featureProperties(layer, {
    ...F.getProperties(),
  });

  // The feature is not displayed by the featureStyle method.
  if (properties === null) return null;

  const theme = layer.style?.theme;

  // Only categorized and graduated themes can remove the style of a feature without side effects.
  if (['categorized', 'graduated'].includes(theme?.type)) {
    const feature = {
      properties,
      style: {},
    };

    mapp.layer.themes[theme.type](theme, feature);

    // The theme removed the feature style.
    if (feature.style === null) return null;
  }

  return F.getGeometry();
}

/**
@function clusterSourceChange

@description
The clusterSourceChange event method is called by changes to the cluster source. The method will map all features in cluster source to return an array of features [counts] for each feature in the cluster source. The largest features count from the array will be assigned as the cluster.max_size.

@param {layer} layer The Mapp layer object.
@property {layer-cluster} layer.cluster The vector layer cluster configuration.
@property {integer} cluster.max_size The length of features in the largest cluster feature.
*/
function clusterSourceChange(e, layer) {
  delete layer.cluster.max_size;

  const features = layer.cluster.source.getFeatures();

  if (!features.length) return;

  const feature_counts = features.map((F) => {
    const features = F.get('features');

    // This error indicates and issue with the provided feature geometries in the layer vector source.
    if (!features.length) {
      console.error(`Empty cluster feature detected.`);
      return;
    }

    // Set cluster feature id for highlight interaction.
    F.set('id', features[0].get('id'), true);

    if (features.length > 1) {
      // Set cluster count.
      F.set('count', features.length, true);
    } else {
      const featureProperties = features[0].getProperties();

      // Set feature properties for for single location cluster.
      Object.entries(featureProperties).forEach((entry) => {
        F.set(entry[0], entry[1], true);
      });
    }

    // Return length for calculation of max_size.
    return features.length;
  });

  if (!feature_counts.length) return;

  // Calculate max_size for cluster size styling.
  layer.cluster.max_size = Math.max(...feature_counts);
}

/**
@function reload

@description
The layer.reload() method determines the table and geometry for the current zoom level. And uses the layer.params{} to create a request for layer features to be added to the layer source.

The xhr request is debounced by 100ms to prevent requests in quick succession on panning, scroll, and touch zooming.

The layer source is loading until the features of the latest request have been set. The Openlayers Map doesn't dispatch the rendercomplete event while the source of a visible layer is loading.

The layer.request is assigned when the reload method is called, before the debounce timeout. The response of a superseded request is discarded and will neither set the layer source nor end the loading of the layer, also if the response arrives during the debounce timeout of the latest reload.

The reload method is bound to the layer object and doesn't require any arguments.

@property {Object} layer.params Parameter for data request.
@property {Object} layer.timeout Timeout for the xhr request.
@property {Object} layer.request The latest request for the layer data.
@property {Promise} [layer.loading] Resolves once the features of the latest request have been set.
*/
function reload() {
  // Do not reload the layer if features have been assigned.
  if (this.features) {
    this.L.changed();
    return;
  }

  const table = this.tableCurrent();

  if (!table) return;

  const geom = this.geomCurrent();

  if (this.fade) mapp.layer.fade(this, true);

  // Set request params.fields for styling.
  this.params.fields = mapp.layer.featureFields.fieldsArray(this);

  const bounds = this.mapview.getBounds(this.srid);

  // Assign current viewport if queryparam is truthy.
  this.params.viewport &&= [
    bounds.west,
    bounds.south,
    bounds.east,
    bounds.north,
    this.srid,
  ];

  // Assign current viewport if queryparam is truthy.
  this.params.z &&= this.mapview.Map.getView().getZoom();

  // The layer must be loading before the debounce timeout to prevent a rendercomplete event prior to the request.
  startLoading(this);

  // The request is assigned before the debounce timeout. A response of an earlier request arriving during the timeout is superseded and must neither set the layer source nor end the loading.
  const request = {};
  this.request = request;

  clearTimeout(this.timeout);

  this.timeout = setTimeout(() => {
    const paramString = mapp.utils.paramString({
      filter: this.filter?.current,
      geom,
      layer: this.key,
      locale: this.mapview.locale.key,
      srid: this.srid,
      table,
      template: this.params.template || this.format,
      ...this.params,
    });

    this.xhr = mapp.utils
      .xhr(`${this.mapview.host}/api/query?${paramString}`)
      .then(async (features) => {
        if (this.request !== request) return;

        // An error will be returned if the xhr fails with a bad request 400.
        if (features instanceof Error) return;

        await this.setSource(features);
      })
      .finally(() => {
        // The loading of a superseded request is ended by the latest request.
        if (this.request === request) endLoading(this);
      });
  }, 100);
}

/**
The loaded extent of a vector source with the default 'all' loading strategy.
*/
const ALL_EXTENT = [-Infinity, -Infinity, Infinity, Infinity];

/**
@function createSource

@description
The createSource method creates the Openlayers vector source for a vector layer.

The vector layer data is requested by the layer.reload() method. The source loader doesn't load any features but completes once the layer has finished loading. The Openlayers vector source is loading while the loader is pending, and the Openlayers Map will not dispatch the rendercomplete event while the source of a visible layer is loading.

@param {layer} layer The vector layer.
@param {Array} [features] The Openlayers features for the source.
@returns {Object} Openlayers vector source.
*/
function createSource(layer, features) {
  return new ol.source.Vector({
    features,
    // The loader must declare the success argument for the source to be loading while the loader is pending.
    loader: (extent, resolution, projection, success) => {
      if (layer.loading) {
        layer.loading.then(() => success([]));
      } else {
        success([]);
      }
    },
    strategy: ol.loadingstrategy.all,
  });
}

/**
@function startLoading

@description
The startLoading method assigns the layer.loading promise which is resolved by the endLoading method once the features of the latest request have been set.

A layer without a source is assigned an empty source to be loading until the features are set.

The loaded extent of the layer source is removed for the source loader to be called on the next render.

The Openlayers cluster source doesn't call a loader. The loading flag is set on a cluster source directly.

@param {layer} layer The vector layer.
*/
function startLoading(layer) {
  if (!layer.loading) {
    let resolve;
    layer.loading = new Promise((r) => {
      resolve = r;
    });
    layer.loading.resolve = resolve;
  }

  if (!layer.L.getSource()) {
    layer.L.setSource(createSource(layer));
  }

  const source = layer.L.getSource();

  if (source instanceof ol.source.Cluster) {
    source.loading = true;
  } else {
    source.removeLoadedExtent(ALL_EXTENT);
  }

  layer.L.changed();
}

/**
@function endLoading

@description
The endLoading method resolves the layer.loading promise for pending source loaders to complete.

The layer is changed to trigger the render which dispatches the rendercomplete event.

@param {layer} layer The vector layer.
*/
function endLoading(layer) {
  layer.loading?.resolve();

  delete layer.loading;

  const source = layer.L.getSource();

  if (source instanceof ol.source.Cluster) {
    source.loading = false;
  }

  layer.L.changed();
}

/**
@function clusterConfig

@description
Configures the clustering options for a layer.

@param {Object} layer The layer object.
@property {string} layer.key The key of the layer.
@property {string} layer.srid The spatial reference system identifier (SRID) for the layer.
@property {Object} layer.params Parameter for data request.
@property {Object} layer.cluster The clustering configuration for the layer.
@property {number} [cluster.distance] The distance threshold for clustering.
@property {number} [cluster.resolution] The resolution threshold for clustering.
@property {boolean} [cluster.hexgrid] Flag indicating whether to use hexgrid clustering.
*/
function clusterConfig(layer) {
  // The clusterConfig can not work without the layer having a cluster config object.
  if (typeof layer.cluster !== 'object') return;

  // Check if both cluster.distance and cluster.resolution are set.
  if (layer.cluster.distance && layer.cluster.resolution) {
    console.warn(
      `Layer: ${layer.key}, cluster.distance and cluster.resolution are mutually exclusive. You cannot use them both on the same layer. Please remove one of them. `,
    );
    return;
  }

  // Check if neither cluster.distance and cluster.resolution are set.
  if (!layer.cluster.distance && !layer.cluster.resolution) {
    console.warn(
      `Layer: ${layer.key}, cluster.distance or cluster.resolution must be set.`,
    );
    return;
  }

  // Check if distance is defined as <1 and layer is a format wkt
  if (layer.cluster.distance < 1) {
    console.warn(
      `Layer: ${layer.key}, cluster.distance is less than 1 [pixel]. The cluster config will be removed.`,
    );

    // Remove the cluster object
    delete layer.cluster;
    return;
  }

  // If cluster.resolution is used, the layer srid must be set to 3857.
  if (layer.cluster.resolution) {
    // Check if resolution is numeric.
    if (typeof layer.cluster.resolution === 'number') {
      // Assign resolution as float.
      layer.params.resolution = Number.parseFloat(layer.cluster.resolution);
    }
    // Otherwise, warn and return.
    else {
      console.warn(`Layer: ${layer.key}, cluster.resolution must be a number.`);
      return;
    }

    // Check if srid is set to 4326, not allowed for cluster layer
    if (layer.srid === '4326') {
      console.warn(
        `Layer: ${layer.key}, srid 4326 is not allowed for cluster.resolution layers.`,
      );
      return;
    }

    // Provide default params for resolution cluster.
    layer.params.viewport = true;
    layer.params.z = true;

    // Format is cluster if resolution is set.
    layer.format = 'cluster';

    // Assign default template.
    layer.params.template ??= layer.cluster.hexgrid ? 'cluster_hex' : 'cluster';
  }
}