loko/streetup/common/static/common/custom-draw-toolbar.js
2026-07-22 14:48:40 +02:00

3443 lines
138 KiB
JavaScript

/**
* Custom Draw Toolbar for Maplibre GL
*
* A reusable, configurable drawing toolbar that integrates with CustomDraw.
* Provides drawing mode toggle, polygon tools (split, merge), snapping,
* and geometry add/delete operations.
*
* Integrated features:
* - Save/Undo floating buttons (bottom-right of map)
* - Help messages display (bottom-left of map)
* - Internationalization via gettext
*
* @version 2.0.0
* @requires maplibre-gl
* @requires custom-draw.js
* @requires turf.js
* @requires Django gettext (optional, falls back to labels)
*/
(function(window) {
'use strict';
/**
* Translation helper - uses Django gettext if available
* @param {string} text - Text to translate
* @returns {string} - Translated text or original if gettext unavailable
*/
function _(text) {
if (typeof gettext === 'function') {
return gettext(text);
}
return text;
}
// Default SVG icons
const ICONS = {
pencil: `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23333' viewBox='0 0 16 16'><path d='M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z'/></svg>`,
addPolygon: `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23333' viewBox='0 0 16 16'><path d='M1 1v14h14V1H1zm13 13H2V2h12v12z'/><path d='M7 5v2H5v2h2v2h2V9h2V7H9V5H7z'/></svg>`,
trash: `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23333' viewBox='0 0 16 16'><path d='M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z'/><path fill-rule='evenodd' d='M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z'/></svg>`,
snap: `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23333' viewBox='0 0 16 16'><path d='M8 1a7 7 0 0 0-7 7h2a5 5 0 0 1 10 0h2a7 7 0 0 0-7-7zm0 3a4 4 0 0 0-4 4h2a2 2 0 0 1 4 0h2a4 4 0 0 0-4-4z'/><rect x='7' y='8' width='2' height='6' rx='1'/></svg>`,
split: `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23333' viewBox='0 0 16 16'><path d='M5.5 1a.5.5 0 0 1 .5.5v13a.5.5 0 0 1-1 0v-13a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v13a.5.5 0 0 1-1 0v-13a.5.5 0 0 1 .5-.5z'/><path d='M2 8a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z'/></svg>`,
// New merge icon: two arrows pointing together (more intuitive)
merge: `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23333' viewBox='0 0 16 16'><path d='M1 8a.5.5 0 0 1 .5-.5h4.793L4.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L6.293 8.5H1.5A.5.5 0 0 1 1 8zm14 0a.5.5 0 0 1-.5.5h-4.793l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 0 1 .708.708L9.707 7.5h4.793a.5.5 0 0 1 .5.5z'/></svg>`,
polygon: `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23333' viewBox='0 0 16 16'><path d='M8 1l7 5v6l-7 3-7-3V6l7-5zm0 1.236L2 6.618v4.764l6 2.572 6-2.572V6.618L8 2.236z'/></svg>`
};
// CSS styles for the toolbar
const TOOLBAR_STYLES = `
.draw-toolbar-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.draw-toolbar-group {
background: #fff;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
overflow: hidden;
}
.draw-toolbar-btn {
width: 29px;
height: 29px;
display: block;
padding: 0;
outline: none;
border: 0;
box-sizing: border-box;
background-color: transparent;
cursor: pointer;
background-repeat: no-repeat;
background-position: center;
background-size: 18px 18px;
border-bottom: 1px solid rgba(0,0,0,.1);
}
.draw-toolbar-btn:last-child {
border-bottom: none;
}
.draw-toolbar-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.draw-toolbar-btn.active {
background-color: #e7f3ff;
}
.draw-toolbar-btn.active:hover {
background-color: #cce5ff;
}
.draw-toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.draw-toolbar-btn:disabled:hover {
background-color: transparent;
}
.draw-toolbar-separator {
height: 1px;
background: rgba(0,0,0,.1);
margin: 0;
}
/* Floating action buttons (save/undo) */
.draw-floating-actions {
position: absolute;
bottom: 20px;
right: 20px;
z-index: 1001;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-end;
}
.draw-floating-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
transition: all 0.2s ease;
}
.draw-floating-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 12px rgba(0,0,0,0.35);
}
.draw-floating-btn-save {
background-color: #198754;
color: white;
}
.draw-floating-btn-save:hover {
background-color: #157347;
}
.draw-floating-btn-undo {
background-color: #ffc107;
color: #212529;
border-radius: 50%;
width: 45px;
height: 45px;
padding: 0;
}
.draw-floating-btn-undo:hover {
background-color: #e0a800;
}
.draw-floating-btn .badge {
margin-left: 8px;
background-color: rgba(255,255,255,0.9);
color: #212529;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.draw-floating-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* Help message toast */
.draw-help-message {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 1000;
max-width: 350px;
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: none;
}
.draw-help-message.visible {
opacity: 1;
transform: translateY(0);
}
.draw-help-message.info {
background-color: #cff4fc;
color: #055160;
border-left: 4px solid #0dcaf0;
}
.draw-help-message.success {
background-color: #d1e7dd;
color: #0a3622;
border-left: 4px solid #198754;
}
.draw-help-message.warning {
background-color: #fff3cd;
color: #664d03;
border-left: 4px solid #ffc107;
}
.draw-help-message.danger {
background-color: #f8d7da;
color: #58151c;
border-left: 4px solid #dc3545;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.draw-floating-actions {
bottom: 15px;
right: 15px;
}
.draw-help-message {
bottom: 15px;
left: 15px;
right: 15px;
max-width: none;
}
.draw-floating-btn-save {
font-size: 0.8rem;
padding: 0.4rem 0.8rem;
}
.draw-floating-btn-undo {
width: 40px;
height: 40px;
}
}
`;
/**
* CustomDrawToolbar class
*
* A reusable toolbar control for Maplibre GL that provides drawing tools
*/
class CustomDrawToolbar {
/**
* Create a new CustomDrawToolbar
* @param {Object} options - Configuration options
* @param {Object} options.draw - The CustomDraw instance
* @param {Object} options.layerConfig - Configuration for the layer(s) to edit
* @param {string} options.layerConfig.sourceId - The MapLibre source ID for the features
* @param {string[]} options.layerConfig.layerIds - The MapLibre layer IDs to filter
* @param {string} options.layerConfig.assetType - The asset type identifier (e.g., 'naturegreensurface')
* @param {Object} options.api - API endpoints configuration
* @param {string} options.api.loadUrl - URL to load assets (POST with filter_geom)
* @param {string} options.api.saveUrl - URL to save geometry changes (POST)
* @param {string} options.api.deleteUrl - URL to delete assets (POST with asset_id)
* @param {Function} options.api.getCsrfToken - Function that returns CSRF token
* @param {Object} options.callbacks - Callback functions
* @param {Function} options.callbacks.onMessage - Callback for showing messages (text, type)
* @param {Function} options.callbacks.onDrawModeChange - Callback when draw mode changes (isActive)
* @param {Function} options.callbacks.onModifiedCountChange - Callback when modified count changes (count)
* @param {Function} options.callbacks.onSelectionChange - Callback when selection changes (selectedFeatures, selectedIds)
* @param {Function} options.callbacks.onFeatureEdit - Callback when a feature enters edit mode (feature with properties)
* @param {Function} options.callbacks.onGeometryCreate - Callback when geometry is created (feature)
* @param {Function} options.callbacks.onGeometryUpdate - Callback when geometry is updated (feature)
* @param {Function} options.callbacks.onGeometryDelete - Callback when geometry is deleted (featureId)
* @param {Object} options.labels - UI labels (for i18n)
* @param {string} options.labels.drawMode - "Draw Mode"
* @param {string} options.labels.addPolygon - "Add Polygon"
* @param {string} options.labels.delete - "Delete"
* @param {string} options.labels.snap - "Snap"
* @param {string} options.labels.split - "Split Polygon"
* @param {string} options.labels.merge - "Merge Polygons"
*/
constructor(options = {}) {
this.options = options;
this.draw = options.draw;
this.map = null;
this.container = null;
// Layer configuration
this.layerConfig = options.layerConfig || {
sourceId: 'assets',
layerIds: ['green-surfaces', 'green-surfaces-outline'],
assetType: 'asset'
};
// API configuration
this.api = options.api || {};
// Callbacks
this.callbacks = options.callbacks || {};
// Help message configuration
this.helpMessageDuration = options.helpMessageDuration || 2000; // 2 seconds default
// Labels with defaults using gettext
this.labels = Object.assign({
drawMode: _('Draw Mode'),
addPolygon: _('Add Polygon'),
delete: _('Delete'),
snap: _('Snap'),
snapEnabled: _('Snap enabled'),
snapDisabled: _('Snap disabled'),
split: _('Split Polygon'),
splitCancelled: _('Split cancelled'),
splitSuccess: _('Split successful'),
merge: _('Merge Polygons'),
mergeSuccess: _('Merge successful'),
mergeError: _('Error merging polygons'),
polygonRemoved: _('Polygon removed from edit mode'),
polygonAdded: _('Polygon added to edit mode'),
drawModeEnabled: _('Draw mode enabled - Click on a geometry to edit'),
drawModeDisabled: _('Draw mode disabled'),
saveGeometries: _('Save geometries'),
undo: _('Undo'),
noFeaturesToDelete: _('No features to delete'),
geometriesDeleted: _('Geometry(ies) deleted'),
addPolygonHelp: _('Click to add points. Double-click or click the first point to finish.'),
splitHelp: _('Draw a line across the polygon to split it.')
}, options.labels || {});
// State
this.isDrawMode = false;
this.isSplitDrawing = false;
this.splitLineFeatureId = null;
this.loadedAssets = new Map();
this.modifiedGeometries = new Map();
this.hiddenFeatures = new Set();
this.originalGeometries = new Map(); // Store original geometries (after normalization by draw)
this.currentEditingAsset = null;
this.geometryHistory = [];
this.lastSelectedFeatureIds = [];
// Button references
this.buttons = {};
// UI elements
this.floatingActionsContainer = null;
this.saveBtn = null;
this.undoBtn = null;
this.helpMessageEl = null;
this.helpMessageTimeout = null;
// Inject styles
this._injectStyles();
}
/**
* Inject toolbar CSS styles
*/
_injectStyles() {
if (!document.getElementById('draw-toolbar-styles')) {
const style = document.createElement('style');
style.id = 'draw-toolbar-styles';
style.textContent = TOOLBAR_STYLES;
document.head.appendChild(style);
}
}
/**
* Create icon URL from SVG string
*/
_iconUrl(svgString) {
return `url("data:image/svg+xml,${encodeURIComponent(svgString.replace(/%23333/g, '#333'))}")`;
}
/**
* Add the control to the map (Maplibre IControl interface)
*/
onAdd(map) {
this.map = map;
// Create main container
this.container = document.createElement('div');
this.container.className = 'maplibregl-ctrl draw-toolbar-container';
// Create main toolbar group
this._createMainToolbar();
// Create tools group (hidden by default)
this._createToolsGroup();
// Create floating action buttons (save/undo)
this._createFloatingActions();
// Create help message element
this._createHelpMessage();
// Setup draw event listeners
this._setupDrawEventListeners();
return this.container;
}
/**
* Remove the control from the map
*/
onRemove() {
// Remove asset click handlers from attached layers
if (this.map && this._boundAssetClickHandler && this._attachedLayers) {
this._attachedLayers.forEach(layerId => {
if (this.map.getLayer(layerId)) {
this.map.off('click', layerId, this._boundAssetClickHandler);
this.map.off('mouseenter', layerId, this._boundAssetMouseEnterHandler);
this.map.off('mouseleave', layerId, this._boundAssetMouseLeaveHandler);
}
});
this._attachedLayers.clear();
}
// Remove empty click handler
if (this.map && this._boundEmptyClickHandler) {
this.map.off('click', this._boundEmptyClickHandler);
}
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
// Remove floating actions
if (this.floatingActionsContainer && this.floatingActionsContainer.parentNode) {
this.floatingActionsContainer.parentNode.removeChild(this.floatingActionsContainer);
}
// Remove help message
if (this.helpMessageEl && this.helpMessageEl.parentNode) {
this.helpMessageEl.parentNode.removeChild(this.helpMessageEl);
}
// Clear timeout
if (this.helpMessageTimeout) {
clearTimeout(this.helpMessageTimeout);
}
this.map = null;
}
/**
* Create the main toolbar with draw mode toggle
*/
_createMainToolbar() {
const group = document.createElement('div');
group.className = 'maplibregl-ctrl-group draw-toolbar-group';
// Draw mode toggle button (pencil icon)
this.buttons.drawMode = this._createButton(
ICONS.pencil,
this.labels.drawMode,
() => this.toggleDrawMode()
);
group.appendChild(this.buttons.drawMode);
this.container.appendChild(group);
}
/**
* Create the tools group (snap, add, delete, split, merge)
*/
_createToolsGroup() {
this.toolsGroup = document.createElement('div');
this.toolsGroup.className = 'maplibregl-ctrl-group draw-toolbar-group';
this.toolsGroup.style.display = 'none';
// Add polygon button
this.buttons.addPolygon = this._createButton(
ICONS.addPolygon,
this.labels.addPolygon,
() => this.startDrawPolygon(),
true
);
this.toolsGroup.appendChild(this.buttons.addPolygon);
// Delete button
this.buttons.delete = this._createButton(
ICONS.trash,
this.labels.delete,
() => this.deleteSelected(),
true
);
this.toolsGroup.appendChild(this.buttons.delete);
// Separator
const sep1 = document.createElement('div');
sep1.className = 'draw-toolbar-separator';
this.toolsGroup.appendChild(sep1);
// Snap button
this.buttons.snap = this._createButton(
ICONS.snap,
this.labels.snap,
() => this.toggleSnap()
);
this.buttons.snap.classList.add('active'); // Snap enabled by default
this.toolsGroup.appendChild(this.buttons.snap);
// Split button
this.buttons.split = this._createButton(
ICONS.split,
this.labels.split,
() => this.toggleSplit(),
true
);
this.toolsGroup.appendChild(this.buttons.split);
// Merge button
this.buttons.merge = this._createButton(
ICONS.merge,
this.labels.merge,
() => this.performMerge(),
true
);
this.toolsGroup.appendChild(this.buttons.merge);
this.container.appendChild(this.toolsGroup);
}
/**
* Create floating action buttons (save/undo)
*/
_createFloatingActions() {
// Container for floating actions
this.floatingActionsContainer = document.createElement('div');
this.floatingActionsContainer.className = 'draw-floating-actions';
this.floatingActionsContainer.style.display = 'none';
// Undo button (circular)
this.undoBtn = document.createElement('button');
this.undoBtn.type = 'button';
this.undoBtn.className = 'draw-floating-btn draw-floating-btn-undo';
this.undoBtn.title = this.labels.undo;
this.undoBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"/><path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/></svg>`;
this.undoBtn.addEventListener('click', (e) => {
e.preventDefault();
this.undoLastAction();
});
// Save button
this.saveBtn = document.createElement('button');
this.saveBtn.type = 'button';
this.saveBtn.className = 'draw-floating-btn draw-floating-btn-save';
this.saveBtn.title = this.labels.saveGeometries;
this.saveBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M11 2H9v3h2V2Z"/><path d="M1.5 0h11.586a1.5 1.5 0 0 1 1.06.44l1.415 1.414A1.5 1.5 0 0 1 16 2.914V14.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 0 14.5v-13A1.5 1.5 0 0 1 1.5 0ZM1 1.5v13a.5.5 0 0 0 .5.5H2v-4.5A1.5 1.5 0 0 1 3.5 9h9a1.5 1.5 0 0 1 1.5 1.5V15h.5a.5.5 0 0 0 .5-.5V2.914a.5.5 0 0 0-.146-.353l-1.415-1.415A.5.5 0 0 0 13.086 1H13v4.5A1.5 1.5 0 0 1 11.5 7h-7A1.5 1.5 0 0 1 3 5.5V1H1.5a.5.5 0 0 0-.5.5Zm3 4a.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5V1H4v4.5ZM3 15h10v-4.5a.5.5 0 0 0-.5-.5h-9a.5.5 0 0 0-.5.5V15Z"/></svg>
<span class="label">${this.labels.saveGeometries}</span>
<span class="badge">0</span>
`;
this.saveBtn.addEventListener('click', (e) => {
e.preventDefault();
this._handleSave();
});
this.floatingActionsContainer.appendChild(this.undoBtn);
this.floatingActionsContainer.appendChild(this.saveBtn);
// Append to map container
const mapContainer = this.map.getContainer();
mapContainer.appendChild(this.floatingActionsContainer);
}
/**
* Create help message element
*/
_createHelpMessage() {
this.helpMessageEl = document.createElement('div');
this.helpMessageEl.className = 'draw-help-message';
// Append to map container
const mapContainer = this.map.getContainer();
mapContainer.appendChild(this.helpMessageEl);
}
/**
* Show help message
* @param {string} message - Message text
* @param {string} type - Message type: 'info', 'success', 'warning', 'danger'
* @param {number} duration - Duration in ms (default: helpMessageDuration)
*/
showHelp(message, type = 'info', duration = null) {
// Clear any pending timeout
if (this.helpMessageTimeout) {
clearTimeout(this.helpMessageTimeout);
}
// Set message content and type
this.helpMessageEl.textContent = message;
this.helpMessageEl.className = 'draw-help-message ' + type;
// Show message
requestAnimationFrame(() => {
this.helpMessageEl.classList.add('visible');
});
// Hide after duration
const displayDuration = duration !== null ? duration : this.helpMessageDuration;
if (displayDuration > 0) {
this.helpMessageTimeout = setTimeout(() => {
this.helpMessageEl.classList.remove('visible');
}, displayDuration);
}
}
/**
* Hide help message immediately
*/
hideHelp() {
if (this.helpMessageTimeout) {
clearTimeout(this.helpMessageTimeout);
}
this.helpMessageEl.classList.remove('visible');
}
/**
* Update floating buttons visibility
*/
_updateFloatingButtons() {
// Use callbacks if provided, otherwise use internal state
const modCount = this.callbacks.getModifiedCount
? this.callbacks.getModifiedCount()
: this.modifiedGeometries.size;
const drawMode = this.callbacks.getIsDrawMode
? this.callbacks.getIsDrawMode()
: this.isDrawMode;
const historyCount = this.geometryHistory.length;
// Show/hide container based on draw mode and modifications
const shouldShow = drawMode && (modCount > 0 || historyCount > 0);
this.floatingActionsContainer.style.display = shouldShow ? 'flex' : 'none';
// Update save button badge
const badge = this.saveBtn.querySelector('.badge');
if (badge) {
badge.textContent = modCount.toString();
}
// Enable/disable buttons
this.saveBtn.disabled = modCount === 0;
this.saveBtn.style.opacity = modCount === 0 ? '0.5' : '1';
this.undoBtn.style.display = historyCount > 0 ? 'flex' : 'none';
}
/**
* Public method to refresh floating buttons (for external state updates)
*/
refreshFloatingButtons() {
this._updateFloatingButtons();
}
/**
* Handle save button click
*/
_handleSave() {
if (this.callbacks.onSave) {
this.callbacks.onSave(this.modifiedGeometries);
}
}
/**
* Create a toolbar button
*/
_createButton(iconSvg, title, onClick, disabled = false) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'draw-toolbar-btn';
btn.title = title;
btn.style.backgroundImage = this._iconUrl(iconSvg);
btn.disabled = disabled;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (!btn.disabled) {
onClick();
}
});
return btn;
}
/**
* Setup draw event listeners
*/
_setupDrawEventListeners() {
if (!this.draw || !this.map) return;
// Draw create event
this.map.on('draw.create', (e) => this._handleDrawCreate(e));
// Draw update event
this.map.on('draw.update', (e) => this._handleDrawUpdate(e));
// Draw delete event
this.map.on('draw.delete', (e) => this._handleDrawDelete(e));
// Draw selection change
this.map.on('draw.selectionchange', (e) => this._handleSelectionChange(e));
// Draw mode change
this.map.on('draw.modechange', (e) => this._handleModeChange(e));
// Draw feature tap event (for touch deselection)
this.map.on('draw.featureTap', (e) => this._handleFeatureTap(e));
// Setup click handlers on configured layers for asset selection
this._setupAssetClickHandlers();
}
/**
* Setup click handlers on asset layers
* This allows clicking on polygons to add them to edit mode
*/
_setupAssetClickHandlers() {
if (!this.layerConfig.layerIds || this.layerConfig.layerIds.length === 0) {
return;
}
// Bind the handler so we can reference it later
this._boundAssetClickHandler = this._handleAssetClick.bind(this);
this._boundAssetMouseEnterHandler = this._handleAssetMouseEnter.bind(this);
this._boundAssetMouseLeaveHandler = this._handleAssetMouseLeave.bind(this);
// Track which layers have handlers attached
this._attachedLayers = new Set();
// Function to attach handlers to available layers
const attachHandlers = () => {
let allAttached = true;
this.layerConfig.layerIds.forEach(layerId => {
// Skip if already attached
if (this._attachedLayers.has(layerId)) {
return;
}
if (this.map.getLayer(layerId)) {
this.map.on('click', layerId, this._boundAssetClickHandler);
this.map.on('mouseenter', layerId, this._boundAssetMouseEnterHandler);
this.map.on('mouseleave', layerId, this._boundAssetMouseLeaveHandler);
this._attachedLayers.add(layerId);
} else {
allAttached = false;
}
});
return allAttached;
};
// Try to attach immediately
if (!attachHandlers()) {
// Listen for layer additions via styledata event
const onStyleData = (e) => {
if (attachHandlers()) {
this.map.off('styledata', onStyleData);
this.map.off('sourcedata', onSourceData);
}
};
// Also listen for sourcedata in case layers are added after source
const onSourceData = (e) => {
if (e.sourceId === this.layerConfig.sourceId && e.isSourceLoaded) {
// Give a small delay for layers to be added after source
setTimeout(() => {
if (attachHandlers()) {
this.map.off('styledata', onStyleData);
this.map.off('sourcedata', onSourceData);
}
}, 100);
}
};
this.map.on('styledata', onStyleData);
this.map.on('sourcedata', onSourceData);
// Also try with a delay as fallback
setTimeout(() => {
if (attachHandlers()) {
this.map.off('styledata', onStyleData);
this.map.off('sourcedata', onSourceData);
}
}, 500);
}
// Also setup handler for clicks on empty areas (to deselect all)
this._boundEmptyClickHandler = this._handleEmptyClick.bind(this);
this.map.on('click', this._boundEmptyClickHandler);
}
/**
* Handle click on empty area (no features)
* Deselects all features but keeps modifications
*/
_handleEmptyClick(e) {
// Only process if in draw mode
if (!this.isDrawMode) {
return;
}
// If currently drawing a line or polygon, don't handle empty clicks
if (this.draw && typeof this.draw.getMode === 'function') {
const mode = this.draw.getMode();
if (mode === 'draw_line_string' || mode === 'draw_polygon') {
return;
}
}
// Skip if we just handled a feature tap (for touch events)
if (this._justHandledFeatureTap) {
return;
}
// Check if CustomDraw is currently dragging - if so, don't process click
if (this.draw && (this.draw.isDragging || this.draw.isDraggingFeature)) {
return;
}
// Check if we should ignore this click because an actual drag just ended
if (this.draw && this.draw._ignoringClickAfterDrag) {
this.draw._ignoringClickAfterDrag = false; // Clear the flag
return;
}
// Check if we clicked on any of our asset layers
const layerIds = this.layerConfig.layerIds || [];
const features = this.map.queryRenderedFeatures(e.point, { layers: layerIds });
// If we clicked on a feature that is NOT hidden, let the layer click handler handle it
// If the feature IS hidden (in edit mode), we should process deselection
if (features && features.length > 0) {
// Check if the feature is hidden (being edited)
const clickedId = features[0].properties?.id || features[0].properties?.asset_id || features[0].id;
if (!this.hiddenFeatures.has(clickedId) && !this.hiddenFeatures.has(String(clickedId))) {
return;
}
}
// Check if we clicked on a CustomDraw element (vertex, midpoint, edge)
// Layer IDs used by CustomDraw (custom-draw.js)
const drawInteractiveLayers = [
'vertex',
'vertex-halo',
'midpoint',
'polygon-fill',
'polygon-outline',
'line',
'point'
];
// Query for draw elements at click point
const existingLayers = drawInteractiveLayers.filter(id => this.map.getLayer(id));
if (existingLayers.length > 0) {
const drawElements = this.map.queryRenderedFeatures(e.point, { layers: existingLayers });
if (drawElements && drawElements.length > 0) {
// Clicked on a draw element (vertex/midpoint) - let CustomDraw handle it
return;
}
}
// Check if we clicked on a draw feature (CustomDraw's own features)
const drawFeatures = this.draw.getAll().features;
if (drawFeatures.length === 0) {
// No features in draw, nothing to deselect
return;
}
// Check if click is on a draw feature by checking if any draw feature contains the clicked point
const clickedPoint = [e.lngLat.lng, e.lngLat.lat];
let clickedDrawFeature = null;
for (const feature of drawFeatures) {
if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') {
try {
if (typeof turf !== 'undefined' && turf.booleanPointInPolygon) {
if (turf.booleanPointInPolygon(clickedPoint, feature)) {
clickedDrawFeature = feature;
break;
}
}
} catch (err) {
// Ignore turf errors
}
}
}
if (clickedDrawFeature) {
// Clicked on a draw feature - deselect it (remove from edit mode, keep modifications)
this._removeFeatureFromEditKeepModifications(clickedDrawFeature.id);
return;
}
this._deselectAllKeepModifications();
}
/**
* Handle click on asset layer
*/
_handleAssetClick(e) {
// Ignore clicks when comment repositioning is active
if (window.commentsComponent?.isRepositioning) {
return;
}
// Only process if in draw mode
if (!this.isDrawMode) {
return;
}
// Check if CustomDraw is currently dragging or just finished dragging
if (this.draw && (this.draw.isDragging || this.draw.isDraggingFeature)) {
return;
}
if (this.draw && this.draw._lastDragEndTime && (Date.now() - this.draw._lastDragEndTime < 100)) {
return;
}
// Check if we clicked on a CustomDraw vertex or midpoint - if so, let CustomDraw handle it
const drawInteractiveLayers = ['vertex', 'vertex-halo', 'midpoint'];
const existingLayers = drawInteractiveLayers.filter(id => this.map.getLayer(id));
if (existingLayers.length > 0) {
const drawElements = this.map.queryRenderedFeatures(e.point, { layers: existingLayers });
if (drawElements && drawElements.length > 0) {
// Clicked on a vertex/midpoint - let CustomDraw handle it
return;
}
}
// Check if we have features
if (!e.features || e.features.length === 0) {
return;
}
const clickedFeature = e.features[0];
// Try to get the real feature ID from multiple sources:
// IMPORTANT: Check properties FIRST because MapLibre's generateId creates
// sequential IDs that don't match the real feature IDs from the API.
// The template stores the real ID in properties.id and properties.asset_id
let featureId = clickedFeature.properties?.id ||
clickedFeature.properties?.asset_id ||
clickedFeature.properties?.pk;
// Fall back to feature.id only if properties don't have it
if (featureId === undefined || featureId === null) {
featureId = clickedFeature.id;
}
// If still no ID, try to find it in loadedAssets by geometry matching
if ((featureId === undefined || featureId === null) && this.loadedAssets.size > 0) {
const clickedGeomStr = JSON.stringify(clickedFeature.geometry);
for (const [assetId, asset] of this.loadedAssets) {
if (JSON.stringify(asset.geometry) === clickedGeomStr) {
featureId = assetId;
break;
}
}
}
if (featureId === undefined || featureId === null) {
return;
}
// FIRST CHECK: If feature is hidden (in edit mode), check if we should deselect it
// This handles the case where MapLibre hasn't yet updated the rendered features
if (this.hiddenFeatures.has(featureId)) {
const existingFeature = this.draw.get(featureId);
if (existingFeature) {
this._removeFeatureFromEditKeepModifications(featureId);
}
return;
}
// Check if this feature is already in draw mode - if so, REMOVE it (deselect)
const existingFeature = this.draw.get(featureId);
if (existingFeature) {
this._removeFeatureFromEditKeepModifications(featureId);
return;
}
// Create a feature object with the correct ID
const feature = this.loadedAssets.get(featureId) || {
id: featureId,
type: 'Feature',
geometry: clickedFeature.geometry,
properties: clickedFeature.properties || {}
};
// If callback provided, use it
if (this.callbacks.onAssetClick) {
this.callbacks.onAssetClick(feature);
return;
}
// Internal handling: add feature to edit mode
this.addFeatureToEdit(feature);
// Show message
this._showMessage(this.labels.polygonAdded || 'Polygon added to edit mode', 'success');
}
/**
* Handle mouse enter on asset layer
*/
_handleAssetMouseEnter(e) {
if (this.isDrawMode) {
this.map.getCanvas().style.cursor = 'pointer';
}
}
/**
* Handle mouse leave on asset layer
*/
_handleAssetMouseLeave(e) {
if (this.isDrawMode) {
this.map.getCanvas().style.cursor = '';
}
}
/**
* Toggle draw mode on/off
*/
toggleDrawMode() {
this.isDrawMode = !this.isDrawMode;
// Update button state
this.buttons.drawMode.classList.toggle('active', this.isDrawMode);
// Show/hide tools group
this.toolsGroup.style.display = this.isDrawMode ? 'block' : 'none';
// Enable/disable tools
this.buttons.addPolygon.disabled = !this.isDrawMode;
this.buttons.delete.disabled = !this.isDrawMode;
// Update split/merge state
this._updateSplitMergeState();
// Update floating buttons
this._updateFloatingButtons();
// Fire callback - let the page handle draw mode logic
if (this.callbacks.onDrawModeChange) {
this.callbacks.onDrawModeChange(this.isDrawMode);
} else {
// Internal handling (no callback provided)
this._showMessage(
this.isDrawMode ? this.labels.drawModeEnabled : this.labels.drawModeDisabled,
'info'
);
// If turning off draw mode, clear draw control
if (!this.isDrawMode) {
this._exitDrawMode();
}
}
}
/**
* Exit draw mode and restore features (discards all modifications)
*/
_exitDrawMode() {
// Get all features from draw
const drawFeatures = this.draw.getAll().features;
const shouldDiscardChanges = this.modifiedGeometries.size > 0;
// Restore hidden features on the map
drawFeatures.forEach(feature => {
const featureId = feature.id;
if (!String(featureId).startsWith('new_')) {
this.hiddenFeatures.delete(featureId);
// Restore original geometry (discard edits) OR keep current draw geometry
if (this.loadedAssets.has(featureId)) {
const asset = this.loadedAssets.get(featureId);
if (shouldDiscardChanges) {
if (this.originalGeometries.has(featureId)) {
const originalGeometry = this.originalGeometries.get(featureId);
asset.geometry = JSON.parse(JSON.stringify(originalGeometry));
}
} else {
asset.geometry = JSON.parse(JSON.stringify(feature.geometry));
}
}
}
});
// Clear draw
this.draw.deleteAll();
this.draw.changeMode('edit');
// Clear tracked originals; discard modifications only if needed
if (shouldDiscardChanges) {
this.modifiedGeometries.clear();
}
this.originalGeometries.clear();
this.geometryHistory = [];
// Update layer filters
this._updateLayerFilters();
// Update map source with original data
this._updateMapSource();
// Update floating buttons (should hide them since no modifications)
this._updateFloatingButtons();
// Reset state
this.currentEditingAsset = null;
this.lastSelectedFeatureIds = [];
this.isSplitDrawing = false;
// Cancel any pending split
if (this.splitLineFeatureId) {
this.splitLineFeatureId = null;
}
if (shouldDiscardChanges) {
this._showMessage(this.labels.drawModeDisabled || 'Draw mode disabled - modifications discarded', 'warning');
} else {
this._showMessage(this.labels.drawModeDisabled || 'Draw mode disabled', 'info');
}
}
/**
* Remove a feature from edit mode but keep its modifications
* The modified geometry is stored and will be shown on the map
*/
_removeFeatureFromEditKeepModifications(featureId) {
// Get the current geometry from draw (which may have been modified)
const drawFeature = this.draw.get(featureId);
if (!drawFeature) {
// Try with string/numeric conversion
const stringId = String(featureId);
const numericId = Number(featureId);
if (!this.draw.get(stringId) && !this.draw.get(numericId)) {
return;
}
}
// Check if geometry was modified
// IMPORTANT: Compare with the NORMALIZED original geometry (stored after draw.add)
const originalGeometry = this.originalGeometries.get(featureId);
const currentGeometry = drawFeature.geometry;
// Use a robust geometry comparison
const geometryChanged = this._geometriesAreDifferent(originalGeometry, currentGeometry);
if (geometryChanged) {
// Track the modification
this.modifiedGeometries.set(featureId, {
assetId: featureId,
assetType: drawFeature.properties?.asset_type || this.layerConfig.assetType,
originalGeometry: originalGeometry,
currentGeometry: currentGeometry,
isDeleted: false
});
// Update the loaded asset with the new geometry for display
const originalAsset = this.loadedAssets.get(featureId);
if (originalAsset) {
originalAsset.geometry = JSON.parse(JSON.stringify(currentGeometry));
}
}
// Remove from draw
this.draw.delete(featureId);
// Clear the stored original geometry
this.originalGeometries.delete(featureId);
// IMPORTANT: Reset draw mode to refresh the display (clear vertices/outline)
const remainingFeatures = this.draw.getAll().features;
const remainingIds = remainingFeatures.map(f => f.id);
// Reset to edit mode to clear the visual state
if (remainingIds.length > 0) {
this.draw.changeMode('edit', { featureIds: remainingIds });
} else {
this.draw.changeMode('edit');
}
// Show on map again
this.hiddenFeatures.delete(featureId);
this._updateLayerFilters();
// Update the map source to show the (possibly modified) geometry
this._updateMapSource();
// Update state
if (this.currentEditingAsset?.id === featureId) {
this.currentEditingAsset = null;
}
this._updateSplitMergeState();
this._updateFloatingButtons();
this._showMessage(this.labels.polygonRemoved || 'Polygon removed from edit mode', 'info');
}
/**
* Remove all features from edit mode but keep modifications
* Called when clicking on empty area
*/
_deselectAllKeepModifications() {
const drawFeatures = this.draw.getAll().features;
const featuresToRemove = [];
drawFeatures.forEach(feature => {
const featureId = feature.id;
// Skip new features (they should stay in draw)
if (String(featureId).startsWith('new_')) {
return;
}
featuresToRemove.push(featureId);
// Check if geometry was modified
const originalAsset = this.loadedAssets.get(featureId);
const originalGeometry = originalAsset?.geometry;
const currentGeometry = feature.geometry;
const geometryChanged = JSON.stringify(originalGeometry) !== JSON.stringify(currentGeometry);
if (geometryChanged) {
// Track the modification
this.modifiedGeometries.set(featureId, {
assetId: featureId,
assetType: feature.properties?.asset_type || this.layerConfig.assetType,
originalGeometry: originalGeometry,
currentGeometry: currentGeometry,
isDeleted: false
});
// Update the loaded asset with the new geometry
if (originalAsset) {
originalAsset.geometry = currentGeometry;
}
}
// Mark for showing on map
this.hiddenFeatures.delete(featureId);
});
// Remove all features from draw at once
if (featuresToRemove.length > 0) {
this.draw.delete(featuresToRemove);
}
// IMPORTANT: Reset draw mode to clear visual state (vertices/outline)
this.draw.changeMode('edit');
// Update map
this._updateLayerFilters();
this._updateMapSource();
// Reset state
this.currentEditingAsset = null;
this.lastSelectedFeatureIds = [];
this._updateSplitMergeState();
this._updateFloatingButtons();
}
/**
* Start drawing a new polygon
*/
startDrawPolygon() {
if (!this.isDrawMode) return;
// If callback provided, let the page handle it
if (this.callbacks.onAddPolygon) {
this.callbacks.onAddPolygon();
// Show help message
this.showHelp(this.labels.addPolygonHelp, 'info');
} else {
// Internal handling
this.draw.changeMode('draw_polygon');
// Show help message
this._showMessage(this.labels.addPolygonHelp, 'info');
}
}
/**
* Delete selected features
*/
deleteSelected() {
if (!this.isDrawMode) return;
// If callback provided, let the page handle it
if (this.callbacks.onDeleteSelected) {
const deleted = this.callbacks.onDeleteSelected();
if (deleted) {
this.showHelp(this.labels.geometriesDeleted, 'info');
} else {
this.showHelp(this.labels.noFeaturesToDelete, 'warning');
}
return;
}
// Internal handling
const selectedFeatures = this.draw.getAll().features;
if (selectedFeatures.length === 0) {
this._showMessage(this.labels.noFeaturesToDelete, 'warning');
return;
}
selectedFeatures.forEach(feature => {
const featureId = feature.id;
// Track deletion in history
this._addToHistory({
type: 'delete',
featureId: featureId,
geometry: feature.geometry,
properties: feature.properties
});
// Remove from draw
this.draw.delete(featureId);
// If it's an existing asset, track for deletion on save
if (!String(featureId).startsWith('new_')) {
this.modifiedGeometries.set(featureId, {
assetId: featureId,
assetType: this.layerConfig.assetType,
originalGeometry: this.loadedAssets.get(featureId)?.geometry || null,
currentGeometry: null, // null = deleted
isDeleted: true
});
} else {
// Remove new feature from tracking
this.modifiedGeometries.delete(featureId);
this.loadedAssets.delete(featureId);
}
// Show feature on map if it was hidden
this.hiddenFeatures.delete(featureId);
});
this._updateLayerFilters();
this._updateMapSource();
this._updateModifiedCount();
// Show confirmation
this._showMessage(this.labels.geometriesDeleted, 'info');
if (this.callbacks.onGeometryDelete) {
this.callbacks.onGeometryDelete(selectedFeatures.map(f => f.id));
}
}
/**
* Toggle snapping
*/
toggleSnap() {
const isActive = this.buttons.snap.classList.contains('active');
this.buttons.snap.classList.toggle('active', !isActive);
this.draw.options.snap = !isActive;
this._showMessage(
isActive ? this.labels.snapDisabled : this.labels.snapEnabled,
'info'
);
}
/**
* Toggle split mode
*/
toggleSplit() {
// If callback provided, let the page handle split logic
if (this.callbacks.onSplitToggle) {
const newState = !this.buttons.split.classList.contains('active');
this.buttons.split.classList.toggle('active', newState);
this.callbacks.onSplitToggle(newState);
if (newState) {
this.showHelp(this.labels.splitHelp, 'info');
}
} else {
// Internal handling
if (this.isSplitDrawing) {
this._cancelSplit();
} else {
this._startSplit();
}
}
}
/**
* Start split drawing
*/
_startSplit() {
this.isSplitDrawing = true;
this.buttons.split.classList.add('active');
// Switch to line drawing mode
this.draw.changeMode('draw_line_string');
// Show help message
this._showMessage(this.labels.splitHelp, 'info');
}
/**
* Cancel split drawing
*/
_cancelSplit() {
// Clean up temp line
if (this.splitLineFeatureId && this.draw.get(this.splitLineFeatureId)) {
try {
this.draw.delete(this.splitLineFeatureId);
} catch (err) {}
}
this.isSplitDrawing = false;
this.splitLineFeatureId = null;
this.buttons.split.classList.remove('active');
// Return to edit mode
const drawFeatures = this.draw.getAll().features;
if (drawFeatures.length > 0) {
this.draw.changeMode('edit', { featureIds: drawFeatures.map(f => f.id) });
} else {
this.draw.changeMode('edit');
}
this._showMessage(this.labels.splitCancelled, 'info');
}
/**
* Perform merge of selected polygons
*/
performMerge() {
// If callback provided, let the page handle merge logic
if (this.callbacks.onMerge) {
this.callbacks.onMerge();
return;
}
// Internal handling
const drawFeatures = this.draw.getAll().features;
const polygons = drawFeatures.filter(f =>
f.geometry && (f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon')
);
if (polygons.length < 2) {
this._showMessage('Select at least 2 polygons to merge', 'warning');
return;
}
try {
// Use turf to merge polygons
let merged = polygons[0];
for (let i = 1; i < polygons.length; i++) {
merged = turf.union(
turf.feature(merged.geometry),
turf.feature(polygons[i].geometry)
);
}
if (!merged || !merged.geometry) {
throw new Error('Union failed');
}
// Use the first polygon's ID and properties
const primaryId = polygons[0].id;
const primaryProps = polygons[0].properties || {};
// Track deletion for all other polygons
for (let i = 1; i < polygons.length; i++) {
const polyId = polygons[i].id;
this._addToHistory({
type: 'merge_delete',
featureId: polyId,
geometry: polygons[i].geometry,
properties: polygons[i].properties
});
// Mark for deletion
this.modifiedGeometries.set(polyId, {
assetId: polyId,
assetType: this.layerConfig.assetType,
originalGeometry: this.loadedAssets.get(polyId)?.geometry || polygons[i].geometry,
currentGeometry: null,
isDeleted: true
});
// Remove from draw
this.draw.delete(polyId);
// Remove from loadedAssets
this.loadedAssets.delete(polyId);
}
// Update primary polygon with merged geometry
this._addToHistory({
type: 'merge_update',
featureId: primaryId,
geometry: polygons[0].geometry,
newGeometry: merged.geometry
});
// Delete and re-add with new geometry
this.draw.delete(primaryId);
this.draw.add({
id: primaryId,
type: 'Feature',
geometry: merged.geometry,
properties: primaryProps
});
// Update loadedAssets
const primaryAsset = this.loadedAssets.get(primaryId);
if (primaryAsset) {
primaryAsset.geometry = merged.geometry;
}
// Track modification
this.modifiedGeometries.set(primaryId, {
assetId: primaryId,
assetType: this.layerConfig.assetType,
originalGeometry: primaryAsset?.geometry || polygons[0].geometry,
currentGeometry: merged.geometry,
modelId: primaryProps.model_id || primaryProps.modelId || null
});
this._updateMapSource();
this._updateModifiedCount();
this._updateSplitMergeState();
this._showMessage(`${this.labels.mergeSuccess}: ${polygons.length}`, 'success');
} catch (error) {
this._showMessage(this.labels.mergeError, 'danger');
}
}
/**
* Update split/merge button states
*/
_updateSplitMergeState() {
if (!this.isDrawMode && !(this.callbacks.getIsDrawMode && this.callbacks.getIsDrawMode())) {
this.buttons.split.disabled = true;
this.buttons.merge.disabled = true;
return;
}
// Use callback to get features if provided
let drawFeatures;
if (this.callbacks.getDrawFeatures) {
drawFeatures = this.callbacks.getDrawFeatures();
} else {
drawFeatures = this.draw.getAll().features;
}
const polygonCount = drawFeatures.filter(f =>
f.geometry && (f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon')
).length;
// Update delete button
this.buttons.delete.disabled = polygonCount < 1;
// Split needs draw mode to be active
const isSplitDrawing = this.callbacks.getIsSplitDrawing
? this.callbacks.getIsSplitDrawing()
: this.isSplitDrawing;
this.buttons.split.disabled = false;
// Merge needs at least 2 polygons
this.buttons.merge.disabled = polygonCount < 2;
}
/**
* Update delete button state
* @param {boolean} enabled - Whether the button should be enabled
*/
updateDeleteState(enabled) {
if (this.buttons.delete) {
this.buttons.delete.disabled = !enabled;
}
}
/**
* Update split button state
* @param {boolean} enabled - Whether the button should be enabled
*/
updateSplitState(enabled) {
if (this.buttons.split) {
this.buttons.split.disabled = !enabled;
// Also update active class based on split drawing state
const isSplitDrawing = this.callbacks.getIsSplitDrawing
? this.callbacks.getIsSplitDrawing()
: this.isSplitDrawing;
this.buttons.split.classList.toggle('active', isSplitDrawing);
}
}
/**
* Update merge button state
* @param {boolean} enabled - Whether the button should be enabled
*/
updateMergeState(enabled) {
if (this.buttons.merge) {
this.buttons.merge.disabled = !enabled;
}
}
/**
* Handle draw create event
*/
_handleDrawCreate(e) {
const features = e.features || [];
// Check if we're in split drawing mode (use callback if provided, otherwise internal state)
const isSplitDrawing = this.callbacks.getIsSplitDrawing
? this.callbacks.getIsSplitDrawing()
: this.isSplitDrawing;
// Check if this is a split line
const splitLine = features.find(f =>
isSplitDrawing && f.geometry && f.geometry.type === 'LineString'
);
if (splitLine) {
// If onSplitToggle callback is provided, the page handles split logic itself
// via its own draw.create handler, so we don't duplicate the split here
if (this.callbacks.onSplitToggle) {
// Page handles split - don't do anything here
return;
}
this._performSplit(splitLine);
return;
}
// Process new polygon(s)
features.forEach(feature => {
if (feature.geometry.type !== 'Polygon' && feature.geometry.type !== 'MultiPolygon') {
return;
}
const tempId = 'new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const originalId = feature.id;
// Replace with new ID
this.draw.delete(originalId);
this.draw.add({
id: tempId,
type: 'Feature',
geometry: feature.geometry,
properties: {
isNew: true,
asset_type: this.layerConfig.assetType
}
});
// Track as new geometry
this.modifiedGeometries.set(tempId, {
assetId: tempId,
assetType: this.layerConfig.assetType,
originalGeometry: null,
currentGeometry: feature.geometry,
isNew: true
});
// Add to loaded assets
this.loadedAssets.set(tempId, {
id: tempId,
type: 'Feature',
geometry: feature.geometry,
properties: { isNew: true, asset_type: this.layerConfig.assetType }
});
// Track in history
this._addToHistory({
type: 'create',
featureId: tempId,
geometry: feature.geometry
});
this.currentEditingAsset = {
id: tempId,
assetType: this.layerConfig.assetType
};
// Switch to edit mode
setTimeout(() => {
this.draw.changeMode('edit', { featureIds: [tempId] });
}, 10);
});
this._updateMapSource();
this._updateModifiedCount();
if (this.callbacks.onGeometryCreate) {
this.callbacks.onGeometryCreate(features);
}
}
/**
* Handle draw update event
*/
_handleDrawUpdate(e) {
const features = e.features || [];
features.forEach(feature => {
const featureId = feature.id;
// Get the original geometry (normalized by draw when added)
const originalGeometry = this.originalGeometries.get(featureId);
const currentGeometry = feature.geometry;
// Check if geometry actually changed
const geometryChanged = this._geometriesAreDifferent(originalGeometry, currentGeometry);
if (!geometryChanged) {
// No actual change, skip this feature
return;
}
const existingMod = this.modifiedGeometries.get(featureId);
// Track in history
this._addToHistory({
type: 'update',
featureId: featureId,
geometry: existingMod?.currentGeometry || originalGeometry || feature.geometry,
newGeometry: feature.geometry
});
// Update modification tracking
this.modifiedGeometries.set(featureId, {
assetId: featureId,
assetType: this.layerConfig.assetType,
originalGeometry: originalGeometry || existingMod?.originalGeometry || null,
currentGeometry: feature.geometry,
isNew: String(featureId).startsWith('new_')
});
// Update loaded assets
const asset = this.loadedAssets.get(featureId);
if (asset) {
asset.geometry = feature.geometry;
}
});
this._updateMapSource();
this._updateModifiedCount();
if (this.callbacks.onGeometryUpdate) {
this.callbacks.onGeometryUpdate(features);
}
}
/**
* Handle draw delete event
*/
_handleDrawDelete(e) {
const features = e.features || [];
features.forEach(feature => {
// Callback already handled in deleteSelected
});
}
/**
* Handle selection change event
*/
_handleSelectionChange(e) {
const selectedIds = e.features ? e.features.map(f => f.id) : [];
this.lastSelectedFeatureIds = selectedIds;
this._updateSplitMergeState();
if (this.callbacks.onSelectionChange) {
// Pass full features with properties, not just IDs
const selectedFeatures = selectedIds.map(id => {
// Try to get from draw first (has current geometry)
const drawFeature = this.draw.get(id);
// Merge with loadedAssets to get original properties
const loadedAsset = this.loadedAssets.get(id);
if (drawFeature) {
return {
id: id,
type: 'Feature',
geometry: drawFeature.geometry,
properties: {
...(loadedAsset?.properties || {}),
...(drawFeature.properties || {})
}
};
} else if (loadedAsset) {
return loadedAsset;
}
return { id: id, type: 'Feature', geometry: null, properties: {} };
}).filter(f => f.geometry);
this.callbacks.onSelectionChange(selectedFeatures, selectedIds);
}
}
/**
* Handle mode change event
*/
_handleModeChange(e) {
// Cancel split if mode changed away from line drawing
if (this.isSplitDrawing && e.mode !== 'draw_line_string') {
this.isSplitDrawing = false;
this.splitLineFeatureId = null;
this.buttons.split.classList.remove('active');
}
this._updateSplitMergeState();
}
/**
* Handle tap on a feature in CustomDraw (for touch deselection)
*/
_handleFeatureTap(e) {
if (!this.isDrawMode) {
return;
}
// Set flag to prevent click handler from also processing this
this._justHandledFeatureTap = true;
setTimeout(() => {
this._justHandledFeatureTap = false;
}, 300); // Clear after a short delay
// Deselect the tapped feature
if (e.featureId) {
this._removeFeatureFromEditKeepModifications(e.featureId);
}
}
/**
* Perform polygon split with a line
*/
_performSplit(lineFeature) {
const drawFeatures = this.draw.getAll().features;
const polygonsById = new Map();
// Polygons currently in draw (editing)
drawFeatures.forEach(f => {
if (f.id === lineFeature.id) return;
if (!f.geometry) return;
if (f.geometry.type !== 'Polygon' && f.geometry.type !== 'MultiPolygon') return;
polygonsById.set(f.id, {
feature: f,
inDraw: true
});
});
// Polygons from loaded assets (visible source)
this.loadedAssets.forEach((asset, assetId) => {
if (!asset?.geometry) return;
if (asset.geometry.type !== 'Polygon' && asset.geometry.type !== 'MultiPolygon') return;
if (!polygonsById.has(assetId)) {
polygonsById.set(assetId, {
feature: asset,
inDraw: false
});
}
});
const polygons = Array.from(polygonsById.values());
let splitCount = 0;
polygons.forEach(entry => {
const polygon = entry.feature;
const inDraw = entry.inDraw;
// Check if line intersects polygon
const intersects = this._geometryIntersectsLine(polygon.geometry, lineFeature.geometry.coordinates);
if (!intersects) {
return;
}
// Perform split
const result = this._splitPolygonWithLine(polygon.geometry, lineFeature.geometry);
if (result && result.length >= 2) {
// Track original in history
this._addToHistory({
type: 'split',
featureId: polygon.id,
geometry: polygon.geometry,
splitResults: result
});
// Delete original from draw if present
if (inDraw) {
this.draw.delete(polygon.id);
}
// Always add first split result to draw control with original ID
// so user can see and edit the split result
this.draw.add({
id: polygon.id,
type: 'Feature',
geometry: result[0],
properties: polygon.properties || {}
});
// Update tracking for original
this.modifiedGeometries.set(polygon.id, {
assetId: polygon.id,
assetType: this.layerConfig.assetType,
originalGeometry: this.loadedAssets.get(polygon.id)?.geometry || polygon.geometry,
currentGeometry: result[0]
});
// Update loaded assets for original
const origAsset = this.loadedAssets.get(polygon.id);
if (origAsset) {
origAsset.geometry = result[0];
} else {
// If not already in loadedAssets, add it so map updates
this.loadedAssets.set(polygon.id, {
id: polygon.id,
type: 'Feature',
geometry: result[0],
properties: polygon.properties || {}
});
}
// Hide original feature on the map layer since it's now in draw
if (!inDraw) {
this.hiddenFeatures.add(polygon.id);
}
// Add remaining split results with new IDs
// Always add to draw control so user can see and edit them
for (let i = 1; i < result.length; i++) {
const newId = 'new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Copy all properties from original polygon (including color, model_id, etc.)
const newProperties = {
...polygon.properties,
isNew: true,
id: newId // Update the id property
};
// Always add to draw control
this.draw.add({
id: newId,
type: 'Feature',
geometry: result[i],
properties: newProperties
});
this.modifiedGeometries.set(newId, {
assetId: newId,
assetType: this.layerConfig.assetType,
originalGeometry: null,
currentGeometry: result[i],
modelId: polygon.properties?.model_id || null,
properties: newProperties,
isNew: true
});
this.loadedAssets.set(newId, {
id: newId,
type: 'Feature',
geometry: result[i],
properties: newProperties // Use the same properties with all attributes
});
// Hide from map source since it's now in draw control
this.hiddenFeatures.add(newId);
}
splitCount++;
}
});
if (splitCount > 0) {
// Delete the split line after successful split
this.draw.delete(lineFeature.id);
// Reset split mode
this.isSplitDrawing = false;
this.buttons.split.classList.remove('active');
// Get all features after split
const allFeatures = this.draw.getAll().features;
if (allFeatures.length > 0) {
// Use 'edit' mode which allows editing multiple features with all vertices visible
// This is the mode that shows all polygons with their vertices
this.draw.changeMode('edit', { featureIds: allFeatures.map(f => f.id) });
}
this._updateMapSource();
this._updateModifiedCount();
this._updateSplitMergeState();
this._showMessage(`${this.labels.splitSuccess}: ${splitCount}`, 'success');
} else {
// No split performed - remove line and keep split mode active for retry
this.draw.delete(lineFeature.id);
this._showMessage(this.labels.splitCancelled || 'No polygons were split', 'warning');
}
}
/**
* Check if geometry intersects a line (requires at least 2 intersection points for split)
*/
_geometryIntersectsLine(geometry, lineCoords) {
if (window.turf && window.turf.polygonToLine && window.turf.lineIntersect) {
try {
// Handle MultiPolygon by checking each polygon
if (geometry.type === 'MultiPolygon') {
for (const polyCoords of geometry.coordinates) {
const polygon = turf.polygon(polyCoords);
const line = turf.lineString(lineCoords);
const boundary = turf.polygonToLine(polygon);
const intersections = turf.lineIntersect(boundary, line);
// Need at least 2 intersections to split
if (intersections && intersections.features && intersections.features.length >= 2) {
return true;
}
// If line is inside polygon but doesn't cross boundary, extend it
if (turf.booleanContains && turf.booleanContains(polygon, line)) {
return true;
}
}
return false;
}
// Handle Polygon
const polygon = turf.feature(geometry);
const line = turf.lineString(lineCoords);
const boundary = turf.polygonToLine(polygon);
const intersections = turf.lineIntersect(boundary, line);
// Need at least 2 intersections to split
if (intersections && intersections.features && intersections.features.length >= 2) {
return true;
}
// If line is inside polygon but doesn't cross boundary, extend it
if (turf.booleanContains && turf.booleanContains(polygon, line)) {
// Line is entirely inside - we need to extend it
return true; // Will be handled by extending the line in split function
}
return false;
} catch (err) {
// Fall back to manual checks
}
}
if (geometry.type === 'Polygon') {
return this._polygonIntersectsLine(geometry.coordinates[0], lineCoords);
} else if (geometry.type === 'MultiPolygon') {
return geometry.coordinates.some(poly =>
this._polygonIntersectsLine(poly[0], lineCoords)
);
}
return false;
}
/**
* Check if polygon ring intersects line
*/
_polygonIntersectsLine(ring, lineCoords) {
for (let i = 0; i < lineCoords.length - 1; i++) {
for (let j = 0; j < ring.length - 1; j++) {
if (this._lineSegmentIntersection(
lineCoords[i], lineCoords[i + 1],
ring[j], ring[j + 1]
)) {
return true;
}
}
}
return false;
}
/**
* Line segment intersection
*/
_lineSegmentIntersection(p1, p2, p3, p4) {
const x1 = p1[0], y1 = p1[1];
const x2 = p2[0], y2 = p2[1];
const x3 = p3[0], y3 = p3[1];
const x4 = p4[0], y4 = p4[1];
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (Math.abs(denom) < 1e-10) return null;
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
return [x1 + t * (x2 - x1), y1 + t * (y2 - y1)];
}
return null;
}
/**
* Split polygon with line (simplified algorithm)
*/
_splitPolygonWithLine(polygonGeometry, lineGeometry) {
// Handle MultiPolygon by splitting each polygon separately
if (polygonGeometry.type === 'MultiPolygon') {
// For now, just try to split the first polygon that intersects the line
// TODO: Handle all polygons in the MultiPolygon
for (let i = 0; i < polygonGeometry.coordinates.length; i++) {
const singlePolygon = { type: 'Polygon', coordinates: polygonGeometry.coordinates[i] };
const result = this._splitPolygonWithLine(singlePolygon, lineGeometry);
if (result && result.length >= 2) {
return result;
}
}
return null;
}
let lineCoords = lineGeometry.coordinates;
// Check if line needs to be extended (if it's inside the polygon)
if (window.turf) {
try {
const polygon = turf.feature(polygonGeometry);
const line = turf.lineString(lineCoords);
const boundary = turf.polygonToLine(polygon);
const intersections = turf.lineIntersect(boundary, line);
// If less than 2 intersections, extend the line
if (intersections.features.length < 2) {
// Calculate extension distance based on polygon size
const bbox = turf.bbox(polygon);
const diag = Math.sqrt(
Math.pow(bbox[2] - bbox[0], 2) +
Math.pow(bbox[3] - bbox[1], 2)
);
const extension = diag * 2; // Extend by twice the diagonal
lineCoords = this._extendLine(lineCoords, extension);
// Check intersections again
const extendedLine = turf.lineString(lineCoords);
const newIntersections = turf.lineIntersect(boundary, extendedLine);
if (newIntersections.features.length < 2) {
return null;
}
}
} catch (e) {
// Silent fail
}
}
// Update lineGeometry with potentially extended coordinates
const extendedLineGeometry = { type: 'LineString', coordinates: lineCoords };
// DISABLED: turf.polygonSplit includes all line vertices, not just intermediate ones
// We use our custom algorithm instead to respect the rule:
// "For line ABCDE with intersections at B and D, only use segment BCD, not A or E"
/*
if (window.turf && window.turf.polygonSplit) {
try {
const polygon = turf.feature(polygonGeometry);
const line = turf.feature(extendedLineGeometry);
const split = turf.polygonSplit(polygon, line);
if (split && split.features && split.features.length > 1) {
return split.features.map(f => f.geometry);
}
} catch (e) {
// Silent fail
}
}
*/
// DISABLED: The polygonize/lineSplit approach is too complex and doesn't build polygons correctly
// We use our robust manual algorithm instead
/*
// Fallback using polygonize: split boundary with line, polygonize, then filter
if (window.turf && window.turf.polygonToLine && window.turf.polygonize) {
try {
const polygon = turf.feature(polygonGeometry);
const line = turf.feature(lineGeometry);
// Extend the line beyond the polygon to ensure it crosses completely
const extendedLine = this._extendLine(lineGeometry.coordinates, 0.001);
const extLine = turf.lineString(extendedLine);
const boundary = turf.polygonToLine(polygon);
// Use lineSplit to split the boundary with the extended line
if (window.turf.lineSplit) {
const splitBoundary = turf.lineSplit(boundary, extLine);
if (splitBoundary.features.length >= 2) {
// Find intersection points
const intersections = turf.lineIntersect(boundary, extLine);
if (intersections.features.length >= 2) {
// Build polygons manually from the segments
const result = this._buildPolygonsFromSplit(
splitBoundary.features,
intersections.features,
lineCoords,
polygon
);
if (result && result.length >= 2) {
return result;
}
}
}
}
const lines = turf.featureCollection([boundary, extLine]);
const polys = turf.polygonize(lines);
if (polys && polys.features && polys.features.length > 1) {
const results = polys.features.filter(f => {
try {
const center = turf.centroid(f);
return turf.booleanPointInPolygon(center, polygon);
} catch (err) {
return false;
}
});
if (results.length > 1) {
return results.map(f => f.geometry);
}
}
} catch (e) {
}
}
*/
// Manual split approach for simple cases
const ring = polygonGeometry.coordinates[0];
// Use the potentially extended line coordinates
const manualLineCoords = lineCoords;
// Use turf.lineIntersect if available for more robust intersection detection
let intersections = [];
if (window.turf && window.turf.lineIntersect) {
try {
const polygonBoundary = turf.lineString([...ring]);
const splitLine = turf.lineString(manualLineCoords);
const intersectResult = turf.lineIntersect(polygonBoundary, splitLine);
if (intersectResult.features.length >= 2) {
// Calculate cumulative distances along the line for each vertex
const cumulativeDistances = [0];
for (let i = 1; i < manualLineCoords.length; i++) {
const dx = manualLineCoords[i][0] - manualLineCoords[i-1][0];
const dy = manualLineCoords[i][1] - manualLineCoords[i-1][1];
const segmentLength = Math.sqrt(dx * dx + dy * dy);
cumulativeDistances.push(cumulativeDistances[i-1] + segmentLength);
}
// Convert turf results to our format
intersections = intersectResult.features.map((f, i) => {
const pt = f.geometry.coordinates;
// Find which edge this intersection is on (polygon edge)
let edgeIndex = 0;
let minDist = Infinity;
for (let j = 0; j < ring.length - 1; j++) {
const dist = this._distanceToSegment(pt, ring[j], ring[j + 1]);
if (dist < minDist) {
minDist = dist;
edgeIndex = j;
}
}
// Find which segment of the line this intersection is on and calculate distance along line
let lineIndex = 0;
let distanceAlongLine = 0;
minDist = Infinity;
for (let k = 0; k < manualLineCoords.length - 1; k++) {
const dist = this._distanceToSegment(pt, manualLineCoords[k], manualLineCoords[k + 1]);
if (dist < minDist) {
minDist = dist;
lineIndex = k;
// Calculate distance from start of this segment to the intersection
const dx = pt[0] - manualLineCoords[k][0];
const dy = pt[1] - manualLineCoords[k][1];
const distFromSegmentStart = Math.sqrt(dx * dx + dy * dy);
distanceAlongLine = cumulativeDistances[k] + distFromSegmentStart;
}
}
return {
coords: pt,
edgeIndex: edgeIndex,
lineIndex: lineIndex,
distance: distanceAlongLine
};
});
intersections.sort((a, b) => a.distance - b.distance);
}
} catch (e) {
}
}
// Fallback to manual intersection detection
if (intersections.length < 2) {
intersections = this._findIntersectionPoints(ring, manualLineCoords);
}
if (intersections.length > 0) {
}
if (intersections.length > 1) {
}
if (intersections.length < 2) {
return null;
}
// Build split polygons using original line coordinates (not extended) for better geometry
const result = this._buildSplitPolygons(ring, intersections, manualLineCoords);
return result;
}
/**
* Calculate distance from point to line segment
*/
_distanceToSegment(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length2 = dx * dx + dy * dy;
if (length2 === 0) {
return Math.sqrt(Math.pow(p[0] - a[0], 2) + Math.pow(p[1] - a[1], 2));
}
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2;
t = Math.max(0, Math.min(1, t));
const closestX = a[0] + t * dx;
const closestY = a[1] + t * dy;
return Math.sqrt(Math.pow(p[0] - closestX, 2) + Math.pow(p[1] - closestY, 2));
}
/**
* Find intersection points between polygon ring and line
*/
_findIntersectionPoints(ring, lineCoords) {
const intersections = [];
// First, calculate cumulative distances along the line for each vertex
const cumulativeDistances = [0];
for (let i = 1; i < lineCoords.length; i++) {
const dx = lineCoords[i][0] - lineCoords[i-1][0];
const dy = lineCoords[i][1] - lineCoords[i-1][1];
const segmentLength = Math.sqrt(dx * dx + dy * dy);
cumulativeDistances.push(cumulativeDistances[i-1] + segmentLength);
}
for (let i = 0; i < lineCoords.length - 1; i++) {
for (let j = 0; j < ring.length - 1; j++) {
const intersection = this._lineSegmentIntersection(
lineCoords[i], lineCoords[i + 1],
ring[j], ring[j + 1]
);
if (intersection) {
// Calculate distance along the line to this intersection point
// = cumulative distance to segment start + distance from segment start to intersection
const dx = intersection[0] - lineCoords[i][0];
const dy = intersection[1] - lineCoords[i][1];
const distFromSegmentStart = Math.sqrt(dx * dx + dy * dy);
const distanceAlongLine = cumulativeDistances[i] + distFromSegmentStart;
intersections.push({
coords: intersection,
edgeIndex: j,
lineIndex: i,
distance: distanceAlongLine
});
}
}
}
// Sort by distance along line
intersections.sort((a, b) => a.distance - b.distance);
// Remove duplicates
const unique = [];
for (const int of intersections) {
const isDupe = unique.some(u =>
Math.abs(u.coords[0] - int.coords[0]) < 1e-9 &&
Math.abs(u.coords[1] - int.coords[1]) < 1e-9
);
if (!isDupe) unique.push(int);
}
return unique;
}
/**
* Build split polygons from intersection data
*/
_buildSplitPolygons(ring, intersections, lineCoords) {
if (intersections.length < 2) return null;
const entry = intersections[0];
const exit = intersections[intersections.length - 1];
// Insert intersection points into ring
const augmentedRing = [];
const insertions = new Map();
// Track insertions by edge
for (const int of [entry, exit]) {
if (!insertions.has(int.edgeIndex)) {
insertions.set(int.edgeIndex, []);
}
insertions.get(int.edgeIndex).push(int);
}
// Build augmented ring
for (let i = 0; i < ring.length - 1; i++) {
augmentedRing.push(ring[i]);
if (insertions.has(i)) {
const edgeInserts = insertions.get(i);
// Sort by distance from edge start
edgeInserts.sort((a, b) => {
const distA = Math.sqrt(
Math.pow(a.coords[0] - ring[i][0], 2) +
Math.pow(a.coords[1] - ring[i][1], 2)
);
const distB = Math.sqrt(
Math.pow(b.coords[0] - ring[i][0], 2) +
Math.pow(b.coords[1] - ring[i][1], 2)
);
return distA - distB;
});
edgeInserts.forEach(ins => augmentedRing.push(ins.coords));
}
}
augmentedRing.push(ring[ring.length - 1]);
// Find indices in augmented ring with larger tolerance
const tolerance = 1e-6; // Increased tolerance
let entryIdx = -1, exitIdx = -1;
for (let i = 0; i < augmentedRing.length; i++) {
if (entryIdx === -1 &&
Math.abs(augmentedRing[i][0] - entry.coords[0]) < tolerance &&
Math.abs(augmentedRing[i][1] - entry.coords[1]) < tolerance) {
entryIdx = i;
}
if (exitIdx === -1 &&
Math.abs(augmentedRing[i][0] - exit.coords[0]) < tolerance &&
Math.abs(augmentedRing[i][1] - exit.coords[1]) < tolerance) {
exitIdx = i;
}
}
if (entryIdx === -1 || exitIdx === -1 || entryIdx === exitIdx) {
// Fallback: insert intersection points directly based on edge index
return this._buildSplitPolygonsFallback(ring, entry, exit, lineCoords);
}
// Normalize order - track if we swapped
let idx1 = entryIdx, idx2 = exitIdx;
let swapped = false;
if (idx1 > idx2) {
[idx1, idx2] = [idx2, idx1];
swapped = true;
}
// Extract only the line segment between the two intersection points
// The intersections array contains entry and exit points sorted by distance along the line
// We need to find which points from lineCoords are actually between these intersections
const lineSegment = this._extractLineSegmentBetweenIntersections(lineCoords, entry, exit);
// Build first polygon: from idx1 to idx2 along the ring
const poly1 = [];
for (let i = idx1; i <= idx2; i++) {
poly1.push(augmentedRing[i]);
}
// Add line segment to close - direction depends on whether we swapped
// If not swapped: idx1=entry, idx2=exit, so line goes entry->segment->exit
// To go from idx2 back to idx1, traverse segment in reverse
// If swapped: idx1=exit, idx2=entry, so line goes exit<-segment<-entry
// To go from idx2 back to idx1, traverse segment forward
if (!swapped) {
for (let i = lineSegment.length - 1; i >= 0; i--) {
poly1.push(lineSegment[i]);
}
} else {
for (const pt of lineSegment) {
poly1.push(pt);
}
}
if (poly1[0][0] !== poly1[poly1.length - 1][0] ||
poly1[0][1] !== poly1[poly1.length - 1][1]) {
poly1.push(poly1[0]);
}
// Build second polygon: from idx2 around the ring back to idx1
const poly2 = [];
for (let i = idx2; i < augmentedRing.length; i++) {
poly2.push(augmentedRing[i]);
}
for (let i = 0; i <= idx1; i++) {
poly2.push(augmentedRing[i]);
}
// Add line segment to close - opposite direction from poly1
if (!swapped) {
for (const pt of lineSegment) {
poly2.push(pt);
}
} else {
for (let i = lineSegment.length - 1; i >= 0; i--) {
poly2.push(lineSegment[i]);
}
}
if (poly2[0][0] !== poly2[poly2.length - 1][0] ||
poly2[0][1] !== poly2[poly2.length - 1][1]) {
poly2.push(poly2[0]);
}
if (poly1.length < 4 || poly2.length < 4) {
return this._buildSplitPolygonsFallback(ring, entry, exit, lineCoords);
}
return [
{ type: 'Polygon', coordinates: [poly1] },
{ type: 'Polygon', coordinates: [poly2] }
];
}
/**
* Fallback method to build split polygons directly from edge indices
*/
_buildSplitPolygonsFallback(ring, entry, exit, lineCoords) {
// Get edge indices sorted
let edge1 = entry.edgeIndex;
let edge2 = exit.edgeIndex;
let int1 = entry.coords;
let int2 = exit.coords;
let actualEntry = entry;
let actualExit = exit;
// Make sure edge1 < edge2 (swap if needed)
if (edge1 > edge2) {
[edge1, edge2] = [edge2, edge1];
[int1, int2] = [int2, int1];
[actualEntry, actualExit] = [actualExit, actualEntry];
}
// Extract only the intermediate points between the two intersection points
// Use the original entry/exit (not swapped) so we get correct distance-based extraction
const lineSegment = this._extractLineSegmentBetweenIntersections(lineCoords, entry, exit);
// Build first polygon: from int1, through edges edge1+1 to edge2, to int2, then line back
const poly1 = [int1];
for (let i = edge1 + 1; i <= edge2; i++) {
poly1.push(ring[i]);
}
poly1.push(int2);
// Add intermediate line points in correct order based on which intersection comes first along the line
// If int1 corresponds to entry (first intersection along line), we need to add lineSegment in reverse
// If int1 corresponds to exit (second intersection along line), we add lineSegment forward
if (actualEntry === entry) {
// int1 = entry, int2 = exit, so line goes entry -> lineSegment -> exit
// To go from int2 back to int1, we traverse: lineSegment in reverse
for (let i = lineSegment.length - 1; i >= 0; i--) {
poly1.push(lineSegment[i]);
}
} else {
// int1 = exit, int2 = entry, so line goes exit <- lineSegment <- entry
// To go from int2 back to int1, we traverse: lineSegment forward
for (const pt of lineSegment) {
poly1.push(pt);
}
}
poly1.push(int1); // close
// Build second polygon: from int2, through remaining edges back to int1, then line
const poly2 = [int2];
for (let i = edge2 + 1; i < ring.length - 1; i++) {
poly2.push(ring[i]);
}
for (let i = 0; i <= edge1; i++) {
poly2.push(ring[i]);
}
poly2.push(int1);
// Add intermediate line points in the opposite order from poly1
if (actualEntry === entry) {
// int1 = entry, int2 = exit
// To go from int1 back to int2, we traverse: lineSegment forward
for (const pt of lineSegment) {
poly2.push(pt);
}
} else {
// int1 = exit, int2 = entry
// To go from int1 back to int2, we traverse: lineSegment in reverse
for (let i = lineSegment.length - 1; i >= 0; i--) {
poly2.push(lineSegment[i]);
}
}
poly2.push(int2); // close
if (poly1.length < 4 || poly2.length < 4) {
return null;
}
// Validate the polygons with turf if available
if (window.turf) {
try {
const p1 = turf.polygon([poly1]);
const p2 = turf.polygon([poly2]);
return [p1.geometry, p2.geometry];
} catch (e) {
// Try to clean up the polygons
try {
// Clean up any self-intersections using turf.cleanCoords
const cleanPoly1 = turf.cleanCoords(turf.lineString(poly1)).geometry.coordinates;
const cleanPoly2 = turf.cleanCoords(turf.lineString(poly2)).geometry.coordinates;
// Close the rings
if (cleanPoly1[0][0] !== cleanPoly1[cleanPoly1.length-1][0] ||
cleanPoly1[0][1] !== cleanPoly1[cleanPoly1.length-1][1]) {
cleanPoly1.push(cleanPoly1[0]);
}
if (cleanPoly2[0][0] !== cleanPoly2[cleanPoly2.length-1][0] ||
cleanPoly2[0][1] !== cleanPoly2[cleanPoly2.length-1][1]) {
cleanPoly2.push(cleanPoly2[0]);
}
if (cleanPoly1.length >= 4 && cleanPoly2.length >= 4) {
return [
{ type: 'Polygon', coordinates: [cleanPoly1] },
{ type: 'Polygon', coordinates: [cleanPoly2] }
];
}
} catch (e2) {
}
}
}
// Return raw polygons as last resort
return [
{ type: 'Polygon', coordinates: [poly1] },
{ type: 'Polygon', coordinates: [poly2] }
];
}
/**
* Extract the portion of the line that is between the two intersection points.
* Given a line ABCDE where B and D are intersection points with the polygon,
* this returns only the points [C] (points strictly between B and D).
* The intersection points themselves are already added as int1/int2.
*
* @param {Array} lineCoords - Full line coordinates
* @param {Object} entry - First intersection {coords, distance, lineIndex}
* @param {Object} exit - Last intersection {coords, distance, lineIndex}
* @returns {Array} - Array of coordinates between the two intersections
*/
_extractLineSegmentBetweenIntersections(lineCoords, entry, exit) {
if (!lineCoords || lineCoords.length < 2) return [];
// Special case: if line has only 2 points, there are no intermediate points
if (lineCoords.length === 2) {
return [];
}
// The intersections have a 'distance' property (distance from line start)
// and possibly a 'lineIndex' property (which segment the intersection is on)
const entryDist = entry.distance !== undefined ? entry.distance : 0;
const exitDist = exit.distance !== undefined ? exit.distance : 0;
// If distances are not set or equal, fall back to using lineIndex
if (entryDist === 0 && exitDist === 0) {
// Use lineIndex to determine which points are between intersections
const entryIdx = entry.lineIndex !== undefined ? entry.lineIndex : 0;
const exitIdx = exit.lineIndex !== undefined ? exit.lineIndex : lineCoords.length - 1;
let startIdx = Math.min(entryIdx, exitIdx);
let endIdx = Math.max(entryIdx, exitIdx);
// If both intersections are on the same segment (e.g., both have lineIndex=0),
// there are no intermediate points
if (startIdx === endIdx) {
return [];
}
// Return only vertices strictly between the two intersection segments
// For a line A-B-C-D-E with intersections on segments 1 (B-C) and 3 (D-E),
// we want to include only C and D, not B or E
const intermediatePoints = [];
for (let i = startIdx + 1; i < endIdx + 1; i++) {
if (i < lineCoords.length) {
intermediatePoints.push(lineCoords[i]);
}
}
return intermediatePoints;
}
// Make sure we have the correct order (entry should have smaller distance)
let startDist = entryDist;
let endDist = exitDist;
let startCoords = entry.coords;
let endCoords = exit.coords;
if (startDist > endDist) {
[startDist, endDist] = [endDist, startDist];
[startCoords, endCoords] = [endCoords, startCoords];
}
// Calculate cumulative distances along the line for each vertex
const cumulativeDistances = [0];
for (let i = 1; i < lineCoords.length; i++) {
const dx = lineCoords[i][0] - lineCoords[i-1][0];
const dy = lineCoords[i][1] - lineCoords[i-1][1];
const segmentLength = Math.sqrt(dx * dx + dy * dy);
cumulativeDistances.push(cumulativeDistances[i-1] + segmentLength);
}
// Find all line vertices that fall between the two intersection points
// A vertex is "between" if its cumulative distance is > startDist and < endDist
const intermediatePoints = [];
for (let i = 0; i < lineCoords.length; i++) {
const vertexDist = cumulativeDistances[i];
// Use a small tolerance for comparisons
const tolerance = 1e-9;
if (vertexDist > startDist + tolerance && vertexDist < endDist - tolerance) {
intermediatePoints.push(lineCoords[i]);
}
}
return intermediatePoints;
}
/**
* Extend a line by a given amount at both ends
*/
_extendLine(coords, extension) {
if (coords.length < 2) return coords;
const result = [...coords];
// Extend start
const dx1 = coords[0][0] - coords[1][0];
const dy1 = coords[0][1] - coords[1][1];
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
if (len1 > 0) {
result[0] = [
coords[0][0] + (dx1 / len1) * extension,
coords[0][1] + (dy1 / len1) * extension
];
}
// Extend end
const last = coords.length - 1;
const dx2 = coords[last][0] - coords[last - 1][0];
const dy2 = coords[last][1] - coords[last - 1][1];
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
if (len2 > 0) {
result[last] = [
coords[last][0] + (dx2 / len2) * extension,
coords[last][1] + (dy2 / len2) * extension
];
}
return result;
}
/**
* Build polygons from split boundary segments and intersection points
*/
_buildPolygonsFromSplit(segments, intersections, lineCoords, originalPolygon) {
try {
// Get intersection coordinates
const intPoints = intersections.map(f => f.geometry.coordinates);
if (intPoints.length < 2) return null;
// Extract only intermediate points between intersections
// For a line ABCDE with intersections at B and D, we only want C
const entry = {
coords: intPoints[0],
distance: 0, // Will be calculated
lineIndex: 0
};
const exit = {
coords: intPoints[intPoints.length - 1],
distance: 0, // Will be calculated
lineIndex: lineCoords.length - 1
};
// Calculate distances for entry and exit
for (let i = 0; i < lineCoords.length; i++) {
const dx = lineCoords[i][0] - entry.coords[0];
const dy = lineCoords[i][1] - entry.coords[1];
const distEntry = Math.sqrt(dx * dx + dy * dy);
if (distEntry < 1e-6) {
entry.lineIndex = i;
break;
}
}
for (let i = 0; i < lineCoords.length; i++) {
const dx = lineCoords[i][0] - exit.coords[0];
const dy = lineCoords[i][1] - exit.coords[1];
const distExit = Math.sqrt(dx * dx + dy * dy);
if (distExit < 1e-6) {
exit.lineIndex = i;
break;
}
}
// Calculate cumulative distances
const cumulativeDistances = [0];
for (let i = 1; i < lineCoords.length; i++) {
const dx = lineCoords[i][0] - lineCoords[i-1][0];
const dy = lineCoords[i][1] - lineCoords[i-1][1];
const segmentLength = Math.sqrt(dx * dx + dy * dy);
cumulativeDistances.push(cumulativeDistances[i-1] + segmentLength);
}
entry.distance = cumulativeDistances[entry.lineIndex];
exit.distance = cumulativeDistances[exit.lineIndex];
// Extract intermediate points
const intermediatePoints = this._extractLineSegmentBetweenIntersections(lineCoords, entry, exit);
// Sort segments by starting point
const sortedSegments = segments.map(s => ({
coords: s.geometry.coordinates,
feature: s
}));
// Build segment including only: [entry] + intermediatePoints + [exit]
const lineSegmentForward = [entry.coords, ...intermediatePoints, exit.coords];
const lineSegmentReverse = [...lineSegmentForward].reverse();
// Try to build valid polygons
const poly1Coords = [];
const poly2Coords = [];
// Helper function to remove duplicate consecutive points
const removeDuplicates = (coords) => {
const result = [];
for (let i = 0; i < coords.length; i++) {
if (i === 0 ||
Math.abs(coords[i][0] - coords[i-1][0]) > 1e-9 ||
Math.abs(coords[i][1] - coords[i-1][1]) > 1e-9) {
result.push(coords[i]);
}
}
return result;
};
// Simple approach: find which segments are on which side
for (let i = 0; i < sortedSegments.length; i++) {
const seg = sortedSegments[i].coords;
// Just alternate for now (this is a simplified approach)
if (i % 2 === 0) {
// Add all points from segment except the last (to avoid duplication with next segment)
for (let j = 0; j < seg.length - 1; j++) {
poly1Coords.push(seg[j]);
}
} else {
for (let j = 0; j < seg.length - 1; j++) {
poly2Coords.push(seg[j]);
}
}
}
// For the line segment, only add the intermediate points (intersections are already in segments)
// Actually, we need to add the intersections + intermediate points to close the polygon
// But we need to be careful not to duplicate points that are already in the segments
// Add line segment to close each polygon (only intermediate points + intersections)
// Skip first and last point of lineSegment if they're already in the coords
const addLineSegment = (targetCoords, lineSegment) => {
for (const pt of lineSegment) {
// Check if this point is already at the end of targetCoords
const lastPt = targetCoords.length > 0 ? targetCoords[targetCoords.length - 1] : null;
if (!lastPt ||
Math.abs(pt[0] - lastPt[0]) > 1e-9 ||
Math.abs(pt[1] - lastPt[1]) > 1e-9) {
targetCoords.push(pt);
}
}
};
addLineSegment(poly1Coords, lineSegmentForward);
addLineSegment(poly2Coords, lineSegmentReverse);
// Remove any remaining duplicates
const cleanPoly1 = removeDuplicates(poly1Coords);
const cleanPoly2 = removeDuplicates(poly2Coords);
// Close the rings if not already closed
if (cleanPoly1.length >= 3) {
if (Math.abs(cleanPoly1[0][0] - cleanPoly1[cleanPoly1.length-1][0]) > 1e-9 ||
Math.abs(cleanPoly1[0][1] - cleanPoly1[cleanPoly1.length-1][1]) > 1e-9) {
cleanPoly1.push(cleanPoly1[0]);
}
}
if (cleanPoly2.length >= 3) {
if (Math.abs(cleanPoly2[0][0] - cleanPoly2[cleanPoly2.length-1][0]) > 1e-9 ||
Math.abs(cleanPoly2[0][1] - cleanPoly2[cleanPoly2.length-1][1]) > 1e-9) {
cleanPoly2.push(cleanPoly2[0]);
}
}
// Validate with turf
if (cleanPoly1.length >= 4 && cleanPoly2.length >= 4) {
try {
const p1 = turf.polygon([cleanPoly1]);
const p2 = turf.polygon([cleanPoly2]);
// Check if both centroids are inside original
const c1 = turf.centroid(p1);
const c2 = turf.centroid(p2);
if (turf.booleanPointInPolygon(c1, originalPolygon) &&
turf.booleanPointInPolygon(c2, originalPolygon)) {
return [p1.geometry, p2.geometry];
}
} catch (e) {
}
}
return null;
} catch (e) {
return null;
}
}
/**
* Add action to history for undo
*/
_addToHistory(action) {
this.geometryHistory.push(action);
// Limit history size
if (this.geometryHistory.length > 50) {
this.geometryHistory.shift();
}
}
/**
* Update layer filters to hide features being edited
* Note: This is now supplementary - main hiding is done in _updateMapSource
*/
_updateLayerFilters() {
// We no longer rely on filters since they have type matching issues
// Instead, we remove hidden features directly from the source data in _updateMapSource
// Clear any existing filters
this.layerConfig.layerIds.forEach(layerId => {
if (this.map.getLayer(layerId)) {
this.map.setFilter(layerId, null);
}
});
// Update the source data to exclude hidden features
this._updateMapSource();
}
/**
* Update map source with current data (excluding hidden and deleted features)
*/
_updateMapSource() {
const source = this.map.getSource(this.layerConfig.sourceId);
if (source) {
// Filter out hidden features and deleted features from the source data
const visibleFeatures = Array.from(this.loadedAssets.values()).filter(feature => {
// Check both number and string versions of ID
const isHiddenByNumber = this.hiddenFeatures.has(feature.id);
const isHiddenByString = this.hiddenFeatures.has(String(feature.id));
const isHiddenByParsed = typeof feature.id === 'string' &&
this.hiddenFeatures.has(parseInt(feature.id, 10));
const isHidden = isHiddenByNumber || isHiddenByString || isHiddenByParsed;
// Check if feature is marked as deleted in modifiedGeometries
const modByNumber = this.modifiedGeometries.get(feature.id);
const modByString = this.modifiedGeometries.get(String(feature.id));
const modByParsed = typeof feature.id === 'string' ?
this.modifiedGeometries.get(parseInt(feature.id, 10)) : null;
const modification = modByNumber || modByString || modByParsed;
const isDeleted = modification && modification.isDeleted;
return !isHidden && !isDeleted;
});
source.setData({
type: 'FeatureCollection',
features: visibleFeatures
});
} else {
}
}
/**
* Compare two geometries to check if they are different
* Uses coordinate comparison with tolerance to avoid false positives
* from floating point rounding differences
*/
_geometriesAreDifferent(geom1, geom2) {
if (!geom1 || !geom2) {
return geom1 !== geom2;
}
if (geom1.type !== geom2.type) {
return true;
}
// Compare coordinates arrays
const coords1 = JSON.stringify(geom1.coordinates);
const coords2 = JSON.stringify(geom2.coordinates);
if (coords1 === coords2) {
return false;
}
// If JSON differs, compare coordinate by coordinate with tolerance
const TOLERANCE = 1e-9; // Very small tolerance for floating point comparison
const compareCoords = (c1, c2) => {
if (Array.isArray(c1) && Array.isArray(c2)) {
if (c1.length !== c2.length) return false;
// Check if this is a coordinate pair [lng, lat]
if (c1.length >= 2 && typeof c1[0] === 'number' && typeof c1[1] === 'number') {
return Math.abs(c1[0] - c2[0]) < TOLERANCE &&
Math.abs(c1[1] - c2[1]) < TOLERANCE;
}
// Otherwise it's a nested array, compare recursively
return c1.every((coord, i) => compareCoords(coord, c2[i]));
}
return c1 === c2;
};
return !compareCoords(geom1.coordinates, geom2.coordinates);
}
/**
* Update modified count and trigger callback
*/
_updateModifiedCount() {
const count = this.modifiedGeometries.size;
// Update floating buttons
this._updateFloatingButtons();
if (this.callbacks.onModifiedCountChange) {
this.callbacks.onModifiedCountChange(count, this.modifiedGeometries);
}
}
/**
* Show a message to the user
*/
_showMessage(text, type) {
// Show via integrated help message
this.showHelp(text, type);
// Also call callback if defined
if (this.callbacks.onMessage) {
this.callbacks.onMessage(text, type);
}
}
// ========== Public API ==========
/**
* Get loaded assets map
*/
getLoadedAssets() {
return this.loadedAssets;
}
/**
* Set loaded assets
*/
setLoadedAssets(assets) {
this.loadedAssets = assets;
}
/**
* Get modified geometries
*/
getModifiedGeometries() {
return this.modifiedGeometries;
}
/**
* Clear modified geometries
*/
clearModifiedGeometries() {
this.modifiedGeometries.clear();
this._updateModifiedCount();
}
/**
* Get hidden features set
*/
getHiddenFeatures() {
return this.hiddenFeatures;
}
/**
* Hide a feature
*/
hideFeature(featureId) {
this.hiddenFeatures.add(featureId);
this._updateLayerFilters();
}
/**
* Show a feature
*/
showFeature(featureId) {
this.hiddenFeatures.delete(featureId);
this._updateLayerFilters();
}
/**
* Check if draw mode is active
*/
isInDrawMode() {
return this.isDrawMode;
}
/**
* Add feature to draw for editing
*/
addFeatureToEdit(feature) {
if (!this.isDrawMode) {
this.toggleDrawMode();
}
const featureId = feature.id;
if (!featureId) {
return;
}
if (!feature.geometry) {
return;
}
// Store in loadedAssets if not already present
if (!this.loadedAssets.has(featureId)) {
this.loadedAssets.set(featureId, {
id: featureId,
type: 'Feature',
geometry: JSON.parse(JSON.stringify(feature.geometry)), // Deep clone
properties: feature.properties || {}
});
}
// Hide on map
this.hideFeature(featureId);
// Add to draw
const addedIds = this.draw.add({
id: featureId,
type: 'Feature',
geometry: feature.geometry,
properties: feature.properties || {}
});
// Verify feature was added
const addedFeature = this.draw.get(featureId);
// Store the NORMALIZED geometry (after draw processing) as the original
// This is important because draw may simplify/round coordinates
if (addedFeature && !this.originalGeometries.has(featureId)) {
this.originalGeometries.set(featureId, JSON.parse(JSON.stringify(addedFeature.geometry)));
}
// Enter edit mode with a small delay to ensure feature is rendered
setTimeout(() => {
const allFeatures = this.draw.getAll();
const allIds = allFeatures.features.map(f => f.id);
this.draw.changeMode('edit', { featureIds: allIds });
this.lastSelectedFeatureIds = allIds;
}, 50);
this.currentEditingAsset = {
id: featureId,
assetType: feature.properties?.asset_type || this.layerConfig.assetType
};
this._updateSplitMergeState();
// Notify that a feature has entered edit mode
if (this.callbacks.onFeatureEdit) {
const fullFeature = this.loadedAssets.get(featureId) || feature;
this.callbacks.onFeatureEdit(fullFeature);
}
}
/**
* Remove feature from draw editing
*/
removeFeatureFromEdit(featureId) {
// Remove from draw
this.draw.delete(featureId);
// Show on map
this.showFeature(featureId);
if (this.currentEditingAsset?.id === featureId) {
this.currentEditingAsset = null;
}
this._updateMapSource();
this._updateSplitMergeState();
}
/**
* Update the properties of a feature (for attribute editing)
* This updates both the draw feature and loadedAssets, and tracks the modification
*
* @param {string|number} featureId - The ID of the feature to update
* @param {Object} properties - The properties to update (will be merged with existing)
* @returns {boolean} - True if update was successful
*/
updateFeatureProperties(featureId, properties) {
if (!featureId || !properties) {
return false;
}
// Update in draw if present
const drawFeature = this.draw.get(featureId);
if (drawFeature) {
const updatedFeature = {
...drawFeature,
properties: {
...drawFeature.properties,
...properties
}
};
this.draw.add(updatedFeature);
}
// Update in loadedAssets
const loadedAsset = this.loadedAssets.get(featureId);
if (loadedAsset) {
loadedAsset.properties = {
...loadedAsset.properties,
...properties
};
}
// Track modification if not already tracked
let mod = this.modifiedGeometries.get(featureId);
if (mod) {
// Update properties in existing modification
mod.properties = {
...(mod.properties || {}),
...properties
};
} else {
// Create new modification entry for property change only
const currentGeom = drawFeature?.geometry || loadedAsset?.geometry;
const originalGeom = this.originalGeometries.get(featureId) || loadedAsset?.geometry;
this.modifiedGeometries.set(featureId, {
assetId: featureId,
assetType: loadedAsset?.properties?.asset_type || this.layerConfig.assetType,
originalGeometry: originalGeom,
currentGeometry: currentGeom,
originalProperties: loadedAsset?.properties || {},
properties: properties,
isDeleted: false
});
}
this._updateModifiedCount();
return true;
}
/**
* Get the current properties of a feature
*
* @param {string|number} featureId - The ID of the feature
* @returns {Object|null} - The properties or null if not found
*/
getFeatureProperties(featureId) {
// Check draw first
const drawFeature = this.draw.get(featureId);
if (drawFeature) {
return drawFeature.properties || {};
}
// Check loadedAssets
const loadedAsset = this.loadedAssets.get(featureId);
if (loadedAsset) {
return loadedAsset.properties || {};
}
return null;
}
/**
* Get geometry history for undo
*/
getGeometryHistory() {
return this.geometryHistory;
}
/**
* Undo last geometry action
*/
undoLastAction() {
if (this.geometryHistory.length === 0) {
return false;
}
const action = this.geometryHistory.pop();
switch (action.type) {
case 'create':
// Delete the created feature
this.draw.delete(action.featureId);
this.modifiedGeometries.delete(action.featureId);
this.loadedAssets.delete(action.featureId);
break;
case 'update':
// Restore previous geometry
const feature = this.draw.get(action.featureId);
if (feature) {
this.draw.delete(action.featureId);
this.draw.add({
id: action.featureId,
type: 'Feature',
geometry: action.geometry,
properties: feature.properties
});
}
// Update tracking
const mod = this.modifiedGeometries.get(action.featureId);
if (mod) {
mod.currentGeometry = action.geometry;
}
// Update loaded assets
const asset = this.loadedAssets.get(action.featureId);
if (asset) {
asset.geometry = action.geometry;
}
break;
case 'delete':
// Restore deleted feature
this.draw.add({
id: action.featureId,
type: 'Feature',
geometry: action.geometry,
properties: action.properties
});
// Remove deletion tracking
this.modifiedGeometries.delete(action.featureId);
// Re-add to loaded assets
this.loadedAssets.set(action.featureId, {
id: action.featureId,
type: 'Feature',
geometry: action.geometry,
properties: action.properties
});
// Hide from map layer
this.hideFeature(action.featureId);
break;
}
this._updateMapSource();
this._updateModifiedCount();
this._updateSplitMergeState();
return true;
}
/**
* Check if draw mode is active
* @returns {boolean} True if draw mode is active
*/
isActive() {
return this.callbacks.getIsDrawMode
? this.callbacks.getIsDrawMode()
: this.isDrawMode;
}
/**
* Check if currently in draw mode (alias for isActive)
* @returns {boolean} True if draw mode is active
*/
isInDrawMode() {
return this.isActive();
}
}
// Export to global scope
window.CustomDrawToolbar = CustomDrawToolbar;
})(window);