mapview_interactions_modify.mjs

/**
## /mapview/interactions/modify

The module exports the modify interaction method which is bound to a mapview in the [decorator]{@link module:/mapview~decorate} method.

@requires /dictionary
@module /mapview/interactions/modify
*/

/**
@function modify
Defines the behaviour for a modification interaction performed on the map.

@param {Object} params Extra parameters for the mapview.interaction.
@param {mapview} mapview The mapview object to which the interaction will be added.
*/
export default function modify(params, mapview = this) {
  // Finish the current interaction.
  mapview.interaction?.finish();

  mapview.interaction = {
    deleteCondition: (e) => deleteCondition(e, mapview),

    finish: finish.bind(mapview),

    format: new ol.format.GeoJSON(),

    getFeature: getFeature.bind(mapview),

    Layer: new ol.layer.Vector({
      zIndex: Infinity,
    }),

    modifyend: mapp.ui?.elements.contextMenu.modify.bind(mapview),

    Style: [
      new ol.style.Style({
        image: new ol.style.Circle({
          radius: 5,
          stroke: new ol.style.Stroke({
            color: '#3399CC',
            width: 1.25,
          }),
        }),
        stroke: new ol.style.Stroke({
          color: '#3399CC',
          width: 1.25,
        }),
      }),
      new ol.style.Style({
        geometry: mapp.utils.verticeGeoms,
        image: new ol.style.Circle({
          fill: new ol.style.Fill({
            color: '#eee',
          }),
          radius: 5,
          stroke: new ol.style.Stroke({
            color: '#3399CC',
            width: 1.25,
          }),
        }),
      }),
    ],

    source: new ol.source.Vector(),

    type: 'modify',

    vertices: [],

    // Spread params argument.
    ...params,
  };

  mapview.Map.getTargetElement().style.cursor = 'crosshair';

  mapview.interaction.source.addFeature(mapview.interaction.Feature);

  // Set mapview.interaction.Layer source.
  mapview.interaction.Layer.setSource(mapview.interaction.source);

  // Set mapview.interaction.Layer style
  mapview.interaction.Layer.setStyle(mapview.interaction.Style);

  // Add mapview.interaction.Layer to mapview.
  mapview.Map.addLayer(mapview.interaction.Layer);

  /**
  @function escape
  @description 
  Ends the modification interaction when the Escape key is pressed.
  @param {Object} e The event object.
  */
  function escape(e) {
    e.key === 'Escape' && mapview.interaction.finish();
  }

  //End the modification on escape key.
  document.addEventListener('keyup', escape);

  mapview.interaction.interaction = new ol.interaction.Modify(
    mapview.interaction,
  );

  // Will clear remove vertex popup.
  mapview.interaction.interaction.on('modifystart', (e) => {
    mapview.popup(null);
  });

  if (typeof mapview.interaction.modifyend === 'function') {
    mapview.interaction.interaction.on(
      'modifyend',
      mapview.interaction.modifyend,
    );
  }

  // Add OL interaction to mapview.Map
  mapview.Map.addInteraction(mapview.interaction.interaction);

  // Assign snap interaction.
  mapview.interactions.snap(mapview);
}

/**
@function finish
@description
Ends the modification interaction, cleans up event listeners, and removes the interaction from the map.
@param {Object} feature The modified feature to be passed to the callback function.
*/
function finish(feature) {
  //Remove the escape key event listener
  document.removeEventListener('keyup', escape);

  // Remove snap interaction.
  this.interaction.snap?.remove?.();

  // Reset the cursor style.
  this.Map.getTargetElement().style.cursor = 'default';

  // Remove popup from mapview.
  this.popup(null);

  // Remove interaction from mapview.Map.
  this.Map.removeInteraction(this.interaction.interaction);

  // Clear the modify source.
  this.interaction.source.clear();

  // Remove draw Layer from mapview.Map.
  this.Map.removeLayer(this.interaction.Layer);

  this.interaction.callback?.(feature);
}

/**
@function getFeature
@description Returns the modified feature as a GeoJSON object.
@returns {Object} The modified feature in GeoJSON format.
*/
function getFeature() {
  return JSON.parse(
    this.interaction.format.writeFeature(this.interaction.Feature, {
      dataProjection: 'EPSG:' + this.interaction.srid || this.srid,
      featureProjection: 'EPSG:' + this.srid,
    }),
  );
}

/**
@function deleteCondition
@description Handles single-click events by displaying the vertex deletion action for eligible geometries.
@param {object} e The interaction event object.
@param {mapview} mapview
*/
function deleteCondition(e, mapview) {
  if (e.type !== 'singleclick') return;

  const params = {};

  params.geom = mapview.interaction.Feature.getGeometry();

  params.geomType = params.geom.getType();

  if (params.geomType === 'Point') return;

  params.coords = params.geom.getCoordinates();

  // Initialize empty list for popup content
  params.content = [];

  // Only allow to remove vertex on linestring with more than 2.
  if (params.geomType === 'LineString' && params.coords.length > 2) {
    params.content.push(mapp.utils.html`<li
      onclick=${() => removeVertex(e, mapview)}>
      ${mapp.dictionary.delete_vertex}`);
  }

  // Only allow to delete vertex on polygon with more than 3.
  if (params.geomType === 'Polygon' && params.coords[0].length > 3) {
    params.content.push(mapp.utils.html`<li
      onclick=${() => removeVertex(e, mapview)}>
      ${mapp.dictionary.delete_vertex}`);
  }

  multipolygon(e, mapview, params);

  // Set popup
  mapview.popup({
    content: mapp.utils.html.node`<ul>${params.content}`,
    coords: params.geom.getClosestPoint(e.coordinate),
  });
}

function removeVertex(e, mapview) {
  mapview.interaction.interaction.removePoint();
  mapview.interaction.vertices.push(
    mapview.interaction.Feature.getGeometry().getClosestPoint(e.coordinate),
  );
  mapview.popup(null);

  // Display save/cancel popup.
  mapview.interaction.interaction.dispatchEvent({
    type: 'modifyend',
    features: new ol.Collection([mapview.interaction.Feature]),
  });
}

/**
@function multipolygon
@description Adds the vertex deletion action when the selected polygon ring has enough vertices.

@param {Object} e The event object.
@param {Object} mapview The mapview object.
@param {Object} params The parameters object containing geometry information.
*/
function multipolygon(e, mapview, params) {
  if (params.geomType !== 'MultiPolygon') return;

  params.clickedPolyIndex = -1;
  params.minDist = Infinity;
  params.polygons = params.geom.getPolygons();

  params.polygons.forEach((poly, idx) => {
    const closest = poly.getClosestPoint(e.coordinate);
    const dx = closest[0] - e.coordinate[0];
    const dy = closest[1] - e.coordinate[1];
    const dist = dx * dx + dy * dy;

    if (dist < params.minDist) {
      params.minDist = dist;
      params.clickedPolyIndex = idx;
      params.ringLength = poly.getCoordinates()[0].length;
    }
  });

  if (params.ringLength > 4) {
    params.content.push(mapp.utils.html`<li
    onclick=${() => removeVertex(e, mapview)}>
    ${mapp.dictionary.delete_vertex}`);
  }

  // Add the option to delete the polygon part to the popup content
  params.content.push(mapp.utils.html`<li
    onclick=${onclick}>
    ${mapp.dictionary.delete_polygon_part}`);

  function onclick() {
    mapview.popup(null);

    const polygons = params.geom.getPolygons();
    const remaining = polygons.filter(
      (_, idx) => idx !== params.clickedPolyIndex,
    );

    if (remaining.length === 0) {
      // If they deleted the last part, end the interaction and nullify
      mapview.interaction.finish();
      mapview.interaction.callback?.(null);
    } else {
      // Apply the filtered coordinates back to the geometry
      params.geom.setCoordinates(remaining.map((p) => p.getCoordinates()));

      // Refresh the visual display
      mapview.interaction.source.clear();
      mapview.interaction.source.addFeature(mapview.interaction.Feature);

      mapview.interaction.interaction.dispatchEvent({
        type: 'modifyend',
        features: new ol.Collection([mapview.interaction.Feature]),
      });
    }
  }
}