Source: bemap-maplibre/bemap-maplibre-v5/bemap-map-maplibre-draw.js

/**
 * BeNomad BeMap JavaScript API - MapLibre v5 - Draw Interaction
 * Manual implementation using GeoJSON sources + mouse events.
 */

bemap.MapLibreMap.prototype.drawPolygon = function(options, callback) {
  return this._startDraw('Polygon', options, callback);
};

bemap.MapLibreMap.prototype.drawPolyline = function(options, callback) {
  return this._startDraw('Polyline', options, callback);
};

bemap.MapLibreMap.prototype.drawRectangle = function(options, callback) {
  return this._startDraw('Rectangle', options, callback);
};

bemap.MapLibreMap.prototype.drawCircle = function(options, callback) {
  return this._startDraw('Circle', options, callback);
};

bemap.MapLibreMap.prototype.drawMarker = function(options, callback) {
  var _this = this;
  this.cancelDraw();

  var clickHandler = function(e) {
    _this.native.off('click', clickHandler);
    var container = _this.native.getContainer();
    if (container) container.classList.remove('bemap-drawing-active');

    var coord = new bemap.Coordinate(e.lngLat.lng, e.lngLat.lat);
    var marker = new bemap.Marker(coord, { icon: (options || {}).icon });
    if (callback) {
      callback(new bemap.MapEvent({ map: _this, bemapObject: marker, coordinate: coord }));
    }
    if ((options || {}).addToMap !== false) {
      _this.addMarker(marker);
    }
  };

  this.native.on('click', clickHandler);
  this._drawState = { clickHandler: clickHandler };

  var container = this.native.getContainer();
  if (container) container.classList.add('bemap-drawing-active');

  return new bemap.Listener();
};

bemap.MapLibreMap.prototype._startDraw = function(type, options, callback) {
  var opts = options || {};
  var _this = this;
  this.cancelDraw();

  var coords = [];
  var sourceId = '_bemap-draw-src';
  var lineLayerId = '_bemap-draw-line';
  var fillLayerId = '_bemap-draw-fill';
  var vertexLayerId = '_bemap-draw-vertices';
  var rubberLineId = '_bemap-draw-rubber';

  var lineColor = 'rgba(0,102,204,1)';
  var fillColor = 'rgba(0,102,204,0.2)';
  var lineWidth = 2;

  if (opts.style) {
    if (opts.style.borderColor) lineColor = this._colorToRgba(opts.style.borderColor);
    if (opts.style.fillColor) fillColor = this._colorToRgba(opts.style.fillColor);
    if (opts.style.borderWidth) lineWidth = opts.style.borderWidth;
  }

  // Disable double-click zoom during drawing
  this.native.doubleClickZoom.disable();

  // Create GeoJSON source for preview
  var emptyGeoJson = { type: 'FeatureCollection', features: [] };
  this.native.addSource(sourceId, { type: 'geojson', data: emptyGeoJson });

  // Fill layer (for polygon)
  if (type === 'Polygon') {
    this.native.addLayer({
      id: fillLayerId, type: 'fill', source: sourceId,
      filter: ['==', '$type', 'Polygon'],
      paint: { 'fill-color': fillColor, 'fill-opacity': 1 }
    });
  }

  // Line layer
  this.native.addLayer({
    id: lineLayerId, type: 'line', source: sourceId,
    filter: ['==', '$type', type === 'Polygon' ? 'Polygon' : 'LineString'],
    paint: { 'line-color': lineColor, 'line-width': lineWidth },
    layout: { 'line-cap': 'round', 'line-join': 'round' }
  });

  // Vertex dots layer
  this.native.addSource(vertexLayerId + '-src', { type: 'geojson', data: emptyGeoJson });
  this.native.addLayer({
    id: vertexLayerId, type: 'circle', source: vertexLayerId + '-src',
    paint: { 'circle-radius': 5, 'circle-color': lineColor, 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 }
  });

  // Rubber-band line (cursor to last point)
  this.native.addSource(rubberLineId + '-src', { type: 'geojson', data: emptyGeoJson });
  this.native.addLayer({
    id: rubberLineId, type: 'line', source: rubberLineId + '-src',
    paint: { 'line-color': lineColor, 'line-width': 1, 'line-dasharray': [4, 4] }
  });

  var updatePreview = function() {
    var lngLats = coords.map(function(c) { return [c.getLon(), c.getLat()]; });

    // Vertex dots
    var vertexFeatures = lngLats.map(function(ll) {
      return { type: 'Feature', geometry: { type: 'Point', coordinates: ll } };
    });
    // The preview sources can be gone when this runs — a style reload (the server
    // charte loads async after login) drops all sources, and the click handler is
    // setTimeout-deferred so it may fire after cleanup. Guard before setData().
    var _vSrc = _this.native.getSource(vertexLayerId + '-src');
    if (!_vSrc) return;
    _vSrc.setData({ type: 'FeatureCollection', features: vertexFeatures });

    // Main shape
    var data;
    if (type === 'Rectangle' && lngLats.length >= 2) {
      // Show rectangle preview from 2 diagonal corners
      var c0 = lngLats[0], c1 = lngLats[lngLats.length - 1];
      var rectRing = [c0, [c1[0], c0[1]], c1, [c0[0], c1[1]], c0];
      data = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [rectRing] } };
    } else if (type === 'Polygon' && lngLats.length >= 3) {
      var ring = lngLats.concat([lngLats[0]]); // close the ring
      data = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } };
    } else if (lngLats.length >= 2) {
      data = { type: 'Feature', geometry: { type: 'LineString', coordinates: lngLats } };
    } else {
      data = emptyGeoJson;
    }
    var _mSrc = _this.native.getSource(sourceId);
    if (_mSrc) _mSrc.setData(data.type === 'FeatureCollection' ? data : { type: 'FeatureCollection', features: [data] });
  };

  // Mouse move → rubber-band line from last vertex to cursor
  var mouseMoveHandler = function(e) {
    if (coords.length === 0) return;
    var last = [coords[coords.length - 1].getLon(), coords[coords.length - 1].getLat()];
    var cursor = [e.lngLat.lng, e.lngLat.lat];
    var _rSrc = _this.native.getSource(rubberLineId + '-src');
    if (_rSrc) _rSrc.setData({
      type: 'Feature', geometry: { type: 'LineString', coordinates: [last, cursor] }
    });
  };

  // Default styles
  var _defaultPolyStyle = opts.style || new bemap.PolygonStyle({
    fillColor: new bemap.Color(0, 102, 204, 0.2),
    borderColor: new bemap.Color(0, 102, 204, 1),
    borderWidth: 2
  });
  var _defaultLineStyle = opts.style || new bemap.LineStyle({
    color: new bemap.Color(230, 57, 70, 1),
    width: 3
  });

  // Click → add vertex (debounced to avoid dblclick phantom vertex)
  var CLOSE_PX = 15;
  var clickTimer = null;

  var clickHandler = function(e) {
    var _e = e;
    clickTimer = setTimeout(function() {
      clickTimer = null;

      // Polygon: close if clicking near first point
      if (type === 'Polygon' && coords.length >= 3) {
        var firstPx = _this.native.project([coords[0].getLon(), coords[0].getLat()]);
        var clickPx = _this.native.project([_e.lngLat.lng, _e.lngLat.lat]);
        var dist = Math.sqrt(Math.pow(firstPx.x - clickPx.x, 2) + Math.pow(firstPx.y - clickPx.y, 2));
        if (dist < CLOSE_PX) {
          finishDraw(_e);
          return;
        }
      }

      coords.push(new bemap.Coordinate(_e.lngLat.lng, _e.lngLat.lat));
      updatePreview();

      // Rectangle: auto-finish on 2nd click
      if (type === 'Rectangle' && coords.length >= 2) {
        finishDraw(_e);
      }
      // Circle: auto-finish on 2nd click (center + edge)
      if (type === 'Circle' && coords.length >= 2) {
        finishDraw(_e);
      }
    }, 220);
  };

  // Double-click → finish
  var dblclickHandler = function(e) {
    if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
    if (e && e.preventDefault) e.preventDefault();
    finishDraw(e);
  };

  // Finish drawing and create the shape
  var finishDraw = function(e) {
    cleanup();

    var bemapObj = null;
    if (type === 'Rectangle' && coords.length >= 2) {
      var c0 = coords[0], c1 = coords[coords.length - 1];
      var rectCoords = [
        c0,
        new bemap.Coordinate(c1.getLon(), c0.getLat()),
        c1,
        new bemap.Coordinate(c0.getLon(), c1.getLat())
      ];
      bemapObj = new bemap.Polygon(rectCoords, { style: _defaultPolyStyle });
    } else if (type === 'Circle' && coords.length >= 2) {
      var center = coords[0];
      var edge = coords[1];
      var R = 6371000;
      var dLat = (edge.getLat() - center.getLat()) * Math.PI / 180;
      var dLon = (edge.getLon() - center.getLon()) * Math.PI / 180;
      var a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(center.getLat() * Math.PI / 180) * Math.cos(edge.getLat() * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2);
      var radius = R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
      var _circleStyle = opts.style || new bemap.CircleStyle({
        fillColor: new bemap.Color(0, 102, 204, 0.2),
        borderColor: new bemap.Color(0, 102, 204, 1),
        borderWidth: 2
      });
      bemapObj = new bemap.Circle(center, radius, { style: _circleStyle });
    } else if (type === 'Polygon' && coords.length >= 3) {
      bemapObj = new bemap.Polygon(coords, { style: _defaultPolyStyle });
    } else if (type === 'Polyline' && coords.length >= 2) {
      bemapObj = new bemap.Polyline(coords, { style: _defaultLineStyle });
    }

    if (bemapObj) {
      if (callback) callback(new bemap.MapEvent({ map: _this, bemapObject: bemapObj, coordinate: coords[0] }));

      // Auto-add to map
      if (opts.addToMap !== false) {
        if (bemap.inheritsof(bemapObj, bemap.Polygon)) _this.addPolygon(bemapObj);
        else if (bemap.inheritsof(bemapObj, bemap.Polyline)) _this.addPolyline(bemapObj);
        else if (bemap.inheritsof(bemapObj, bemap.Circle)) _this.addCircle(bemapObj);
        else if (bemap.inheritsof(bemapObj, bemap.Marker)) _this.addMarker(bemapObj);
      }

      // Auto-enable vertex editing for polygons
      if (opts.editable !== false && bemap.inheritsof(bemapObj, bemap.Polygon)) {
        _this.editPolygon(bemapObj, callback);
      }
    }
  };

  var cleanup = function() {
    if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
    _this.native.off('click', clickHandler);
    _this.native.off('dblclick', dblclickHandler);
    _this.native.off('mousemove', mouseMoveHandler);

    try { if (_this.native.getLayer(fillLayerId)) _this.native.removeLayer(fillLayerId); } catch(e) {}
    try { if (_this.native.getLayer(lineLayerId)) _this.native.removeLayer(lineLayerId); } catch(e) {}
    try { if (_this.native.getLayer(vertexLayerId)) _this.native.removeLayer(vertexLayerId); } catch(e) {}
    try { if (_this.native.getLayer(rubberLineId)) _this.native.removeLayer(rubberLineId); } catch(e) {}
    try { if (_this.native.getSource(sourceId)) _this.native.removeSource(sourceId); } catch(e) {}
    try { if (_this.native.getSource(vertexLayerId + '-src')) _this.native.removeSource(vertexLayerId + '-src'); } catch(e) {}
    try { if (_this.native.getSource(rubberLineId + '-src')) _this.native.removeSource(rubberLineId + '-src'); } catch(e) {}

    _this.native.doubleClickZoom.enable();
    var container = _this.native.getContainer();
    if (container) container.classList.remove('bemap-drawing-active');
    _this._drawState = null;
  };

  this.native.on('click', clickHandler);
  this.native.on('dblclick', dblclickHandler);
  this.native.on('mousemove', mouseMoveHandler);

  this._drawState = { cleanup: cleanup, coords: coords };

  var container = this.native.getContainer();
  if (container) container.classList.add('bemap-drawing-active');

  return new bemap.Listener();
};

bemap.MapLibreMap.prototype.cancelDraw = function() {
  if (this._drawState) {
    if (this._drawState.cleanup) this._drawState.cleanup();
    else {
      if (this._drawState.clickHandler) this.native.off('click', this._drawState.clickHandler);
      var container = this.native.getContainer();
      if (container) container.classList.remove('bemap-drawing-active');
    }
    this._drawState = null;
  }
  return this;
};

bemap.MapLibreMap.prototype.editPolygon = function(polygon, callback) {
  if (!polygon || !polygon.native) return new bemap.Listener();

  var _this = this;
  var MERGE_PX = 15;

  // Store edit state on polygon for rebuild
  polygon._editCallback = callback;
  polygon._editMap = this;

  this._rebuildVertexHandles(polygon);

  // Click on polygon layer to insert vertex on nearest edge
  var polyLayerId = polygon._maplibreFillLayerId || polygon._maplibreLayerId;
  if (polyLayerId && this.native.getLayer(polyLayerId)) {
    var insertHandler = function(e) {
      _this._insertVertexOnEdge(polygon, e.lngLat);
    };
    this.native.on('click', polyLayerId, insertHandler);
    polygon._insertHandler = insertHandler;
    polygon._insertLayerId = polyLayerId;
  }

  return new bemap.Listener({ native: null, bemapObject: polygon, key: 'editPolygon' });
};

/**
 * Rebuild all vertex handle markers for a polygon being edited.
 */
bemap.MapLibreMap.prototype._rebuildVertexHandles = function(polygon) {
  var _this = this;
  var coords = polygon.getCoordinates();
  var MERGE_PX = 15;
  var callback = polygon._editCallback;

  // Remove old handles
  if (polygon._vertexMarkers) {
    for (var r = 0; r < polygon._vertexMarkers.length; r++) {
      polygon._vertexMarkers[r].remove();
    }
  }
  polygon._vertexMarkers = [];

  for (var i = 0; i < coords.length; i++) {
    (function(index) {
      var el = document.createElement('div');
      el.className = 'bemap-vertex-handle';

      var vm = new maplibregl.Marker({ element: el, draggable: true })
        .setLngLat([coords[index].getLon(), coords[index].getLat()])
        .addTo(_this.native);

      // Real-time drag — update polygon during drag
      vm.on('drag', function() {
        var ll = vm.getLngLat();
        coords[index] = new bemap.Coordinate(ll.lng, ll.lat);
        polygon.coords = coords;
        _this._updatePolygonSource(polygon, coords);
      });

      // Dragend — merge check + callback
      vm.on('dragend', function() {
        var ll = vm.getLngLat();
        var dropPoint = _this.native.project([ll.lng, ll.lat]);

        // Merge: check distance to all other vertices
        if (coords.length > 3) {
          for (var j = 0; j < coords.length; j++) {
            if (j === index) continue;
            var otherPoint = _this.native.project([coords[j].getLon(), coords[j].getLat()]);
            var dist = Math.sqrt(Math.pow(dropPoint.x - otherPoint.x, 2) + Math.pow(dropPoint.y - otherPoint.y, 2));
            if (dist < MERGE_PX) {
              // Merge: remove dragged vertex
              coords.splice(index, 1);
              polygon.coords = coords;
              _this._updatePolygonSource(polygon, coords);
              _this._rebuildVertexHandles(polygon);
              if (callback) callback(new bemap.MapEvent({ map: _this, bemapObject: polygon }));
              return;
            }
          }
        }

        polygon.coords = coords;
        if (callback) callback(new bemap.MapEvent({ map: _this, bemapObject: polygon }));
      });

      // Right-click — delete vertex
      el.addEventListener('contextmenu', function(domEvt) {
        domEvt.preventDefault();
        domEvt.stopPropagation();
        if (coords.length <= 3) return; // min 3 vertices
        coords.splice(index, 1);
        polygon.coords = coords;
        _this._updatePolygonSource(polygon, coords);
        _this._rebuildVertexHandles(polygon);
        if (callback) callback(new bemap.MapEvent({ map: _this, bemapObject: polygon }));
      });

      polygon._vertexMarkers.push(vm);
    })(i);
  }
};

/**
 * Update polygon GeoJSON source from coordinates array.
 */
bemap.MapLibreMap.prototype._updatePolygonSource = function(polygon, coords) {
  var lngLats = coords.map(function(c) { return [c.getLon(), c.getLat()]; });
  var ring = lngLats.concat([lngLats[0]]);
  var sourceId = polygon._maplibreSourceId;
  if (sourceId && this.native.getSource(sourceId)) {
    this.native.getSource(sourceId).setData({
      type: 'Feature',
      geometry: { type: 'Polygon', coordinates: [ring] },
      properties: polygon.properties || {}
    });
  }
};

/**
 * Insert a vertex on the nearest polygon edge to the click point.
 */
bemap.MapLibreMap.prototype._insertVertexOnEdge = function(polygon, lngLat) {
  var coords = polygon.getCoordinates();
  var clickPx = this.native.project([lngLat.lng, lngLat.lat]);
  var bestIdx = 0, bestDist = Infinity;

  for (var i = 0; i < coords.length; i++) {
    var a = this.native.project([coords[i].getLon(), coords[i].getLat()]);
    var b = this.native.project([coords[(i + 1) % coords.length].getLon(), coords[(i + 1) % coords.length].getLat()]);
    var d = this._distToSegmentPx(clickPx, a, b);
    if (d < bestDist) { bestDist = d; bestIdx = i; }
  }

  // Only insert if click is reasonably close to an edge (within 20px)
  if (bestDist > 20) return;

  var newCoord = new bemap.Coordinate(lngLat.lng, lngLat.lat);
  coords.splice(bestIdx + 1, 0, newCoord);
  polygon.coords = coords;
  this._updatePolygonSource(polygon, coords);
  this._rebuildVertexHandles(polygon);
  if (polygon._editCallback) polygon._editCallback(new bemap.MapEvent({ map: this, bemapObject: polygon }));
};

/**
 * Distance from point P to segment AB (in pixels).
 */
bemap.MapLibreMap.prototype._distToSegmentPx = function(p, a, b) {
  var dx = b.x - a.x, dy = b.y - a.y;
  if (dx === 0 && dy === 0) return Math.sqrt(Math.pow(p.x - a.x, 2) + Math.pow(p.y - a.y, 2));
  var t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy)));
  var projX = a.x + t * dx, projY = a.y + t * dy;
  return Math.sqrt(Math.pow(p.x - projX, 2) + Math.pow(p.y - projY, 2));
};