2039 lines
76 KiB
JavaScript
2039 lines
76 KiB
JavaScript
/**
|
|
* Custom Drawing Module for Maplibre GL
|
|
*
|
|
* A replacement for MapboxDraw that provides:
|
|
* - Point, Line, and Polygon drawing
|
|
* - Vertex snapping functionality
|
|
* - Single 'edit' mode for vertex editing and feature dragging
|
|
* - Compatible with Maplibre GL
|
|
* - Uses only turf.js for geometry operations
|
|
*
|
|
* @version 2.0.0
|
|
* @requires maplibre-gl
|
|
* @requires turf.js
|
|
*/
|
|
|
|
(function(window) {
|
|
'use strict';
|
|
|
|
// Constants
|
|
const SNAP_DISTANCE_PX = 15;
|
|
const SNAP_VERTEX_PRIORITY_DISTANCE = 0.0005; // ~50m in degrees
|
|
const VERTEX_CLICK_TOLERANCE_PX = 20; // Increased for touch support
|
|
const DRAW_LAYER_PREFIX = 'custom-draw-';
|
|
|
|
/**
|
|
* Main CustomDraw class
|
|
*/
|
|
class CustomDraw {
|
|
constructor(options = {}) {
|
|
this.options = {
|
|
displayControlsDefault: options.displayControlsDefault !== false,
|
|
controls: options.controls || {},
|
|
defaultMode: options.defaultMode || 'edit',
|
|
clickTolerance: options.clickTolerance || 2,
|
|
touchBuffer: options.touchBuffer || 0,
|
|
snap: options.snap !== false,
|
|
snapOptions: options.snapOptions || {},
|
|
styles: options.styles || this._getDefaultStyles(),
|
|
userProperties: options.userProperties !== false
|
|
};
|
|
|
|
this.map = null;
|
|
this.mode = this.options.defaultMode;
|
|
this.features = new Map(); // Store all features by ID
|
|
this.selectedFeatureIds = new Set();
|
|
this.currentFeature = null; // Feature being drawn
|
|
this.handlers = {}; // Event handlers
|
|
this.eventListeners = {}; // User event listeners
|
|
|
|
// Snapping configuration
|
|
this.snapOptions = {
|
|
snapPx: this.options.snapOptions.snapPx || SNAP_DISTANCE_PX,
|
|
snapToMidPoints: this.options.snapOptions.snapToMidPoints !== false,
|
|
snapToSegments: this.options.snapOptions.snapToSegments !== false,
|
|
snapVertexPriorityDistance: this.options.snapOptions.snapVertexPriorityDistance || SNAP_VERTEX_PRIORITY_DISTANCE,
|
|
snapIncludeMapFeatures: this.options.snapOptions.snapIncludeMapFeatures !== false,
|
|
snapGetFeatures: this.options.snapOptions.snapGetFeatures || null
|
|
};
|
|
|
|
// State for edit mode
|
|
this.draggedVertex = null;
|
|
this.draggedFeature = null; // For dragging entire features
|
|
this.dragStartPoint = null; // Starting point for feature drag
|
|
this.hoveredVertex = null;
|
|
this.isDragging = false;
|
|
this.isDraggingFeature = false; // True when dragging whole feature
|
|
this._lastDragEndTime = 0; // Timestamp when drag ended (to prevent click after drag)
|
|
|
|
// Snapping cache
|
|
this._snapFeaturesCache = null;
|
|
this._snapFeaturesCacheTime = 0;
|
|
this._snapFeaturesCacheTTL = 100; // Cache for 100ms
|
|
|
|
// Mode-specific snap setting (can override global snap)
|
|
this._modeSnap = this.options.snap;
|
|
|
|
// Double-click detection (fallback since dblclick event is unreliable)
|
|
this._lastClickTime = 0;
|
|
this._lastClickPoint = null;
|
|
this._doubleClickThreshold = 300; // ms
|
|
this._doubleClickDistanceThreshold = 10; // pixels
|
|
|
|
this._setupEventHandlers();
|
|
}
|
|
|
|
/**
|
|
* Add the draw control to a map
|
|
*/
|
|
onAdd(map) {
|
|
this.map = map;
|
|
this._addLayers();
|
|
this._attachMapHandlers();
|
|
this._updateLayers();
|
|
|
|
return document.createElement('div'); // Dummy container for IControl
|
|
}
|
|
|
|
/**
|
|
* Remove the draw control from the map
|
|
*/
|
|
onRemove() {
|
|
this._detachMapHandlers();
|
|
this._removeLayers();
|
|
this.map = null;
|
|
}
|
|
|
|
/**
|
|
* Get all features as a GeoJSON FeatureCollection
|
|
*/
|
|
getAll() {
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: Array.from(this.features.values()).map(f => ({
|
|
type: 'Feature',
|
|
id: f.id,
|
|
geometry: f.geometry,
|
|
properties: f.properties || {}
|
|
}))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add a feature or features
|
|
*/
|
|
add(featureOrCollection) {
|
|
const features = featureOrCollection.type === 'FeatureCollection'
|
|
? featureOrCollection.features
|
|
: [featureOrCollection];
|
|
|
|
features.forEach(feature => {
|
|
const id = feature.id || this._generateId();
|
|
this.features.set(id, {
|
|
id: id,
|
|
type: 'Feature',
|
|
geometry: feature.geometry,
|
|
properties: feature.properties || {}
|
|
});
|
|
});
|
|
|
|
this._updateLayers();
|
|
return features.map(f => f.id);
|
|
}
|
|
|
|
/**
|
|
* Get a feature by ID
|
|
*/
|
|
get(id) {
|
|
return this.features.get(id) || null;
|
|
}
|
|
|
|
/**
|
|
* Get selected features
|
|
*/
|
|
getSelected() {
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: Array.from(this.selectedFeatureIds)
|
|
.map(id => this.features.get(id))
|
|
.filter(f => f)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get selected feature IDs
|
|
*/
|
|
getSelectedIds() {
|
|
return Array.from(this.selectedFeatureIds);
|
|
}
|
|
|
|
/**
|
|
* Delete a feature or features by ID(s)
|
|
*/
|
|
delete(ids) {
|
|
const idArray = Array.isArray(ids) ? ids : [ids];
|
|
idArray.forEach(id => {
|
|
this.features.delete(id);
|
|
this.selectedFeatureIds.delete(id);
|
|
});
|
|
this._updateLayers();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Delete all features
|
|
*/
|
|
deleteAll() {
|
|
this.features.clear();
|
|
this.selectedFeatureIds.clear();
|
|
this._updateLayers();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set features (replace all)
|
|
*/
|
|
set(featureCollection) {
|
|
this.features.clear();
|
|
this.selectedFeatureIds.clear();
|
|
this.add(featureCollection);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Change the drawing mode
|
|
*/
|
|
changeMode(mode, options = {}) {
|
|
const oldMode = this.mode;
|
|
this.mode = mode;
|
|
|
|
// Clear current drawing state
|
|
this.currentFeature = null;
|
|
this.draggedVertex = null;
|
|
this.isDragging = false;
|
|
|
|
// Store mode-specific snap option
|
|
if (options.snap !== undefined) {
|
|
this._modeSnap = options.snap;
|
|
} else {
|
|
// Use global snap setting as default
|
|
this._modeSnap = this.options.snap;
|
|
}
|
|
|
|
// Update snapOptions if provided in mode options
|
|
if (options.snapOptions) {
|
|
this.snapOptions = {
|
|
...this.snapOptions,
|
|
...options.snapOptions
|
|
};
|
|
}
|
|
|
|
// Handle mode-specific setup
|
|
// Map old mode names to new 'edit' mode for compatibility
|
|
if (mode === 'simple_select' || mode === 'direct_select') {
|
|
this.mode = 'edit';
|
|
}
|
|
|
|
if ((mode === 'edit' || mode === 'simple_select' || mode === 'direct_select') && options.featureIds) {
|
|
this.selectedFeatureIds.clear();
|
|
options.featureIds.forEach(id => this.selectedFeatureIds.add(id));
|
|
} else if (options.featureId) {
|
|
// Support direct_select style single featureId option
|
|
this.selectedFeatureIds.clear();
|
|
this.selectedFeatureIds.add(options.featureId);
|
|
} else {
|
|
// console.log('[CustomDraw.changeMode] No featureIds in options, selectedFeatureIds:', Array.from(this.selectedFeatureIds));
|
|
}
|
|
|
|
this._updateLayers();
|
|
this._updateMapCursor();
|
|
|
|
this._fireEvent('mode.change', { mode: mode });
|
|
this._fireEvent('draw.modechange', { mode: mode }); // Alias for compatibility
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Get the current mode
|
|
*/
|
|
getMode() {
|
|
return this.mode;
|
|
}
|
|
|
|
/**
|
|
* Trash/delete selected features
|
|
*/
|
|
trash() {
|
|
const ids = Array.from(this.selectedFeatureIds);
|
|
if (ids.length > 0) {
|
|
this.delete(ids);
|
|
this._fireEvent('draw.delete', {
|
|
features: ids.map(id => this.features.get(id)).filter(f => f)
|
|
});
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add an event listener
|
|
*/
|
|
on(event, handler) {
|
|
if (!this.eventListeners[event]) {
|
|
this.eventListeners[event] = [];
|
|
}
|
|
this.eventListeners[event].push(handler);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Remove an event listener
|
|
*/
|
|
off(event, handler) {
|
|
if (!this.eventListeners[event]) return this;
|
|
|
|
if (handler) {
|
|
this.eventListeners[event] = this.eventListeners[event]
|
|
.filter(h => h !== handler);
|
|
} else {
|
|
this.eventListeners[event] = [];
|
|
}
|
|
return this;
|
|
}
|
|
|
|
// ========== Private Methods ==========
|
|
|
|
/**
|
|
* Fire an event to all listeners
|
|
*/
|
|
_fireEvent(eventName, data = {}) {
|
|
const listeners = this.eventListeners[eventName] || [];
|
|
listeners.forEach(handler => {
|
|
try {
|
|
handler(data);
|
|
} catch (err) {
|
|
console.error(`Error in ${eventName} handler:`, err);
|
|
}
|
|
});
|
|
|
|
// Also fire on the map for compatibility with existing code
|
|
if (this.map) {
|
|
this.map.fire(eventName, data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a unique ID for a feature
|
|
*/
|
|
_generateId() {
|
|
// More robust UUID-like generation to avoid collisions
|
|
const timestamp = Date.now().toString(36);
|
|
const randomPart = Math.random().toString(36).substr(2, 9);
|
|
const counterPart = (this._idCounter = (this._idCounter || 0) + 1).toString(36);
|
|
return 'feature_' + timestamp + '_' + randomPart + '_' + counterPart;
|
|
}
|
|
|
|
/**
|
|
* Setup event handler methods
|
|
*/
|
|
_setupEventHandlers() {
|
|
this.handlers.click = this._handleClick.bind(this);
|
|
this.handlers.mousemove = this._handleMouseMove.bind(this);
|
|
this.handlers.mousedown = this._handleMouseDown.bind(this);
|
|
this.handlers.mouseup = this._handleMouseUp.bind(this);
|
|
this.handlers.dblclick = this._handleDoubleClick.bind(this);
|
|
this.handlers.contextmenu = this._handleContextMenu.bind(this);
|
|
// Touch events are handled separately via canvas event listeners with passive: false
|
|
}
|
|
|
|
/**
|
|
* Attach map event handlers
|
|
*/
|
|
_attachMapHandlers() {
|
|
if (!this.map) return;
|
|
|
|
this.map.on('click', this.handlers.click);
|
|
this.map.on('mousemove', this.handlers.mousemove);
|
|
this.map.on('mousedown', this.handlers.mousedown);
|
|
this.map.on('mouseup', this.handlers.mouseup);
|
|
this.map.on('dblclick', this.handlers.dblclick);
|
|
this.map.on('contextmenu', this.handlers.contextmenu);
|
|
|
|
// Touch events - attach directly to canvas with passive: false to allow preventDefault
|
|
const canvas = this.map.getCanvas();
|
|
if (canvas) {
|
|
this._canvasTouchHandlers = {
|
|
touchstart: this._handleCanvasTouchStart.bind(this),
|
|
touchmove: this._handleCanvasTouchMove.bind(this),
|
|
touchend: this._handleCanvasTouchEnd.bind(this)
|
|
};
|
|
canvas.addEventListener('touchstart', this._canvasTouchHandlers.touchstart, { passive: false });
|
|
canvas.addEventListener('touchmove', this._canvasTouchHandlers.touchmove, { passive: false });
|
|
canvas.addEventListener('touchend', this._canvasTouchHandlers.touchend, { passive: false });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detach map event handlers
|
|
*/
|
|
_detachMapHandlers() {
|
|
if (!this.map) return;
|
|
|
|
this.map.off('click', this.handlers.click);
|
|
this.map.off('mousemove', this.handlers.mousemove);
|
|
this.map.off('mousedown', this.handlers.mousedown);
|
|
this.map.off('mouseup', this.handlers.mouseup);
|
|
this.map.off('dblclick', this.handlers.dblclick);
|
|
this.map.off('contextmenu', this.handlers.contextmenu);
|
|
|
|
// Remove canvas touch handlers
|
|
const canvas = this.map.getCanvas();
|
|
if (canvas && this._canvasTouchHandlers) {
|
|
canvas.removeEventListener('touchstart', this._canvasTouchHandlers.touchstart);
|
|
canvas.removeEventListener('touchmove', this._canvasTouchHandlers.touchmove);
|
|
canvas.removeEventListener('touchend', this._canvasTouchHandlers.touchend);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert touch event to MapLibre-like event object
|
|
*/
|
|
_touchEventToMapEvent(e) {
|
|
if (!e.touches || e.touches.length === 0) {
|
|
// For touchend, use changedTouches
|
|
if (!e.changedTouches || e.changedTouches.length === 0) return null;
|
|
const touch = e.changedTouches[0];
|
|
const canvas = this.map.getCanvas();
|
|
const rect = canvas.getBoundingClientRect();
|
|
const point = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
|
|
const lngLat = this.map.unproject(point);
|
|
return { point, lngLat, originalEvent: e };
|
|
}
|
|
const touch = e.touches[0];
|
|
const canvas = this.map.getCanvas();
|
|
const rect = canvas.getBoundingClientRect();
|
|
const point = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
|
|
const lngLat = this.map.unproject(point);
|
|
return { point, lngLat, originalEvent: e };
|
|
}
|
|
|
|
/**
|
|
* Handle touch start on canvas
|
|
*/
|
|
_handleCanvasTouchStart(e) {
|
|
const mapEvent = this._touchEventToMapEvent(e);
|
|
if (!mapEvent) return;
|
|
|
|
|
|
// Check if we're touching a vertex or feature in edit mode
|
|
if (this.mode === 'edit') {
|
|
// Log the vertices of selected features for debugging
|
|
for (const featureId of this.selectedFeatureIds) {
|
|
const feature = this.features.get(featureId);
|
|
if (feature && feature.geometry) {
|
|
if (feature.geometry.type === 'Polygon') {
|
|
const ring = feature.geometry.coordinates[0];
|
|
for (let i = 0; i < Math.min(3, ring.length); i++) {
|
|
const projected = this.map.project(ring[i]);
|
|
}
|
|
} else if (feature.geometry.type === 'MultiPolygon') {
|
|
const ring = feature.geometry.coordinates[0][0];
|
|
for (let i = 0; i < Math.min(3, ring.length); i++) {
|
|
const projected = this.map.project(ring[i]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const vertex = this._getVertexAtPoint(mapEvent.point);
|
|
if (vertex) {
|
|
this.isDragging = true;
|
|
this.draggedVertex = vertex;
|
|
// Disable map drag pan to prevent interference
|
|
if (this.map.dragPan) this.map.dragPan.disable();
|
|
e.preventDefault(); // Prevent map panning
|
|
return;
|
|
}
|
|
|
|
const midpoint = this._getMidpointAtPoint(mapEvent.point);
|
|
if (midpoint) {
|
|
const newVertex = this._insertVertexAtMidpoint(midpoint);
|
|
if (newVertex) {
|
|
this.isDragging = true;
|
|
this.draggedVertex = newVertex;
|
|
// Disable map drag pan to prevent interference
|
|
if (this.map.dragPan) this.map.dragPan.disable();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const clickedFeatures = this._getFeatureAtPoint(mapEvent.point);
|
|
if (clickedFeatures.length > 0) {
|
|
// Don't start drag immediately - wait for movement
|
|
// Store potential drag info for touchmove to decide
|
|
this._potentialFeatureDrag = {
|
|
feature: clickedFeatures[0],
|
|
startPoint: mapEvent.lngLat,
|
|
startScreenPoint: mapEvent.point
|
|
};
|
|
// Don't call preventDefault here - allow click event for deselection
|
|
// e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle touch move on canvas
|
|
*/
|
|
_handleCanvasTouchMove(e) {
|
|
const mapEvent = this._touchEventToMapEvent(e);
|
|
if (!mapEvent) return;
|
|
|
|
// Check if we have a potential feature drag that should now become a real drag
|
|
if (this._potentialFeatureDrag && !this.isDraggingFeature) {
|
|
const dx = mapEvent.point.x - this._potentialFeatureDrag.startScreenPoint.x;
|
|
const dy = mapEvent.point.y - this._potentialFeatureDrag.startScreenPoint.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Only start drag if moved more than 5 pixels
|
|
if (distance > 5) {
|
|
this.isDraggingFeature = true;
|
|
this.draggedFeature = this._potentialFeatureDrag.feature;
|
|
this.dragStartPoint = this._potentialFeatureDrag.startPoint;
|
|
if (this.map.dragPan) this.map.dragPan.disable();
|
|
}
|
|
}
|
|
|
|
if (!this.isDragging && !this.isDraggingFeature) return;
|
|
|
|
|
|
e.preventDefault(); // Prevent map panning during drag
|
|
|
|
if (this.isDragging && this.draggedVertex) {
|
|
this._handleVertexDrag(mapEvent);
|
|
} else if (this.isDraggingFeature && this.draggedFeature) {
|
|
this._handleFeatureDrag(mapEvent);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle touch end on canvas
|
|
*/
|
|
_handleCanvasTouchEnd(e) {
|
|
|
|
// Re-enable map drag pan
|
|
if (this.map && this.map.dragPan) {
|
|
this.map.dragPan.enable();
|
|
}
|
|
|
|
// Check if this was a tap on a feature (potentialDrag that never became a real drag)
|
|
// If so, fire a custom event for deselection
|
|
if (this._potentialFeatureDrag && !this.isDraggingFeature) {
|
|
const tappedFeature = this._potentialFeatureDrag.feature;
|
|
this._potentialFeatureDrag = null;
|
|
|
|
// Fire a custom event that the toolbar can listen to
|
|
this._fireEvent('draw.featureTap', {
|
|
feature: tappedFeature,
|
|
featureId: tappedFeature.id
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Clear potential drag
|
|
this._potentialFeatureDrag = null;
|
|
|
|
if (this.isDragging || this.isDraggingFeature) {
|
|
const mapEvent = this._touchEventToMapEvent(e);
|
|
this._handleEditMouseUp(mapEvent || { point: { x: 0, y: 0 }, lngLat: { lng: 0, lat: 0 } });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle click events
|
|
*/
|
|
_handleClick(e) {
|
|
const mode = this.mode;
|
|
const now = Date.now();
|
|
|
|
|
|
// Double-click detection for drawing modes
|
|
if (mode === 'draw_line_string' || mode === 'draw_polygon') {
|
|
const timeDiff = now - this._lastClickTime;
|
|
const isSamePoint = this._lastClickPoint &&
|
|
Math.abs(e.point.x - this._lastClickPoint.x) < this._doubleClickDistanceThreshold &&
|
|
Math.abs(e.point.y - this._lastClickPoint.y) < this._doubleClickDistanceThreshold;
|
|
|
|
|
|
if (timeDiff < this._doubleClickThreshold && isSamePoint) {
|
|
this._lastClickTime = 0;
|
|
this._lastClickPoint = null;
|
|
this._finishDrawing();
|
|
return;
|
|
}
|
|
|
|
this._lastClickTime = now;
|
|
this._lastClickPoint = { x: e.point.x, y: e.point.y };
|
|
}
|
|
|
|
// In edit mode, check if we clicked on a vertex or draw feature
|
|
// If not, let the event propagate to allow other layers to handle it
|
|
if (mode === 'edit') {
|
|
const vertex = this._getVertexAtPoint(e.point);
|
|
const clickedFeatures = this._getFeatureAtPoint(e.point);
|
|
|
|
if (!vertex && clickedFeatures.length === 0) {
|
|
// No vertex or draw feature clicked - let the event propagate
|
|
return;
|
|
}
|
|
// Don't handle click in edit mode - dragging is handled by mousedown/mouseup
|
|
return;
|
|
}
|
|
|
|
// For drawing modes, prevent default (if cancelable)
|
|
if (mode === 'draw_point' || mode === 'draw_line_string' || mode === 'draw_polygon') {
|
|
if (e.preventDefault && (!e.originalEvent || e.originalEvent.cancelable !== false)) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
if (mode === 'draw_point') {
|
|
this._handleDrawPointClick(e);
|
|
} else if (mode === 'draw_line_string') {
|
|
this._handleDrawLineClick(e);
|
|
} else if (mode === 'draw_polygon') {
|
|
this._handleDrawPolygonClick(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle mouse move events
|
|
*/
|
|
_handleMouseMove(e) {
|
|
const mode = this.mode;
|
|
|
|
if (mode === 'draw_line_string' || mode === 'draw_polygon') {
|
|
this._updateCurrentFeatureLastVertex(e);
|
|
} else if (mode === 'edit') {
|
|
// Handle vertex dragging
|
|
if (this.isDragging && this.draggedVertex) {
|
|
this._handleVertexDrag(e);
|
|
}
|
|
// Handle feature dragging
|
|
else if (this.isDraggingFeature && this.draggedFeature) {
|
|
this._handleFeatureDrag(e);
|
|
}
|
|
}
|
|
|
|
this._updateMapCursor(e);
|
|
}
|
|
|
|
/**
|
|
* Handle mouse down events
|
|
*/
|
|
_handleMouseDown(e) {
|
|
if (this.mode === 'edit') {
|
|
this._handleEditMouseDown(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle mouse up events
|
|
*/
|
|
_handleMouseUp(e) {
|
|
if (this.mode === 'edit') {
|
|
this._handleEditMouseUp(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle edit mode mouse down - start vertex or feature dragging
|
|
*/
|
|
_handleEditMouseDown(e) {
|
|
|
|
// First check if we clicked on a vertex
|
|
const vertex = this._getVertexAtPoint(e.point);
|
|
|
|
if (vertex) {
|
|
this.isDragging = true;
|
|
this.draggedVertex = vertex;
|
|
this.map.getCanvas().style.cursor = 'grabbing';
|
|
|
|
// Prevent default map behavior (panning)
|
|
// Check if event is cancelable to avoid passive listener warnings
|
|
if (e.originalEvent && e.originalEvent.cancelable !== false) {
|
|
e.originalEvent.stopPropagation();
|
|
e.originalEvent.preventDefault();
|
|
}
|
|
if (e.preventDefault) e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// Check if we clicked on a midpoint (to add a new vertex)
|
|
const midpoint = this._getMidpointAtPoint(e.point);
|
|
|
|
if (midpoint) {
|
|
// Insert the new vertex into the feature
|
|
const newVertex = this._insertVertexAtMidpoint(midpoint);
|
|
if (newVertex) {
|
|
// Start dragging the newly created vertex
|
|
this.isDragging = true;
|
|
this.draggedVertex = newVertex;
|
|
this.map.getCanvas().style.cursor = 'grabbing';
|
|
|
|
// Prevent default map behavior (panning)
|
|
// Check if event is cancelable to avoid passive listener warnings
|
|
if (e.originalEvent && e.originalEvent.cancelable !== false) {
|
|
e.originalEvent.stopPropagation();
|
|
e.originalEvent.preventDefault();
|
|
}
|
|
if (e.preventDefault) e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check if we clicked on a feature (for dragging the whole feature)
|
|
const clickedFeatures = this._getFeatureAtPoint(e.point);
|
|
|
|
if (clickedFeatures.length > 0) {
|
|
const feature = clickedFeatures[0];
|
|
this.isDraggingFeature = true;
|
|
this.draggedFeature = feature;
|
|
this.dragStartPoint = e.lngLat;
|
|
this.map.getCanvas().style.cursor = 'grabbing';
|
|
|
|
// Prevent default map behavior (panning)
|
|
// Check if event is cancelable to avoid passive listener warnings
|
|
if (e.originalEvent && e.originalEvent.cancelable !== false) {
|
|
e.originalEvent.stopPropagation();
|
|
e.originalEvent.preventDefault();
|
|
}
|
|
if (e.preventDefault) e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle edit mode mouse up - end vertex or feature dragging
|
|
*/
|
|
_handleEditMouseUp(e) {
|
|
if (this.isDragging && this.draggedVertex) {
|
|
this.isDragging = false;
|
|
this._lastDragEndTime = Date.now(); // Track when drag ended
|
|
this._ignoringClickAfterDrag = true; // Flag to ignore next click event
|
|
|
|
const feature = this.features.get(this.draggedVertex.featureId);
|
|
if (feature) {
|
|
// Include synced features in the update event
|
|
const allModifiedFeatures = [feature];
|
|
if (this._lastSyncedFeatures && this._lastSyncedFeatures.length > 0) {
|
|
allModifiedFeatures.push(...this._lastSyncedFeatures);
|
|
}
|
|
|
|
this._fireEvent('draw.update', {
|
|
features: allModifiedFeatures,
|
|
action: 'move'
|
|
});
|
|
|
|
// Clear synced features after firing event
|
|
this._lastSyncedFeatures = null;
|
|
}
|
|
|
|
this.draggedVertex = null;
|
|
this.map.getCanvas().style.cursor = '';
|
|
}
|
|
|
|
if (this.isDraggingFeature && this.draggedFeature) {
|
|
this.isDraggingFeature = false;
|
|
this._lastDragEndTime = Date.now(); // Track when drag ended
|
|
this._ignoringClickAfterDrag = true; // Flag to ignore next click event
|
|
|
|
const feature = this.features.get(this.draggedFeature.id);
|
|
if (feature) {
|
|
this._fireEvent('draw.update', {
|
|
features: [feature],
|
|
action: 'move'
|
|
});
|
|
}
|
|
|
|
this.draggedFeature = null;
|
|
this.dragStartPoint = null;
|
|
this.map.getCanvas().style.cursor = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle feature dragging (move entire geometry)
|
|
*/
|
|
_handleFeatureDrag(e) {
|
|
if (!this.draggedFeature || !this.dragStartPoint) return;
|
|
|
|
const feature = this.features.get(this.draggedFeature.id);
|
|
if (!feature) return;
|
|
|
|
// Calculate the delta
|
|
const dx = e.lngLat.lng - this.dragStartPoint.lng;
|
|
const dy = e.lngLat.lat - this.dragStartPoint.lat;
|
|
|
|
// Update all coordinates by the delta
|
|
this._translateGeometry(feature.geometry, dx, dy);
|
|
|
|
// Update the start point for next move
|
|
this.dragStartPoint = e.lngLat;
|
|
|
|
this._updateLayers();
|
|
}
|
|
|
|
/**
|
|
* Translate all coordinates in a geometry by dx, dy
|
|
*/
|
|
_translateGeometry(geometry, dx, dy) {
|
|
if (geometry.type === 'Point') {
|
|
geometry.coordinates[0] += dx;
|
|
geometry.coordinates[1] += dy;
|
|
} else if (geometry.type === 'LineString') {
|
|
geometry.coordinates.forEach(coord => {
|
|
coord[0] += dx;
|
|
coord[1] += dy;
|
|
});
|
|
} else if (geometry.type === 'Polygon') {
|
|
geometry.coordinates.forEach(ring => {
|
|
ring.forEach(coord => {
|
|
coord[0] += dx;
|
|
coord[1] += dy;
|
|
});
|
|
});
|
|
} else if (geometry.type === 'MultiPolygon') {
|
|
geometry.coordinates.forEach(polygon => {
|
|
polygon.forEach(ring => {
|
|
ring.forEach(coord => {
|
|
coord[0] += dx;
|
|
coord[1] += dy;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle double click events
|
|
*/
|
|
_handleDoubleClick(e) {
|
|
if (e.preventDefault && (!e.originalEvent || e.originalEvent.cancelable !== false)) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (this.mode === 'draw_line_string' || this.mode === 'draw_polygon') {
|
|
this._finishDrawing();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle right click / context menu
|
|
*/
|
|
_handleContextMenu(e) {
|
|
if (e.preventDefault && (!e.originalEvent || e.originalEvent.cancelable !== false)) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (this.mode === 'draw_line_string' || this.mode === 'draw_polygon') {
|
|
this._finishDrawing();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle point drawing click
|
|
*/
|
|
_handleDrawPointClick(e) {
|
|
const coords = this._getSnappedCoordinates(e);
|
|
|
|
const feature = {
|
|
id: this._generateId(),
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: coords
|
|
},
|
|
properties: {}
|
|
};
|
|
|
|
this.features.set(feature.id, feature);
|
|
this._updateLayers();
|
|
|
|
this._fireEvent('draw.create', {
|
|
features: [feature]
|
|
});
|
|
|
|
// Stay in draw_point mode for multiple points
|
|
}
|
|
|
|
/**
|
|
* Handle line drawing click
|
|
*/
|
|
_handleDrawLineClick(e) {
|
|
const coords = this._getSnappedCoordinates(e);
|
|
|
|
if (!this.currentFeature) {
|
|
// Start new line
|
|
this.currentFeature = {
|
|
id: this._generateId(),
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: [coords, coords] // Start with duplicate for preview
|
|
},
|
|
properties: {}
|
|
};
|
|
} else {
|
|
// If clicking near the last fixed point, finish the line
|
|
const coordinates = this.currentFeature.geometry.coordinates;
|
|
if (coordinates.length >= 3) {
|
|
const lastFixed = coordinates[coordinates.length - 2];
|
|
const lastPoint = this.map.project(lastFixed);
|
|
const distance = Math.sqrt(
|
|
Math.pow(e.point.x - lastPoint.x, 2) +
|
|
Math.pow(e.point.y - lastPoint.y, 2)
|
|
);
|
|
|
|
if (distance < VERTEX_CLICK_TOLERANCE_PX) {
|
|
this._finishDrawing();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Add point to line
|
|
coordinates[coordinates.length - 1] = coords;
|
|
coordinates.push(coords); // Add new preview point
|
|
}
|
|
|
|
this._updateLayers();
|
|
}
|
|
|
|
/**
|
|
* Handle polygon drawing click
|
|
*/
|
|
_handleDrawPolygonClick(e) {
|
|
const coords = this._getSnappedCoordinates(e);
|
|
|
|
if (!this.currentFeature) {
|
|
// Start new polygon
|
|
this.currentFeature = {
|
|
id: this._generateId(),
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'Polygon',
|
|
coordinates: [[coords, coords, coords, coords]] // Start with 4 duplicates for preview
|
|
},
|
|
properties: {}
|
|
};
|
|
} else {
|
|
// Add point to polygon
|
|
const ring = this.currentFeature.geometry.coordinates[0];
|
|
|
|
// Check if clicking near the first point to close the polygon
|
|
// We need at least 3 points (excluding preview) to close
|
|
if (ring.length >= 4) { // At least 3 real points + 1 preview
|
|
const firstPoint = ring[0];
|
|
const point = this.map.project([firstPoint[0], firstPoint[1]]);
|
|
const distance = Math.sqrt(
|
|
Math.pow(e.point.x - point.x, 2) +
|
|
Math.pow(e.point.y - point.y, 2)
|
|
);
|
|
|
|
// If close to first point (within 15px), finish the polygon
|
|
if (distance < 15) {
|
|
this._finishDrawing();
|
|
return;
|
|
}
|
|
}
|
|
|
|
ring[ring.length - 2] = coords; // Update the second-to-last point
|
|
ring.push(coords); // Add new preview point
|
|
}
|
|
|
|
this._updateLayers();
|
|
}
|
|
|
|
/**
|
|
* Handle vertex dragging
|
|
*/
|
|
_handleVertexDrag(e) {
|
|
if (!this.draggedVertex) return;
|
|
|
|
const coords = this._getSnappedCoordinates(e);
|
|
const feature = this.features.get(this.draggedVertex.featureId);
|
|
|
|
if (!feature) return;
|
|
|
|
// Get the OLD coordinates before updating (for shared vertex detection)
|
|
const { coordPath } = this.draggedVertex;
|
|
const oldCoords = this._getCoordinateAtPath(feature.geometry, coordPath);
|
|
|
|
// Update the vertex coordinates
|
|
this._setCoordinateAtPath(feature.geometry, coordPath, coords);
|
|
|
|
// For polygons, if we moved the first or last vertex, update both (they must be identical to close the polygon)
|
|
if (feature.geometry.type === 'Polygon' && coordPath.length === 3) {
|
|
const ringIdx = coordPath[1];
|
|
const vertexIdx = coordPath[2];
|
|
const ring = feature.geometry.coordinates[ringIdx];
|
|
|
|
// If it's the first vertex (index 0), also update the last vertex (index length-1)
|
|
if (vertexIdx === 0) {
|
|
ring[ring.length - 1] = [...coords];
|
|
}
|
|
// If it's the last vertex, also update the first vertex
|
|
else if (vertexIdx === ring.length - 1) {
|
|
ring[0] = [...coords];
|
|
}
|
|
}
|
|
// For MultiPolygon, coordPath is [coordinates, polyIdx, ringIdx, vertexIdx]
|
|
else if (feature.geometry.type === 'MultiPolygon' && coordPath.length === 4) {
|
|
const polyIdx = coordPath[1];
|
|
const ringIdx = coordPath[2];
|
|
const vertexIdx = coordPath[3];
|
|
const ring = feature.geometry.coordinates[polyIdx][ringIdx];
|
|
|
|
// If it's the first vertex (index 0), also update the last vertex (index length-1)
|
|
if (vertexIdx === 0) {
|
|
ring[ring.length - 1] = [...coords];
|
|
}
|
|
// If it's the last vertex, also update the first vertex
|
|
else if (vertexIdx === ring.length - 1) {
|
|
ring[0] = [...coords];
|
|
}
|
|
}
|
|
|
|
// Sync shared vertices across all selected features
|
|
if (oldCoords && this.selectedFeatureIds.size > 1) {
|
|
const syncedFeatures = this._syncSharedVertices(this.draggedVertex.featureId, oldCoords, coords);
|
|
// Store synced features for inclusion in draw.update event
|
|
this._lastSyncedFeatures = syncedFeatures;
|
|
}
|
|
|
|
this._updateLayers();
|
|
}
|
|
|
|
/**
|
|
* Sync shared vertices: when a vertex is moved, update matching vertices in other selected features
|
|
* Returns array of modified features (excluding source)
|
|
*/
|
|
_syncSharedVertices(sourceFeatureId, oldCoords, newCoords) {
|
|
const TOLERANCE = 1e-9; // Very tight tolerance for exact matches
|
|
const modifiedFeatures = [];
|
|
|
|
this.selectedFeatureIds.forEach(featureId => {
|
|
if (featureId === sourceFeatureId) return;
|
|
|
|
const feature = this.features.get(featureId);
|
|
if (!feature || feature.geometry.type !== 'Polygon') return;
|
|
|
|
let modified = false;
|
|
|
|
feature.geometry.coordinates.forEach((ring, ringIdx) => {
|
|
ring.forEach((vertex, vertexIdx) => {
|
|
// Check if this vertex matches the OLD position of the dragged vertex
|
|
if (Math.abs(vertex[0] - oldCoords[0]) <= TOLERANCE &&
|
|
Math.abs(vertex[1] - oldCoords[1]) <= TOLERANCE) {
|
|
// Update to new position
|
|
feature.geometry.coordinates[ringIdx][vertexIdx] = [...newCoords];
|
|
modified = true;
|
|
|
|
// If it's first or last vertex of a ring, update the corresponding one too
|
|
if (vertexIdx === 0) {
|
|
ring[ring.length - 1] = [...newCoords];
|
|
} else if (vertexIdx === ring.length - 1) {
|
|
ring[0] = [...newCoords];
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
if (modified) {
|
|
modifiedFeatures.push(feature);
|
|
}
|
|
});
|
|
|
|
return modifiedFeatures;
|
|
}
|
|
|
|
/**
|
|
* Get coordinate at a nested path
|
|
*/
|
|
_getCoordinateAtPath(geometry, path) {
|
|
let coords = geometry.coordinates;
|
|
for (let i = 1; i < path.length; i++) {
|
|
coords = coords[path[i]];
|
|
}
|
|
return coords ? [...coords] : null;
|
|
}
|
|
|
|
/**
|
|
* Update the last vertex of the current feature being drawn
|
|
*/
|
|
_updateCurrentFeatureLastVertex(e) {
|
|
if (!this.currentFeature) return;
|
|
|
|
const coords = this._getSnappedCoordinates(e);
|
|
const geometry = this.currentFeature.geometry;
|
|
|
|
if (geometry.type === 'LineString') {
|
|
const coordinates = geometry.coordinates;
|
|
coordinates[coordinates.length - 1] = coords;
|
|
} else if (geometry.type === 'Polygon') {
|
|
const ring = geometry.coordinates[0];
|
|
ring[ring.length - 1] = coords;
|
|
}
|
|
|
|
this._updateLayers();
|
|
}
|
|
|
|
/**
|
|
* Finish drawing the current feature
|
|
*/
|
|
_finishDrawing() {
|
|
if (!this.currentFeature) return;
|
|
|
|
const geometry = this.currentFeature.geometry;
|
|
let isValid = false;
|
|
|
|
if (geometry.type === 'LineString') {
|
|
// Remove the preview point
|
|
const coords = geometry.coordinates;
|
|
coords.pop();
|
|
|
|
// Line needs at least 2 points
|
|
isValid = coords.length >= 2;
|
|
} else if (geometry.type === 'Polygon') {
|
|
// Remove the preview point
|
|
const ring = geometry.coordinates[0];
|
|
ring.pop();
|
|
|
|
// Close the ring
|
|
if (ring.length >= 3) {
|
|
ring.push(ring[0]);
|
|
isValid = true;
|
|
}
|
|
}
|
|
|
|
if (isValid) {
|
|
this.features.set(this.currentFeature.id, this.currentFeature);
|
|
|
|
this._fireEvent('draw.create', {
|
|
features: [this.currentFeature]
|
|
});
|
|
} else {
|
|
}
|
|
|
|
this.currentFeature = null;
|
|
this._updateLayers();
|
|
}
|
|
|
|
/**
|
|
* Get coordinates with snapping applied
|
|
*/
|
|
_getSnappedCoordinates(e) {
|
|
const lngLat = e.lngLat;
|
|
let coords = [lngLat.lng, lngLat.lat];
|
|
|
|
// Check if snapping is enabled (either globally or for this mode)
|
|
const isSnapEnabled = this.options.snap || this._modeSnap;
|
|
|
|
if (!isSnapEnabled) {
|
|
return coords;
|
|
}
|
|
|
|
// Get features to snap to
|
|
const snapFeatures = this._getSnapFeatures();
|
|
|
|
if (snapFeatures.length === 0) {
|
|
return coords;
|
|
}
|
|
|
|
// Find nearest snap point
|
|
const snapPoint = this._findNearestSnapPoint(e.point, e.lngLat, snapFeatures);
|
|
|
|
if (snapPoint) {
|
|
return snapPoint.coordinates;
|
|
}
|
|
|
|
return coords;
|
|
}
|
|
|
|
/**
|
|
* Get features to snap to (with caching)
|
|
*/
|
|
_getSnapFeatures() {
|
|
// Use cache if still valid
|
|
const now = Date.now();
|
|
if (this._snapFeaturesCache && (now - this._snapFeaturesCacheTime) < this._snapFeaturesCacheTTL) {
|
|
return this._snapFeaturesCache;
|
|
}
|
|
|
|
let snapFeatures = [];
|
|
|
|
// Use custom snap function if provided
|
|
if (this.snapOptions.snapGetFeatures) {
|
|
try {
|
|
const customFeatures = this.snapOptions.snapGetFeatures(this.map, this, []);
|
|
snapFeatures = customFeatures;
|
|
} catch (err) {
|
|
console.error('Error getting snap features:', err);
|
|
}
|
|
}
|
|
|
|
// Include rendered map features (excluding draw layers)
|
|
if (this.map && this.snapOptions.snapIncludeMapFeatures) {
|
|
try {
|
|
const rendered = this.map.queryRenderedFeatures();
|
|
const drawSourceId = DRAW_LAYER_PREFIX + 'source';
|
|
|
|
rendered.forEach(mapFeature => {
|
|
const layerId = (mapFeature.layer && mapFeature.layer.id) ? mapFeature.layer.id : '';
|
|
if (layerId && layerId.startsWith(DRAW_LAYER_PREFIX)) return;
|
|
if (mapFeature.source && mapFeature.source === drawSourceId) return;
|
|
if (!mapFeature.geometry) return;
|
|
|
|
snapFeatures.push({
|
|
id: mapFeature.id || mapFeature.properties?.id || null,
|
|
type: 'Feature',
|
|
geometry: mapFeature.geometry,
|
|
properties: mapFeature.properties || {}
|
|
});
|
|
});
|
|
} catch (err) {
|
|
console.error('Error getting rendered map features for snapping:', err);
|
|
}
|
|
}
|
|
|
|
// Also include current drawing features from the draw control
|
|
const drawFeatures = Array.from(this.features.values());
|
|
snapFeatures = snapFeatures.concat(drawFeatures);
|
|
|
|
// Cache the result
|
|
this._snapFeaturesCache = snapFeatures;
|
|
this._snapFeaturesCacheTime = now;
|
|
|
|
return snapFeatures;
|
|
}
|
|
|
|
/**
|
|
* Find the nearest snap point
|
|
*/
|
|
_findNearestSnapPoint(screenPoint, lngLat, features) {
|
|
const snapPx = this.snapOptions.snapPx;
|
|
const vertexPriorityDistance = this.snapOptions.snapVertexPriorityDistance;
|
|
let nearestPoint = null;
|
|
let nearestDistance = Infinity;
|
|
let nearestVertex = null;
|
|
let nearestVertexDistance = Infinity;
|
|
let nearestVertexGeoDistance = Infinity;
|
|
let nearestSegment = null;
|
|
let nearestSegmentDistance = Infinity;
|
|
|
|
features.forEach(feature => {
|
|
if (!feature.geometry) return;
|
|
|
|
// Extract all coordinates from the feature
|
|
const coordinates = this._extractCoordinates(feature.geometry);
|
|
|
|
coordinates.forEach(coord => {
|
|
const point = this.map.project(coord);
|
|
const distance = Math.sqrt(
|
|
Math.pow(point.x - screenPoint.x, 2) +
|
|
Math.pow(point.y - screenPoint.y, 2)
|
|
);
|
|
|
|
if (distance < snapPx && distance < nearestVertexDistance) {
|
|
nearestVertexDistance = distance;
|
|
const geoDistance = lngLat
|
|
? Math.sqrt(
|
|
Math.pow(coord[0] - lngLat.lng, 2) +
|
|
Math.pow(coord[1] - lngLat.lat, 2)
|
|
)
|
|
: Infinity;
|
|
nearestVertexGeoDistance = geoDistance;
|
|
nearestVertex = {
|
|
coordinates: coord,
|
|
distance: distance,
|
|
geoDistance: geoDistance
|
|
};
|
|
}
|
|
});
|
|
|
|
// Also check midpoints if enabled
|
|
if (this.snapOptions.snapToMidPoints) {
|
|
const midpoints = this._getMidpoints(feature.geometry);
|
|
midpoints.forEach(coord => {
|
|
const point = this.map.project(coord);
|
|
const distance = Math.sqrt(
|
|
Math.pow(point.x - screenPoint.x, 2) +
|
|
Math.pow(point.y - screenPoint.y, 2)
|
|
);
|
|
|
|
if (distance < snapPx && distance < nearestDistance) {
|
|
nearestDistance = distance;
|
|
nearestPoint = {
|
|
coordinates: coord,
|
|
distance: distance
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// Also check nearest point on segments if enabled
|
|
if (this.snapOptions.snapToSegments) {
|
|
const segmentPoint = this._findNearestPointOnSegments(screenPoint, feature.geometry);
|
|
if (segmentPoint && segmentPoint.distance < snapPx && segmentPoint.distance < nearestSegmentDistance) {
|
|
nearestSegmentDistance = segmentPoint.distance;
|
|
nearestSegment = {
|
|
coordinates: segmentPoint.coordinates,
|
|
distance: segmentPoint.distance
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
// Vertex priority within a tolerance (in degrees)
|
|
if (nearestVertex && vertexPriorityDistance !== null && vertexPriorityDistance !== undefined) {
|
|
if (nearestVertexGeoDistance <= vertexPriorityDistance) {
|
|
return nearestVertex;
|
|
}
|
|
}
|
|
|
|
// Otherwise, pick the closest between vertex/segment/midpoint candidates
|
|
nearestPoint = nearestPoint || nearestSegment || nearestVertex;
|
|
nearestDistance = nearestPoint ? nearestPoint.distance : Infinity;
|
|
|
|
if (nearestSegment && nearestSegment.distance < nearestDistance) {
|
|
nearestPoint = nearestSegment;
|
|
nearestDistance = nearestSegment.distance;
|
|
}
|
|
|
|
if (nearestVertex && nearestVertex.distance < nearestDistance) {
|
|
nearestPoint = nearestVertex;
|
|
}
|
|
|
|
return nearestPoint;
|
|
}
|
|
|
|
/**
|
|
* Find nearest point on any segment of a geometry in screen space
|
|
*/
|
|
_findNearestPointOnSegments(screenPoint, geometry) {
|
|
if (!this.map || !geometry) return null;
|
|
|
|
let nearest = null;
|
|
let nearestDistance = Infinity;
|
|
|
|
const checkSegment = (coordA, coordB) => {
|
|
const a = this.map.project(coordA);
|
|
const b = this.map.project(coordB);
|
|
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const lengthSq = dx * dx + dy * dy;
|
|
if (lengthSq === 0) return;
|
|
|
|
let t = ((screenPoint.x - a.x) * dx + (screenPoint.y - a.y) * dy) / lengthSq;
|
|
t = Math.max(0, Math.min(1, t));
|
|
|
|
const closestScreen = {
|
|
x: a.x + t * dx,
|
|
y: a.y + t * dy
|
|
};
|
|
|
|
const distance = Math.sqrt(
|
|
Math.pow(closestScreen.x - screenPoint.x, 2) +
|
|
Math.pow(closestScreen.y - screenPoint.y, 2)
|
|
);
|
|
|
|
if (distance < nearestDistance) {
|
|
nearestDistance = distance;
|
|
const lngLat = this.map.unproject(closestScreen);
|
|
nearest = {
|
|
coordinates: [lngLat.lng, lngLat.lat],
|
|
distance: distance
|
|
};
|
|
}
|
|
};
|
|
|
|
const processLine = (coords) => {
|
|
for (let i = 0; i < coords.length - 1; i++) {
|
|
checkSegment(coords[i], coords[i + 1]);
|
|
}
|
|
};
|
|
|
|
if (geometry.type === 'LineString') {
|
|
processLine(geometry.coordinates);
|
|
} else if (geometry.type === 'Polygon') {
|
|
geometry.coordinates.forEach(ring => processLine(ring));
|
|
} else if (geometry.type === 'MultiLineString') {
|
|
geometry.coordinates.forEach(line => processLine(line));
|
|
} else if (geometry.type === 'MultiPolygon') {
|
|
geometry.coordinates.forEach(polygon => {
|
|
polygon.forEach(ring => processLine(ring));
|
|
});
|
|
}
|
|
|
|
return nearest;
|
|
}
|
|
|
|
/**
|
|
* Extract all coordinates from a geometry
|
|
*/
|
|
_extractCoordinates(geometry) {
|
|
const coords = [];
|
|
|
|
if (geometry.type === 'Point') {
|
|
coords.push(geometry.coordinates);
|
|
} else if (geometry.type === 'LineString') {
|
|
coords.push(...geometry.coordinates);
|
|
} else if (geometry.type === 'Polygon') {
|
|
geometry.coordinates.forEach(ring => {
|
|
coords.push(...ring);
|
|
});
|
|
} else if (geometry.type === 'MultiPoint') {
|
|
coords.push(...geometry.coordinates);
|
|
} else if (geometry.type === 'MultiLineString') {
|
|
geometry.coordinates.forEach(line => {
|
|
coords.push(...line);
|
|
});
|
|
} else if (geometry.type === 'MultiPolygon') {
|
|
geometry.coordinates.forEach(polygon => {
|
|
polygon.forEach(ring => {
|
|
coords.push(...ring);
|
|
});
|
|
});
|
|
}
|
|
|
|
return coords;
|
|
}
|
|
|
|
/**
|
|
* Get midpoints of line segments in a geometry
|
|
*/
|
|
_getMidpoints(geometry) {
|
|
const midpoints = [];
|
|
|
|
const processRing = (coords) => {
|
|
for (let i = 0; i < coords.length - 1; i++) {
|
|
const midpoint = [
|
|
(coords[i][0] + coords[i + 1][0]) / 2,
|
|
(coords[i][1] + coords[i + 1][1]) / 2
|
|
];
|
|
midpoints.push(midpoint);
|
|
}
|
|
};
|
|
|
|
if (geometry.type === 'LineString') {
|
|
processRing(geometry.coordinates);
|
|
} else if (geometry.type === 'Polygon') {
|
|
geometry.coordinates.forEach(ring => processRing(ring));
|
|
} else if (geometry.type === 'MultiLineString') {
|
|
geometry.coordinates.forEach(line => processRing(line));
|
|
} else if (geometry.type === 'MultiPolygon') {
|
|
geometry.coordinates.forEach(polygon => {
|
|
polygon.forEach(ring => processRing(ring));
|
|
});
|
|
}
|
|
|
|
return midpoints;
|
|
}
|
|
|
|
/**
|
|
* Get feature at a screen point
|
|
*/
|
|
_getFeatureAtPoint(point) {
|
|
if (!this.map) return [];
|
|
|
|
const tolerance = this.options.clickTolerance;
|
|
const bbox = [
|
|
[point.x - tolerance, point.y - tolerance],
|
|
[point.x + tolerance, point.y + tolerance]
|
|
];
|
|
|
|
const layers = this._getDrawLayerIds();
|
|
const mapFeatures = this.map.queryRenderedFeatures(bbox, { layers: layers });
|
|
|
|
// Map back to our feature objects
|
|
const features = [];
|
|
mapFeatures.forEach(mapFeature => {
|
|
const featureId = mapFeature.properties.id || mapFeature.id;
|
|
const feature = this.features.get(featureId);
|
|
if (feature) {
|
|
features.push(feature);
|
|
}
|
|
});
|
|
|
|
return features;
|
|
}
|
|
|
|
/**
|
|
* Get vertex at a screen point
|
|
*/
|
|
_getVertexAtPoint(point) {
|
|
if (!this.map) return null;
|
|
|
|
const tolerance = VERTEX_CLICK_TOLERANCE_PX;
|
|
|
|
// Check selected features for vertices
|
|
for (const featureId of this.selectedFeatureIds) {
|
|
const feature = this.features.get(featureId);
|
|
if (!feature) continue;
|
|
|
|
const vertex = this._findVertexNearPoint(feature, point, tolerance);
|
|
if (vertex) {
|
|
return vertex;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get midpoint at a screen point
|
|
*/
|
|
_getMidpointAtPoint(point) {
|
|
if (!this.map) return null;
|
|
|
|
const tolerance = VERTEX_CLICK_TOLERANCE_PX;
|
|
|
|
// Check selected features for midpoints
|
|
for (const featureId of this.selectedFeatureIds) {
|
|
const feature = this.features.get(featureId);
|
|
if (!feature) continue;
|
|
if (feature.geometry.type !== 'Polygon' && feature.geometry.type !== 'MultiPolygon') continue;
|
|
|
|
const midpoint = this._findMidpointNearPoint(feature, point, tolerance);
|
|
if (midpoint) {
|
|
return midpoint;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find a midpoint near a point
|
|
*/
|
|
_findMidpointNearPoint(feature, screenPoint, tolerance) {
|
|
const geometry = feature.geometry;
|
|
|
|
const checkRing = (ring, ringIdx, polyIdx = null) => {
|
|
for (let i = 0; i < ring.length - 1; i++) {
|
|
const coord1 = ring[i];
|
|
const coord2 = ring[i + 1];
|
|
|
|
// Calculate midpoint
|
|
const midCoord = [
|
|
(coord1[0] + coord2[0]) / 2,
|
|
(coord1[1] + coord2[1]) / 2
|
|
];
|
|
|
|
const point = this.map.project(midCoord);
|
|
const distance = Math.sqrt(
|
|
Math.pow(point.x - screenPoint.x, 2) +
|
|
Math.pow(point.y - screenPoint.y, 2)
|
|
);
|
|
|
|
if (distance < tolerance) {
|
|
return {
|
|
featureId: feature.id,
|
|
coordinates: midCoord,
|
|
polyIdx: polyIdx,
|
|
ringIdx: ringIdx,
|
|
insertAfterIdx: i,
|
|
distance: distance
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (geometry.type === 'Polygon') {
|
|
for (let ringIdx = 0; ringIdx < geometry.coordinates.length; ringIdx++) {
|
|
const result = checkRing(geometry.coordinates[ringIdx], ringIdx);
|
|
if (result) return result;
|
|
}
|
|
} else if (geometry.type === 'MultiPolygon') {
|
|
for (let polyIdx = 0; polyIdx < geometry.coordinates.length; polyIdx++) {
|
|
const polygon = geometry.coordinates[polyIdx];
|
|
for (let ringIdx = 0; ringIdx < polygon.length; ringIdx++) {
|
|
const result = checkRing(polygon[ringIdx], ringIdx, polyIdx);
|
|
if (result) return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Insert a vertex at a midpoint position
|
|
*/
|
|
_insertVertexAtMidpoint(midpoint) {
|
|
const feature = this.features.get(midpoint.featureId);
|
|
if (!feature) return null;
|
|
|
|
let ring;
|
|
let coordPath;
|
|
|
|
if (feature.geometry.type === 'Polygon') {
|
|
ring = feature.geometry.coordinates[midpoint.ringIdx];
|
|
coordPath = ['coordinates', midpoint.ringIdx, midpoint.insertAfterIdx + 1];
|
|
} else if (feature.geometry.type === 'MultiPolygon' && midpoint.polyIdx !== null) {
|
|
ring = feature.geometry.coordinates[midpoint.polyIdx][midpoint.ringIdx];
|
|
coordPath = ['coordinates', midpoint.polyIdx, midpoint.ringIdx, midpoint.insertAfterIdx + 1];
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
// Insert the new vertex after insertAfterIdx
|
|
ring.splice(midpoint.insertAfterIdx + 1, 0, midpoint.coordinates);
|
|
|
|
// Update layers
|
|
this._updateLayers();
|
|
|
|
// Return the new vertex info for dragging
|
|
return {
|
|
featureId: midpoint.featureId,
|
|
coordinates: midpoint.coordinates,
|
|
coordPath: coordPath
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Find a vertex near a point
|
|
*/
|
|
_findVertexNearPoint(feature, screenPoint, tolerance) {
|
|
const geometry = feature.geometry;
|
|
|
|
const checkCoord = (coord, path) => {
|
|
const point = this.map.project(coord);
|
|
const distance = Math.sqrt(
|
|
Math.pow(point.x - screenPoint.x, 2) +
|
|
Math.pow(point.y - screenPoint.y, 2)
|
|
);
|
|
|
|
if (distance < tolerance) {
|
|
return {
|
|
featureId: feature.id,
|
|
coordinates: coord,
|
|
coordPath: path,
|
|
distance: distance
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (geometry.type === 'Point') {
|
|
return checkCoord(geometry.coordinates, ['coordinates']);
|
|
} else if (geometry.type === 'LineString') {
|
|
for (let i = 0; i < geometry.coordinates.length; i++) {
|
|
const vertex = checkCoord(geometry.coordinates[i], ['coordinates', i]);
|
|
if (vertex) return vertex;
|
|
}
|
|
} else if (geometry.type === 'Polygon') {
|
|
for (let ringIdx = 0; ringIdx < geometry.coordinates.length; ringIdx++) {
|
|
const ring = geometry.coordinates[ringIdx];
|
|
for (let i = 0; i < ring.length; i++) {
|
|
const vertex = checkCoord(ring[i], ['coordinates', ringIdx, i]);
|
|
if (vertex) return vertex;
|
|
}
|
|
}
|
|
} else if (geometry.type === 'MultiPolygon') {
|
|
for (let polyIdx = 0; polyIdx < geometry.coordinates.length; polyIdx++) {
|
|
const polygon = geometry.coordinates[polyIdx];
|
|
for (let ringIdx = 0; ringIdx < polygon.length; ringIdx++) {
|
|
const ring = polygon[ringIdx];
|
|
for (let i = 0; i < ring.length; i++) {
|
|
const vertex = checkCoord(ring[i], ['coordinates', polyIdx, ringIdx, i]);
|
|
if (vertex) return vertex;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Set coordinate at a nested path
|
|
*/
|
|
_setCoordinateAtPath(geometry, path, newCoord) {
|
|
let obj = geometry;
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
obj = obj[path[i]];
|
|
}
|
|
obj[path[path.length - 1]] = newCoord;
|
|
}
|
|
|
|
/**
|
|
* Update map cursor based on mode and hover state
|
|
*/
|
|
_updateMapCursor(e) {
|
|
if (!this.map) return;
|
|
|
|
const mode = this.mode;
|
|
|
|
if (mode === 'draw_point' || mode === 'draw_line_string' || mode === 'draw_polygon') {
|
|
this.map.getCanvas().style.cursor = 'crosshair';
|
|
} else if (mode === 'direct_select') {
|
|
// Check if hovering over a vertex
|
|
if (e) {
|
|
const vertex = this._getVertexAtPoint(e.point);
|
|
this.map.getCanvas().style.cursor = vertex ? 'move' : 'default';
|
|
} else {
|
|
this.map.getCanvas().style.cursor = 'default';
|
|
}
|
|
} else {
|
|
this.map.getCanvas().style.cursor = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add map layers for rendering
|
|
*/
|
|
_addLayers() {
|
|
if (!this.map) return;
|
|
|
|
// Add source
|
|
this.map.addSource(DRAW_LAYER_PREFIX + 'source', {
|
|
type: 'geojson',
|
|
data: this._getSourceData()
|
|
});
|
|
|
|
// Add layers based on styles
|
|
const styles = this.options.styles;
|
|
styles.forEach(style => {
|
|
const layer = {
|
|
...style,
|
|
id: DRAW_LAYER_PREFIX + style.id,
|
|
source: DRAW_LAYER_PREFIX + 'source'
|
|
};
|
|
|
|
try {
|
|
this.map.addLayer(layer);
|
|
} catch (err) {
|
|
console.error('Error adding layer:', err);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove map layers
|
|
*/
|
|
_removeLayers() {
|
|
if (!this.map) return;
|
|
|
|
const styles = this.options.styles;
|
|
styles.forEach(style => {
|
|
const layerId = DRAW_LAYER_PREFIX + style.id;
|
|
if (this.map.getLayer(layerId)) {
|
|
this.map.removeLayer(layerId);
|
|
}
|
|
});
|
|
|
|
if (this.map.getSource(DRAW_LAYER_PREFIX + 'source')) {
|
|
this.map.removeSource(DRAW_LAYER_PREFIX + 'source');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update map layers with current data
|
|
*/
|
|
_updateLayers() {
|
|
if (!this.map) return;
|
|
|
|
const source = this.map.getSource(DRAW_LAYER_PREFIX + 'source');
|
|
if (source) {
|
|
source.setData(this._getSourceData());
|
|
}
|
|
|
|
// Invalidate snap features cache when features change
|
|
this._snapFeaturesCache = null;
|
|
}
|
|
|
|
/**
|
|
* Get source data as GeoJSON
|
|
*/
|
|
_getSourceData() {
|
|
const features = [];
|
|
|
|
// Add all features
|
|
this.features.forEach(feature => {
|
|
const isActive = this.selectedFeatureIds.has(feature.id);
|
|
const isVertex = false;
|
|
|
|
features.push({
|
|
type: 'Feature',
|
|
id: feature.id,
|
|
geometry: feature.geometry,
|
|
properties: {
|
|
...feature.properties,
|
|
id: feature.id,
|
|
active: isActive ? 'true' : 'false',
|
|
meta: 'feature'
|
|
}
|
|
});
|
|
|
|
// Add vertices for selected features in edit mode (includes direct_select)
|
|
if (isActive && (this.mode === 'edit' || this.mode === 'direct_select')) {
|
|
this._addVertexFeatures(feature, features);
|
|
}
|
|
});
|
|
|
|
// Add current drawing feature if any
|
|
if (this.currentFeature) {
|
|
features.push({
|
|
type: 'Feature',
|
|
id: this.currentFeature.id,
|
|
geometry: this.currentFeature.geometry,
|
|
properties: {
|
|
id: this.currentFeature.id,
|
|
active: 'true',
|
|
meta: 'feature'
|
|
}
|
|
});
|
|
|
|
// Add vertices for the current drawing feature so user can see the points
|
|
if (this.mode === 'draw_polygon' || this.mode === 'draw_line_string') {
|
|
this._addVertexFeatures(this.currentFeature, features);
|
|
}
|
|
}
|
|
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: features
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add vertex features for a feature
|
|
*/
|
|
_addVertexFeatures(feature, features) {
|
|
const coordinates = this._extractCoordinates(feature.geometry);
|
|
|
|
// Add actual vertices
|
|
coordinates.forEach((coord, idx) => {
|
|
features.push({
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: coord
|
|
},
|
|
properties: {
|
|
meta: 'vertex',
|
|
parent: feature.id,
|
|
coord_path: idx
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add midpoints for adding new vertices
|
|
const midpoints = this._getMidpoints(feature.geometry);
|
|
midpoints.forEach((coord, idx) => {
|
|
features.push({
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: coord
|
|
},
|
|
properties: {
|
|
meta: 'midpoint',
|
|
parent: feature.id,
|
|
coord_path: idx
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get draw layer IDs
|
|
*/
|
|
_getDrawLayerIds() {
|
|
return this.options.styles
|
|
.filter(s => s.type === 'fill' || s.type === 'line' || s.type === 'circle')
|
|
.map(s => DRAW_LAYER_PREFIX + s.id);
|
|
}
|
|
|
|
/**
|
|
* Get default styles
|
|
*/
|
|
_getDefaultStyles() {
|
|
return [
|
|
// Polygon fill inactive
|
|
{
|
|
id: 'polygon-fill-inactive',
|
|
type: 'fill',
|
|
filter: ['all',
|
|
['==', ['get', 'active'], 'false'],
|
|
['==', ['geometry-type'], 'Polygon'],
|
|
['==', ['get', 'meta'], 'feature']
|
|
],
|
|
paint: {
|
|
'fill-color': '#3bb2d0',
|
|
'fill-opacity': 0.1
|
|
}
|
|
},
|
|
// Polygon fill active
|
|
{
|
|
id: 'polygon-fill-active',
|
|
type: 'fill',
|
|
filter: ['all',
|
|
['==', ['get', 'active'], 'true'],
|
|
['==', ['geometry-type'], 'Polygon'],
|
|
['==', ['get', 'meta'], 'feature']
|
|
],
|
|
paint: {
|
|
'fill-color': '#fbb03b',
|
|
'fill-opacity': 0.1
|
|
}
|
|
},
|
|
// Polygon outline inactive
|
|
{
|
|
id: 'polygon-stroke-inactive',
|
|
type: 'line',
|
|
filter: ['all',
|
|
['==', ['get', 'active'], 'false'],
|
|
['==', ['geometry-type'], 'Polygon'],
|
|
['==', ['get', 'meta'], 'feature']
|
|
],
|
|
layout: {
|
|
'line-cap': 'round',
|
|
'line-join': 'round'
|
|
},
|
|
paint: {
|
|
'line-color': '#3bb2d0',
|
|
'line-width': 2
|
|
}
|
|
},
|
|
// Polygon outline active
|
|
{
|
|
id: 'polygon-stroke-active',
|
|
type: 'line',
|
|
filter: ['all',
|
|
['==', ['get', 'active'], 'true'],
|
|
['==', ['geometry-type'], 'Polygon'],
|
|
['==', ['get', 'meta'], 'feature']
|
|
],
|
|
layout: {
|
|
'line-cap': 'round',
|
|
'line-join': 'round'
|
|
},
|
|
paint: {
|
|
'line-color': '#fbb03b',
|
|
'line-width': 2
|
|
}
|
|
},
|
|
// Line inactive
|
|
{
|
|
id: 'line-inactive',
|
|
type: 'line',
|
|
filter: ['all',
|
|
['==', ['get', 'active'], 'false'],
|
|
['==', ['geometry-type'], 'LineString'],
|
|
['==', ['get', 'meta'], 'feature']
|
|
],
|
|
layout: {
|
|
'line-cap': 'round',
|
|
'line-join': 'round'
|
|
},
|
|
paint: {
|
|
'line-color': '#3bb2d0',
|
|
'line-width': 2
|
|
}
|
|
},
|
|
// Line active
|
|
{
|
|
id: 'line-active',
|
|
type: 'line',
|
|
filter: ['all',
|
|
['==', ['get', 'active'], 'true'],
|
|
['==', ['geometry-type'], 'LineString'],
|
|
['==', ['get', 'meta'], 'feature']
|
|
],
|
|
layout: {
|
|
'line-cap': 'round',
|
|
'line-join': 'round'
|
|
},
|
|
paint: {
|
|
'line-color': '#fbb03b',
|
|
'line-width': 2
|
|
}
|
|
},
|
|
// Point inactive
|
|
{
|
|
id: 'point-inactive',
|
|
type: 'circle',
|
|
filter: ['all',
|
|
['==', ['get', 'active'], 'false'],
|
|
['==', ['geometry-type'], 'Point'],
|
|
['==', ['get', 'meta'], 'feature']
|
|
],
|
|
paint: {
|
|
'circle-radius': 5,
|
|
'circle-color': '#3bb2d0'
|
|
}
|
|
},
|
|
// Point active
|
|
{
|
|
id: 'point-active',
|
|
type: 'circle',
|
|
filter: ['all',
|
|
['==', ['get', 'active'], 'true'],
|
|
['==', ['geometry-type'], 'Point'],
|
|
['==', ['get', 'meta'], 'feature']
|
|
],
|
|
paint: {
|
|
'circle-radius': 7,
|
|
'circle-color': '#fbb03b'
|
|
}
|
|
},
|
|
// Vertex points
|
|
{
|
|
id: 'vertex-halo',
|
|
type: 'circle',
|
|
filter: ['all',
|
|
['==', ['get', 'meta'], 'vertex']
|
|
],
|
|
paint: {
|
|
'circle-radius': 5,
|
|
'circle-color': '#FFF'
|
|
}
|
|
},
|
|
{
|
|
id: 'vertex',
|
|
type: 'circle',
|
|
filter: ['all',
|
|
['==', ['get', 'meta'], 'vertex']
|
|
],
|
|
paint: {
|
|
'circle-radius': 3,
|
|
'circle-color': '#fbb03b'
|
|
}
|
|
},
|
|
// Midpoint points (for adding new vertices)
|
|
{
|
|
id: 'midpoint',
|
|
type: 'circle',
|
|
filter: ['all',
|
|
['==', ['get', 'meta'], 'midpoint']
|
|
],
|
|
paint: {
|
|
'circle-radius': 3,
|
|
'circle-color': '#fbb03b',
|
|
'circle-opacity': 0.6
|
|
}
|
|
}
|
|
];
|
|
}
|
|
}
|
|
|
|
// Export to global scope
|
|
window.CustomDraw = CustomDraw;
|
|
|
|
})(window);
|