3443 lines
138 KiB
JavaScript
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);
|