loko/streetup/mobile/templates/mobile/green_surfaces_edit_v2.html
2026-07-22 14:48:40 +02:00

1740 lines
65 KiB
HTML

{% extends "mobile/mobile_base.html" %}
{% load static i18n %}
{% block extra_head %}
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet"/>
<link href="{% static 'common/custom-draw-toolbar.css' %}" rel="stylesheet"/>
<link href="{% static 'assets/info-panel.css' %}" rel="stylesheet"/>
<style>
body {
overflow: hidden;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
button, input[type="button"], input[type="submit"], a {
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
}
#map {
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
#map-container {
position: fixed;
top: 56px;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
#map {
width: 100%;
height: 100%;
}
#attributes-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
z-index: 1000;
max-height: 50vh;
overflow-y: auto;
transition: transform 0.3s ease;
}
#attributes-panel.collapsed {
transform: translateY(calc(100% - 50px));
}
#panel-toggle {
position: sticky;
top: 0;
background: white;
border-bottom: 1px solid #ddd;
padding: 0.5rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
}
#panel-content {
padding: 1rem;
}
.maplibregl-ctrl-group button {
width: 42px;
height: 42px;
display: flex !important;
align-items: center;
justify-content: center;
}
.maplibregl-ctrl-group button span {
display: block;
width: 32px;
height: 32px;
background-size: 32px 32px;
background-position: center;
background-repeat: no-repeat;
flex-shrink: 0;
}
.marker-current-location {
background-color: #4285F4;
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
}
/* Model badges styling */
.model-badge {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.2rem;
margin: 0.25rem;
border-radius: 0.5rem;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
font-size: 0.75rem;
background-color: #f8f9fa;
width: 120px;
}
.model-badge:hover {
background-color: #e9ecef;
}
.model-badge.active {
border-color: #0d6efd;
background-color: #e7f3ff;
}
.model-badge.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.model-badge .color-swatch {
width: 20px;
height: 20px;
border-radius: 4px;
margin-right: 0.5rem;
border: 1px solid rgba(0,0,0,0.2);
flex-shrink: 0;
}
.model-badge .model-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
/* Selection info styling */
.selection-info {
background: #e7f3ff;
border-left: 4px solid #0d6efd;
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.modified-info {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: 4px;
}
/* Status badges */
.status-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
}
/* Tablet landscape mode */
@media (min-width: 768px) and (orientation: landscape) {
#map-container {
right: 33.33%;
}
#attributes-panel {
left: 66.67%;
bottom: 0;
top: 56px;
max-height: none;
transform: none !important;
}
#panel-toggle {
display: none;
}
#attributes-panel.collapsed {
transform: none;
}
.draw-floating-actions {
right: calc(33.33% + 20px);
}
.draw-help-message {
max-width: calc(66.67% - 40px);
}
}
/* Phone mode: keep space for collapsed attributes panel */
@media (max-width: 767px) {
#map-container {
bottom: 50px;
}
}
/* Floating exit button on map */
.draw-floating-actions-row {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
width: 100%;
}
.draw-floating-btn-exit {
background-color: #dc3545;
color: #fff;
}
.draw-floating-btn-exit:hover {
background-color: #bb2d3b;
}
</style>
{% endblock extra_head %}
{% block title %}
{% translate "Espaces verts - V2" %}
{% endblock title %}
{% block content %}
<!-- CSRF Token for API requests -->
{% csrf_token %}
<div id="map-container">
<div id="map"></div>
</div>
<div id="attributes-panel" class="collapsed">
<div id="panel-toggle">
<strong>
<span id="panel-title">{% translate "Édition des espaces verts" %}</span>
<span id="selection-count" class="badge bg-primary ms-2"></span>
</strong>
<i class="bi bi-chevron-up" id="toggle-icon"></i>
</div>
<div id="panel-content">
<!-- Comment content container (hidden by default) -->
<div id="comment-panel-content" style="display: none;">
<!-- Le contenu des commentaires sera injecté ici -->
</div>
<!-- Default panel content -->
<div id="default-panel-content">
<!-- Modified geometries info -->
<div class="modified-info" id="modified-info" style="display: none;">
<strong><i class="bi bi-pencil-square"></i> <span id="modified-assets-count">0</span> {% translate "géométrie(s) modifiée(s)" %}</strong>
</div>
<!-- Selection info for normal mode -->
<div class="selection-info" id="selection-info" style="display: none;">
<strong><i class="bi bi-check2-square"></i> <span id="selected-count">0</span> {% translate "élément(s) sélectionné(s)" %}</strong>
<p class="mb-0 small text-muted">{% translate "Sélectionnez un modèle ci-dessous pour l'appliquer aux éléments sélectionnés." %}</p>
</div>
<!-- Attribute Edit Panel - for draw mode editing -->
<div id="attribute-panel" style="display: none;">
<h6><i class="bi bi-pencil-square"></i> {% translate "Édition des attributs" %}</h6>
<div class="mb-2">
<small class="text-muted">ID: <span id="edit-feature-id">-</span></small>
</div>
</div>
<!-- Model selection with badges -->
<div class="mb-3">
<label class="form-label fw-bold"><i class="bi bi-collection"></i> {% translate "Modèle" %}</label>
<div id="model-badges" class="d-flex flex-wrap">
{% for model in models %}
<div class="model-badge disabled" data-value="{{ model.id }}" data-color="{{ model.color|default:'#cccccc' }}" title="{{ model.get_name }}">
<span class="color-swatch" style="background-color: {{ model.color|default:'#cccccc' }};"></span>
<span class="model-name">{{ model.get_name }}</span>
</div>
{% endfor %}
</div>
<input type="hidden" id="model-select" name="model_id">
</div>
<!-- Bulb types selection (green surfaces only) -->
<div class="mb-3">
<div class="d-flex align-items-center gap-2 mb-2">
<label class="form-label fw-bold mb-0"><i class="bi bi-flower1"></i> {% translate "Types de bulbes" %}</label>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="btn-toggle-bulbs">
<i class="bi bi-chevron-down" id="bulbs-toggle-icon"></i>
</button>
</div>
<div id="bulb-types-panel" style="display: none;">
<div id="bulb-badges" class="d-flex flex-wrap">
{% for value, label in bulb_type_choices %}
<div class="model-badge bulb-badge disabled" data-value="{{ value }}" title="{{ label }}">
<span class="model-name">{{ label }}</span>
</div>
{% endfor %}
</div>
<input type="hidden" id="bulb-types-input" name="bulb_types" value="[]">
</div>
</div>
<hr>
<!-- Action buttons -->
<div class="btn-toolbar mb-3 gap-2">
<button type="button" class="btn btn-sm btn-primary" id="btn-save" disabled>
<i class="bi bi-save"></i> {% translate "Enregistrer" %}
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-clear-selection">
<i class="bi bi-x-circle"></i> {% translate "Désélectionner" %}
</button>
</div>
<hr>
<!-- Status info -->
<div id="status-info">
<div class="status-row">
<div class="status-item">
<span class="text-muted">{% translate "Mode dessin" %}:</span>
<span id="draw-mode-status" class="badge bg-secondary">{% translate "Inactif" %}</span>
</div>
<div class="status-item">
<span class="text-muted">{% translate "Modifications" %}:</span>
<span id="modified-count" class="badge bg-warning text-dark">0</span>
</div>
</div>
<div class="status-row">
<div class="status-item">
<span class="text-muted">{% translate "Assets chargés" %}:</span>
<span id="loaded-count" class="badge bg-info">0</span>
</div>
<div class="status-item">
<span class="text-muted">{% translate "En édition" %}:</span>
<span id="draw-count" class="badge bg-primary">0</span>
</div>
</div>
</div>
<div id="edit-message" class="alert mt-3" style="display: none;"></div>
</div><!-- End of default-panel-content -->
</div>
</div>
{% endblock content %}
{% block footer %}
{% endblock footer %}
{% block extra_scripts %}
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'common/custom-draw.js' %}"></script>
<script src="{% static 'common/custom-draw-toolbar.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'assets/green-surfaces-comments.js' %}"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script>
<script>
/**
* Green Surfaces Edit V2
*
* Combines CustomDrawToolbar from standalone template with:
* - Multi-selection capability for bulk model updates
* - Ergonomic model selection with Bootstrap badges
* - Geolocated comments with photo capture
*
* Features:
* - Normal mode: Click assets to select/deselect for bulk operations
* - Draw mode (via CustomDrawToolbar): Edit geometries directly
* - Model badges: Quick model assignment with visual color swatches
* - Comments: Add geolocated comments with photos (independent from assets)
*/
(function() {
'use strict';
// Configuration
const MIN_ZOOM_FOR_ASSETS = 15;
const BUFFER_SIZE = 0.001; // degrees (~100m)
const ORTHO_WMTS_URL = 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}';
// State
let map = null;
let draw = null;
let drawToolbar = null;
let currentLocation = null;
let selectedAssets = new Set(); // For normal mode multi-selection
let selectedBulbTypes = new Set(); // Currently selected bulb types
let currentEditFeatureId = null; // For draw mode editing
// =============================
// Helper Functions
// =============================
function showMessage(message, type) {
const messageDiv = document.getElementById('edit-message');
if (messageDiv) {
messageDiv.className = `alert alert-${type}`;
messageDiv.textContent = message;
messageDiv.style.display = 'block';
setTimeout(() => {
messageDiv.style.display = 'none';
}, 5000);
}
// Also show toast
const toast = document.createElement('div');
toast.className = `alert alert-${type} position-fixed top-0 start-50 translate-middle-x mt-3`;
toast.style.zIndex = '9999';
toast.style.minWidth = '300px';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 4000);
}
function toggleLoading(show) {
let loader = document.getElementById('loading-overlay');
if (!loader && show) {
loader = document.createElement('div');
loader.id = 'loading-overlay';
loader.className = 'position-fixed top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center';
loader.style.backgroundColor = 'rgba(0,0,0,0.5)';
loader.style.zIndex = '9999';
loader.innerHTML = '<div class="spinner-border text-light" role="status"><span class="visually-hidden">Loading...</span></div>';
document.body.appendChild(loader);
}
if (loader) {
loader.style.display = show ? 'flex' : 'none';
}
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// =============================
// Model Badge Functions
// =============================
function setModelValue(value) {
const input = document.getElementById('model-select');
input.value = value || '';
// Update badge UI - exclude bulb badges
document.querySelectorAll('.model-badge:not(.bulb-badge)').forEach(badge => {
if (badge.dataset.value === String(value)) {
badge.classList.add('active');
} else {
badge.classList.remove('active');
}
});
}
function enableModelBadges(enable) {
document.querySelectorAll('.model-badge:not(.bulb-badge)').forEach(badge => {
if (enable) {
badge.classList.remove('disabled');
} else {
badge.classList.add('disabled');
badge.classList.remove('active');
}
});
}
// =============================
// Bulb Type Functions
// =============================
function getBulbTypesArray() {
return Array.from(selectedBulbTypes);
}
function setBulbTypes(values) {
selectedBulbTypes.clear();
(values || []).forEach(v => selectedBulbTypes.add(v));
document.querySelectorAll('.bulb-badge').forEach(badge => {
badge.classList.toggle('active', selectedBulbTypes.has(badge.dataset.value));
});
const input = document.getElementById('bulb-types-input');
if (input) input.value = JSON.stringify(Array.from(selectedBulbTypes));
// Auto-open panel when there are selected values, collapse when empty
const panel = document.getElementById('bulb-types-panel');
const icon = document.getElementById('bulbs-toggle-icon');
if (panel) {
if (selectedBulbTypes.size > 0) {
panel.style.display = '';
if (icon) icon.className = 'bi bi-chevron-up';
} else {
panel.style.display = 'none';
if (icon) icon.className = 'bi bi-chevron-down';
}
}
}
function enableBulbBadges(enable) {
document.querySelectorAll('.bulb-badge').forEach(badge => {
if (enable) {
badge.classList.remove('disabled');
} else {
badge.classList.add('disabled');
badge.classList.remove('active');
}
});
}
// =============================
// Selection Functions (Normal Mode)
// =============================
function updateSelectionUI() {
const count = selectedAssets.size;
document.getElementById('selected-count').textContent = count;
document.getElementById('selection-count').textContent = count || '';
if (count > 0) {
document.getElementById('selection-info').style.display = 'block';
enableModelBadges(true);
enableBulbBadges(true);
document.getElementById('btn-save').disabled = false;
// Check for common model among selected assets
let commonModel = undefined;
let firstPass = true;
let diverseModel = false;
for (const assetId of selectedAssets) {
// Try to get asset with different ID formats (string/number)
let asset = drawToolbar?.loadedAssets.get(assetId);
if (!asset) asset = drawToolbar?.loadedAssets.get(String(assetId));
if (!asset) asset = drawToolbar?.loadedAssets.get(Number(assetId));
const props = asset?.properties || {};
const modId = props.model_id ? String(props.model_id) : "";
if (firstPass) {
commonModel = modId;
firstPass = false;
} else {
if (commonModel !== modId) diverseModel = true;
}
if (diverseModel) break;
}
// Set model value only if all selected assets have the same model
// Otherwise, no option is preselected
if (!diverseModel && commonModel) {
setModelValue(commonModel);
} else {
// Clear selection - diverse models or no model
setModelValue('');
}
// Check for common bulb types among selected assets
let commonBulbTypesKey = null;
let firstPassBulb = true;
let diverseBulbs = false;
for (const assetId of selectedAssets) {
let asset = drawToolbar?.loadedAssets.get(assetId);
if (!asset) asset = drawToolbar?.loadedAssets.get(String(assetId));
if (!asset) asset = drawToolbar?.loadedAssets.get(Number(assetId));
const props = asset?.properties || {};
let bulbs = props.bulb_types || [];
if (typeof bulbs === 'string') {
try { bulbs = JSON.parse(bulbs); } catch(e) { bulbs = []; }
}
const bulbsKey = JSON.stringify([...bulbs].sort());
if (firstPassBulb) {
commonBulbTypesKey = bulbsKey;
firstPassBulb = false;
} else if (commonBulbTypesKey !== bulbsKey) {
diverseBulbs = true;
}
if (diverseBulbs) break;
}
setBulbTypes(!diverseBulbs && commonBulbTypesKey !== null ? JSON.parse(commonBulbTypesKey) : []);
} else {
document.getElementById('selection-info').style.display = 'none';
enableModelBadges(false);
setModelValue('');
setBulbTypes([]);
enableBulbBadges(false);
document.getElementById('btn-save').disabled = true;
}
}
function clearSelection() {
selectedAssets.forEach(assetId => {
map.setFeatureState(
{ source: 'assets', id: assetId },
{ selected: false }
);
});
selectedAssets.clear();
updateSelectionUI();
}
function handleAssetClick(e) {
// Ignore clicks when comment repositioning is active
if (window.commentsComponent?.isRepositioning) {
return;
}
// Close comment panel if open (asset click means user wants asset interaction)
if (window.infoPanel?.isOpen()) {
window.infoPanel.close();
}
if (!e.features || e.features.length === 0) return;
const feature = e.features[0];
// Get asset ID from properties (more reliable) or fallback to feature.id
const assetId = feature.properties?.id || feature.properties?.asset_id || feature.id;
// Check if we're in draw mode
const isDrawMode = drawToolbar?.isActive();
if (isDrawMode) {
// In draw mode, let CustomDrawToolbar handle clicks
// The toolbar will add features to draw control
return;
}
// Normal mode: toggle selection
if (selectedAssets.has(assetId)) {
selectedAssets.delete(assetId);
map.setFeatureState(
{ source: 'assets', id: assetId },
{ selected: false }
);
} else {
selectedAssets.add(assetId);
map.setFeatureState(
{ source: 'assets', id: assetId },
{ selected: true }
);
}
updateSelectionUI();
}
// =============================
// Attribute Panel (Draw Mode)
// =============================
function showAttributePanel(feature) {
if (!feature) return;
const panel = document.getElementById('attribute-panel');
const featureIdSpan = document.getElementById('edit-feature-id');
currentEditFeatureId = feature.id;
featureIdSpan.textContent = feature.id;
// Get model from properties
const modelId = feature.properties?.model_id || '';
setModelValue(modelId);
enableModelBadges(true);
// Get bulb types from properties
// bulb_types may arrive as a JSON string when read from MapLibre feature properties
let bulbTypes = feature.properties?.bulb_types || [];
if (typeof bulbTypes === 'string') {
try { bulbTypes = JSON.parse(bulbTypes); } catch(e) { bulbTypes = []; }
}
setBulbTypes(bulbTypes);
enableBulbBadges(true);
document.getElementById('btn-save').disabled = false;
panel.style.display = 'block';
}
function hideAttributePanel() {
const panel = document.getElementById('attribute-panel');
panel.style.display = 'none';
currentEditFeatureId = null;
setBulbTypes([]);
enableBulbBadges(false);
}
function showMultiSelectInfo(features) {
const panel = document.getElementById('attribute-panel');
const featureIdSpan = document.getElementById('edit-feature-id');
currentEditFeatureId = null;
featureIdSpan.textContent = `{% translate "Sélection multiple" %} (${features.length})`;
enableModelBadges(true);
enableBulbBadges(true);
document.getElementById('btn-save').disabled = false;
panel.style.display = 'block';
}
// =============================
// Save Functions
// =============================
const handleSaveGeometries = function() {
if (!drawToolbar || drawToolbar.modifiedGeometries.size === 0) {
showMessage('{% translate "Aucune modification à enregistrer" %}', 'info');
return;
}
// Prepare data for saving
const modifications = [];
const deletions = [];
Array.from(drawToolbar.modifiedGeometries.values()).forEach(mod => {
if (mod.currentGeometry === null) {
deletions.push({
asset_id: mod.assetId,
asset_type: mod.assetType
});
} else {
const modification = {
asset_id: mod.assetId,
asset_type: mod.assetType,
geometry: mod.currentGeometry
};
if (mod.modelId) {
modification.model_id = mod.modelId;
}
if (mod.properties) {
modification.properties = mod.properties;
}
if (mod.bulbTypes !== undefined) {
modification.bulb_types = mod.bulbTypes;
}
modifications.push(modification);
}
});
toggleLoading(true);
fetch('{% url "assets:nature_save_geometries" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
modifications: modifications,
deletions: deletions
})
})
.then(response => response.json())
.then(data => {
toggleLoading(false);
if (data.success) {
let message = '{% translate "Géométries enregistrées avec succès" %}';
if (data.updated_count > 0) {
message += ` - ${data.updated_count} {% translate "modifié(s)" %}`;
}
if (data.deleted_count > 0) {
message += ` - ${data.deleted_count} {% translate "supprimé(s)" %}`;
}
showMessage(message, 'success');
// Handle new asset IDs
if (data.id_mapping) {
Object.entries(data.id_mapping).forEach(([oldId, newId]) => {
if (drawToolbar.loadedAssets.has(oldId)) {
const asset = drawToolbar.loadedAssets.get(oldId);
asset.id = newId;
if (asset.properties) asset.properties.id = newId;
drawToolbar.loadedAssets.delete(oldId);
drawToolbar.loadedAssets.set(newId, asset);
}
const drawFeature = draw.get(oldId);
if (drawFeature) {
draw.add({
...drawFeature,
id: newId
});
draw.delete(oldId);
}
});
}
drawToolbar.modifiedGeometries.clear();
drawToolbar._updateModifiedCount();
drawToolbar.geometryHistory = [];
loadAssetsIfNeeded();
updateStatusDisplay();
} else {
showMessage(data.error || '{% translate "Erreur lors de la sauvegarde" %}', 'danger');
}
})
.catch(error => {
toggleLoading(false);
showMessage('{% translate "Erreur lors de la sauvegarde des géométries" %}', 'danger');
});
};
function handleBulkSave() {
const modelId = document.getElementById('model-select').value;
// Check if we're in draw mode with a current feature
if (drawToolbar?.isActive() && currentEditFeatureId) {
// Update the model for the feature being edited
if (modelId && drawToolbar) {
const mod = drawToolbar.modifiedGeometries.get(currentEditFeatureId);
if (mod) {
mod.modelId = modelId;
mod.bulbTypes = getBulbTypesArray();
}
// Also update loaded asset properties
const asset = drawToolbar.loadedAssets.get(currentEditFeatureId);
if (asset) {
if (!asset.properties) asset.properties = {};
asset.properties.model_id = modelId;
asset.properties.bulb_types = getBulbTypesArray();
}
}
// Save geometries
handleSaveGeometries();
return;
}
// Normal mode: bulk update selected assets
if (selectedAssets.size === 0) {
showMessage('{% translate "Aucun élément sélectionné" %}', 'warning');
return;
}
if (!modelId && selectedBulbTypes.size === 0) {
showMessage('{% translate "Veuillez sélectionner un modèle ou des types de bulbes" %}', 'warning');
return;
}
const assetIds = Array.from(selectedAssets);
toggleLoading(true);
fetch('{% url "assets:nature_bulk_update_assets" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
asset_ids: assetIds,
category_id: null,
model_id: modelId || null,
bulb_types: getBulbTypesArray()
})
})
.then(response => response.json())
.then(data => {
toggleLoading(false);
if (data.success) {
showMessage('{% translate "Modifications enregistrées avec succès" %}', 'success');
clearSelection();
// Force reload assets from server to get updated colors
// Clear loadedAssets to force a fresh load
if (drawToolbar) {
drawToolbar.loadedAssets.clear();
}
forceReloadAssets();
} else {
showMessage(data.error || '{% translate "Erreur lors de la sauvegarde" %}', 'danger');
}
})
.catch(error => {
toggleLoading(false);
showMessage('{% translate "Erreur lors de la sauvegarde" %}', 'danger');
});
}
// =============================
// Map Initialization
// =============================
function initMap() {
map = new maplibregl.Map({
container: 'map',
style: window.MAP_STYLE_STANDARD,
center: [4.35, 50.85], // Brussels
zoom: 13,
attributionControl: true
});
map.addControl(new maplibregl.NavigationControl(), 'top-left');
const geolocate = new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true,
showAccuracyCircle: true
});
map.addControl(geolocate, 'top-left');
geolocate.on('geolocate', function(e) {
currentLocation = {
lat: e.coords.latitude,
lng: e.coords.longitude
};
});
const drawStyles = buildDrawStyles();
draw = new CustomDraw({
displayControlsDefault: false,
controls: {},
defaultMode: 'edit',
snap: true,
snapPx: 15,
snapToMidPoints: true,
snapVertexPriorityDistance: 0.0005,
guides: true,
snapOptions: {
snapPx: 15,
snapToMidPoints: true,
snapVertexPriorityDistance: 0.0005,
snapSelfEnabled: true
},
styles: drawStyles,
userProperties: true
});
map.on('load', function() {
map.addControl(draw, 'top-left');
drawToolbar = new CustomDrawToolbar({
draw: draw,
layerConfig: {
sourceId: 'assets',
layerIds: ['green-surfaces', 'green-surfaces-outline'],
assetType: 'naturegreensurface'
},
api: {
loadUrl: '/api/nature/assets/',
saveUrl: '/api/nature/assets/bulk-save/',
deleteUrl: '/api/nature/assets/bulk-delete/',
getCsrfToken: () => document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
},
labels: {
drawMode: '{% translate "Mode dessin" %}',
addPolygon: '{% translate "Ajouter un polygone" %}',
deleteSelected: '{% translate "Supprimer les géométries sélectionnées" %}',
snap: '{% translate "Aimantage (Snap)" %}',
snapEnabled: '{% translate "Aimantage activé" %}',
snapDisabled: '{% translate "Aimantage désactivé" %}',
split: '{% translate "Découper un polygone" %}',
splitCancelled: '{% translate "Découpage annulé" %}',
splitSuccess: '{% translate "Polygone découpé avec succès" %}',
merge: '{% translate "Fusionner les polygones sélectionnés" %}',
mergeSuccess: '{% translate "Polygones fusionnés avec succès" %}',
saveGeometries: '{% translate "Enregistrer les modifications" %}',
undo: '{% translate "Annuler" %}',
noFeaturesToDelete: '{% translate "Aucune géométrie à supprimer" %}',
geometriesDeleted: '{% translate "Géométrie(s) supprimée(s)" %}',
drawModeEnabled: "{% translate "Mode dessin activé - Cliquez sur une géométrie pour l'éditer" %}",
drawModeDisabled: '{% translate "Mode dessin désactivé" %}',
addPolygonHelp: '{% translate "Cliquez pour ajouter des points. Double-cliquez ou cliquez sur le premier point pour terminer." %}',
splitHelp: '{% translate "Tracez une ligne pour découper le polygone. Double-cliquez pour terminer." %}'
},
callbacks: {
onSave: handleSaveGeometries,
onDrawModeChange: function(isActive) {
if (isActive) {
map.doubleClickZoom.disable();
// Clear normal selection when entering draw mode
clearSelection();
document.getElementById('selection-info').style.display = 'none';
} else {
map.doubleClickZoom.enable();
hideAttributePanel();
enableModelBadges(false);
// When exiting draw mode, remove all features from draw control
const drawFeatures = draw.getAll().features;
drawFeatures.forEach(feature => {
const featureId = feature.id;
draw.delete(featureId);
// Show the feature back on the map
if (drawToolbar && drawToolbar.hiddenFeatures) {
drawToolbar.hiddenFeatures.delete(featureId);
}
});
// Reset modified geometries counters
if (drawToolbar) {
drawToolbar.modifiedGeometries.clear();
drawToolbar.geometryHistory = [];
drawToolbar._updateModifiedCount();
drawToolbar._updateFloatingButtons();
}
// Cancel unsaved modifications by reloading assets from server
// Clear loadedAssets to force a fresh load
if (drawToolbar) {
drawToolbar.loadedAssets.clear();
}
forceReloadAssets();
}
updateStatusDisplay();
},
onFeatureEdit: function(feature) {
showAttributePanel(feature);
},
onSelectionChange: function(selectedFeatures, selectedIds) {
if (selectedFeatures.length === 1) {
showAttributePanel(selectedFeatures[0]);
} else if (selectedFeatures.length === 0) {
hideAttributePanel();
enableModelBadges(false);
} else {
showMultiSelectInfo(selectedFeatures);
}
}
}
});
map.addControl(drawToolbar, 'top-left');
setupFloatingExitButton();
const layerSwitcher = new LayerSwitcherControl({
layers: [
{
id: 'ortho',
name: 'Orthophotos',
icon: 'satellite',
group: 'ortho',
layer: {
type: 'raster',
paint: {
'raster-opacity': 1
}
},
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '© Brussels UrbIS'
}
},
{
id: 'vlaanderen',
name: 'Vlaanderen',
source: {
type: 'raster',
tiles: [ORTHO_WMTS_URL],
tileSize: 256,
attribution: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
},
{
id: 'assets-visibility',
name: '{% translate "Espaces verts" %}',
icon: 'bi:flower1',
group: 'data',
exclusive: false,
existingLayerIds: ['green-surfaces', 'green-surfaces-outline'],
visible: true
},
{
id: 'trees-visibility',
name: '{% translate "Arbres" %}',
icon: 'bi:tree',
group: 'data',
exclusive: false,
existingLayerIds: ['trees', 'trees-center'],
visible: true
}
]
});
map.addControl(layerSwitcher, 'top-left');
map.addSource('assets', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
},
promoteId: 'id' // Use the 'id' property from features as the feature ID
});
map.addLayer({
id: 'green-surfaces',
type: 'fill',
source: 'assets',
filter: ['==', ['get', 'asset_type'], 'naturegreensurface'],
paint: {
'fill-color': ['get', 'color'],
'fill-opacity': [
'case',
['boolean', ['feature-state', 'selected'], false],
0.7,
0.5
]
}
});
map.addLayer({
id: 'green-surfaces-outline',
type: 'line',
source: 'assets',
filter: ['==', ['get', 'asset_type'], 'naturegreensurface'],
paint: {
'line-color': [
'case',
['boolean', ['feature-state', 'selected'], false],
'#ff0000',
'#000000'
],
'line-width': [
'case',
['boolean', ['feature-state', 'selected'], false],
3,
1
]
}
});
// Trees layer: green circles with black border
map.addLayer({
id: 'trees',
type: 'circle',
source: 'assets',
filter: ['==', ['get', 'asset_type'], 'naturetree'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
15, 1.5,
17, 2.5,
19, 4
],
'circle-color': '#4CAF50',
'circle-stroke-width': [
'interpolate', ['linear'], ['zoom'],
15, 0.25,
17, 0.5,
19, 0.75
],
'circle-stroke-color': '#222222'
}
});
// Trees center dot
map.addLayer({
id: 'trees-center',
type: 'circle',
source: 'assets',
filter: ['==', ['get', 'asset_type'], 'naturetree'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
15, 0.5,
17, 0.75,
19, 1.25
],
'circle-color': '#111111',
'circle-stroke-width': 0
}
});
loadAssetsIfNeeded();
geolocate.trigger();
// Initialize comments component
const commentsComponent = new GreenSurfacesComments(map, {
apiBaseUrl: '{% url "assets:green_surfaces_comments_geojson" %}'.replace('/geojson/', '')
});
// Make it globally accessible for debugging
window.commentsComponent = commentsComponent;
setInterval(updateStatusDisplay, 1000);
});
// Handle clicks on assets for selection (normal mode)
map.on('click', 'green-surfaces', handleAssetClick);
// Change cursor on hover
map.on('mouseenter', 'green-surfaces', function() {
if (!drawToolbar?.isActive()) {
map.getCanvas().style.cursor = 'pointer';
}
});
map.on('mouseleave', 'green-surfaces', function() {
if (!drawToolbar?.isActive()) {
map.getCanvas().style.cursor = '';
}
});
map.on('moveend', function() {
loadAssetsIfNeeded();
});
// Setup panel toggle
setupPanelToggle();
// Setup model badge clicks
setupModelBadges();
// Setup action buttons
setupActionButtons();
}
function buildDrawStyles() {
return [
{
'id': 'polygon-fill-inactive',
'type': 'fill',
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
'paint': {
'fill-color': ['coalesce', ['get', 'user_color'], '#3bb2d0'],
'fill-outline-color': ['coalesce', ['get', 'user_color'], '#3bb2d0'],
'fill-opacity': 0.5
}
},
{
'id': 'polygon-fill-active',
'type': 'fill',
'filter': ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
'paint': {
'fill-color': '#0066ff',
'fill-outline-color': '#0066ff',
'fill-opacity': 0.5
}
},
{
'id': 'polygon-stroke-inactive',
'type': 'line',
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
'layout': {
'line-cap': 'round',
'line-join': 'round'
},
'paint': {
'line-color': ['coalesce', ['get', 'user_color'], '#3bb2d0'],
'line-width': 2
}
},
{
'id': 'polygon-stroke-active',
'type': 'line',
'filter': ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
'layout': {
'line-cap': 'round',
'line-join': 'round'
},
'paint': {
'line-color': '#0066ff',
'line-dasharray': ['literal', [0.2, 2]],
'line-width': 2
}
},
{
'id': 'polygon-and-line-vertex-halo-active',
'type': 'circle',
'filter': ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
'paint': {
'circle-radius': 5,
'circle-color': '#FFF'
}
},
{
'id': 'polygon-and-line-vertex-active',
'type': 'circle',
'filter': ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
'paint': {
'circle-radius': 3,
'circle-color': '#0066ff'
}
},
{
'id': 'polygon-midpoint',
'type': 'circle',
'filter': ['all', ['==', 'meta', 'midpoint'], ['==', '$type', 'Point']],
'paint': {
'circle-radius': 3,
'circle-color': '#3bb2d0'
}
},
{
'id': 'gl-draw-line-inactive',
'type': 'line',
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
'layout': {
'line-cap': 'round',
'line-join': 'round'
},
'paint': {
'line-color': '#3bb2d0',
'line-width': 2
}
},
{
'id': 'gl-draw-line-active',
'type': 'line',
'filter': ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
'layout': {
'line-cap': 'round',
'line-join': 'round'
},
'paint': {
'line-color': '#ff6600',
'line-dasharray': ['literal', [0.2, 2]],
'line-width': 3
}
}
];
}
/**
* Force reload assets from server (clears cache and reloads)
* Used after save operations to get updated colors
*/
function forceReloadAssets() {
if (!map) return;
const bounds = map.getBounds();
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const bufferedBounds = {
geometry: {
type: 'Polygon',
coordinates: [[
[sw.lng - BUFFER_SIZE, sw.lat - BUFFER_SIZE],
[ne.lng + BUFFER_SIZE, sw.lat - BUFFER_SIZE],
[ne.lng + BUFFER_SIZE, ne.lat + BUFFER_SIZE],
[sw.lng - BUFFER_SIZE, ne.lat + BUFFER_SIZE],
[sw.lng - BUFFER_SIZE, sw.lat - BUFFER_SIZE]
]]
}
};
fetch('{% url "assets:nature_assets_geojson" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
},
body: JSON.stringify({
filter_geom: bufferedBounds
})
})
.then(response => response.json())
.then(data => {
const features = (data.features || []).map(feature => {
const properties = {
...feature.properties,
id: feature.id,
asset_id: feature.id
};
return {
...feature,
properties
};
});
// Clear and reload all assets
if (drawToolbar) {
drawToolbar.loadedAssets.clear();
features.forEach(feature => {
drawToolbar.loadedAssets.set(feature.id, {
id: feature.id,
type: 'Feature',
geometry: feature.geometry,
properties: feature.properties || {}
});
});
drawToolbar._updateMapSource();
} else {
if (map.getSource('assets')) {
map.getSource('assets').setData({
type: 'FeatureCollection',
features: features
});
}
}
updateStatusDisplay();
})
.catch(error => {
showMessage('{% translate "Erreur lors du rechargement des données" %}', 'danger');
});
}
function loadAssetsIfNeeded() {
if (!map || map.getZoom() < MIN_ZOOM_FOR_ASSETS) {
return;
}
const bounds = map.getBounds();
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const bufferedBounds = {
geometry: {
type: 'Polygon',
coordinates: [[
[sw.lng - BUFFER_SIZE, sw.lat - BUFFER_SIZE],
[ne.lng + BUFFER_SIZE, sw.lat - BUFFER_SIZE],
[ne.lng + BUFFER_SIZE, ne.lat + BUFFER_SIZE],
[sw.lng - BUFFER_SIZE, ne.lat + BUFFER_SIZE],
[sw.lng - BUFFER_SIZE, sw.lat - BUFFER_SIZE]
]]
}
};
fetch('{% url "assets:nature_assets_geojson" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
},
body: JSON.stringify({
filter_geom: bufferedBounds
})
})
.then(response => response.json())
.then(data => {
const features = (data.features || []).map(feature => {
const properties = {
...feature.properties,
id: feature.id,
asset_id: feature.id
};
return {
...feature,
properties
};
});
if (drawToolbar && features) {
features.forEach(feature => {
if (!drawToolbar.loadedAssets.has(feature.id)) {
drawToolbar.loadedAssets.set(feature.id, {
id: feature.id,
type: 'Feature',
geometry: feature.geometry,
properties: feature.properties || {}
});
}
});
drawToolbar._updateMapSource();
} else {
if (map.getSource('assets')) {
map.getSource('assets').setData({
type: 'FeatureCollection',
features: features
});
}
}
updateStatusDisplay();
})
.catch(error => {
showMessage('{% translate "Erreur lors du chargement des données" %}', 'danger');
});
}
function updateStatusDisplay() {
const drawModeStatus = document.getElementById('draw-mode-status');
if (drawToolbar) {
const isActive = drawToolbar.isActive();
drawModeStatus.textContent = isActive ? '{% translate "Actif" %}' : '{% translate "Inactif" %}';
drawModeStatus.className = 'badge ' + (isActive ? 'bg-success' : 'bg-secondary');
}
const modifiedCount = document.getElementById('modified-count');
const modifiedInfo = document.getElementById('modified-info');
const modifiedAssetsCount = document.getElementById('modified-assets-count');
if (drawToolbar) {
const count = drawToolbar.modifiedGeometries.size;
modifiedCount.textContent = count;
modifiedCount.className = 'badge ' + (count > 0 ? 'bg-warning text-dark' : 'bg-secondary');
modifiedAssetsCount.textContent = count;
modifiedInfo.style.display = count > 0 ? 'block' : 'none';
}
const loadedCount = document.getElementById('loaded-count');
if (drawToolbar) {
loadedCount.textContent = drawToolbar.loadedAssets.size;
}
const drawCount = document.getElementById('draw-count');
if (draw) {
drawCount.textContent = draw.getAll().features.length;
}
}
function setupPanelToggle() {
const panelToggle = document.getElementById('panel-toggle');
panelToggle.addEventListener('click', function() {
const panel = document.getElementById('attributes-panel');
const icon = document.getElementById('toggle-icon');
panel.classList.toggle('collapsed');
if (panel.classList.contains('collapsed')) {
icon.className = 'bi bi-chevron-up';
} else {
icon.className = 'bi bi-chevron-down';
}
});
}
function setupModelBadges() {
document.querySelectorAll('.model-badge:not(.bulb-badge)').forEach(badge => {
badge.addEventListener('click', function(e) {
e.preventDefault();
if (this.classList.contains('disabled')) return;
const value = this.dataset.value;
setModelValue(value);
// If in draw mode, update the model for all selected features
if (drawToolbar?.isActive()) {
// Get the color for the selected model
const modelColor = this.dataset.color || '#3bb2d0';
// Get all features currently in draw control
const allDrawFeatures = drawToolbar.draw.getAll().features;
// Update model for all features in draw control
allDrawFeatures.forEach(feature => {
const featureId = feature.id;
// Get or create modification entry
let mod = drawToolbar.modifiedGeometries.get(featureId);
if (!mod) {
// Feature exists but hasn't been modified yet - create entry
const asset = drawToolbar.loadedAssets.get(featureId);
mod = {
assetId: featureId,
assetType: drawToolbar.layerConfig.assetType,
originalGeometry: asset?.geometry || feature.geometry,
currentGeometry: feature.geometry,
isNew: feature.properties?.isNew || false
};
drawToolbar.modifiedGeometries.set(featureId, mod);
}
// Update model
mod.modelId = value;
if (!mod.properties) mod.properties = {};
mod.properties.model_id = value;
mod.properties.color = modelColor;
// Also update loadedAssets
const asset = drawToolbar.loadedAssets.get(featureId);
if (asset) {
if (!asset.properties) asset.properties = {};
asset.properties.model_id = value;
asset.properties.color = modelColor;
}
// Update draw feature properties with color
feature.properties = feature.properties || {};
feature.properties.model_id = value;
feature.properties.color = modelColor;
// Update the feature in draw control to refresh the display
draw.add({
id: featureId,
type: 'Feature',
geometry: feature.geometry,
properties: feature.properties
});
});
// Update the modified count display
if (drawToolbar._updateModifiedCount) {
drawToolbar._updateModifiedCount();
}
}
});
});
}
function setupBulbBadges() {
// Toggle panel visibility
document.getElementById('btn-toggle-bulbs')?.addEventListener('click', function() {
const panel = document.getElementById('bulb-types-panel');
const icon = document.getElementById('bulbs-toggle-icon');
if (panel.style.display === 'none') {
panel.style.display = '';
icon.className = 'bi bi-chevron-up';
} else {
panel.style.display = 'none';
icon.className = 'bi bi-chevron-down';
}
});
// Badge click: toggle selection (multi-select)
document.querySelectorAll('.bulb-badge').forEach(badge => {
badge.addEventListener('click', function(e) {
e.preventDefault();
if (this.classList.contains('disabled')) return;
const value = this.dataset.value;
if (selectedBulbTypes.has(value)) {
selectedBulbTypes.delete(value);
} else {
selectedBulbTypes.add(value);
}
this.classList.toggle('active', selectedBulbTypes.has(value));
const input = document.getElementById('bulb-types-input');
if (input) input.value = JSON.stringify(Array.from(selectedBulbTypes));
// In draw mode, propagate to all features currently in draw control
if (drawToolbar?.isActive()) {
const bulbTypesArray = Array.from(selectedBulbTypes);
const allDrawFeatures = drawToolbar.draw.getAll().features;
allDrawFeatures.forEach(feature => {
const featureId = feature.id;
let mod = drawToolbar.modifiedGeometries.get(featureId);
if (!mod) {
const asset = drawToolbar.loadedAssets.get(featureId);
mod = {
assetId: featureId,
assetType: drawToolbar.layerConfig.assetType,
originalGeometry: asset?.geometry || feature.geometry,
currentGeometry: feature.geometry,
isNew: feature.properties?.isNew || false
};
drawToolbar.modifiedGeometries.set(featureId, mod);
}
mod.bulbTypes = bulbTypesArray;
const asset = drawToolbar.loadedAssets.get(featureId);
if (asset) {
if (!asset.properties) asset.properties = {};
asset.properties.bulb_types = bulbTypesArray;
}
});
if (drawToolbar._updateModifiedCount) drawToolbar._updateModifiedCount();
}
});
});
}
function setupActionButtons() {
document.getElementById('btn-save').addEventListener('click', handleBulkSave);
document.getElementById('btn-clear-selection').addEventListener('click', function() {
clearSelection();
hideAttributePanel();
});
setupBulbBadges();
}
function setupFloatingExitButton() {
if (!drawToolbar || !drawToolbar.floatingActionsContainer || !drawToolbar.saveBtn) return;
const container = drawToolbar.floatingActionsContainer;
let row = container.querySelector('.draw-floating-actions-row');
if (!row) {
row = document.createElement('div');
row.className = 'draw-floating-actions-row';
container.insertBefore(row, drawToolbar.saveBtn);
row.appendChild(drawToolbar.saveBtn);
}
if (!row.querySelector('.draw-floating-btn-exit')) {
const exitBtn = document.createElement('button');
exitBtn.type = 'button';
exitBtn.className = 'draw-floating-btn draw-floating-btn-exit';
exitBtn.title = '{% translate "Quitter" %}';
exitBtn.innerHTML = `<span class="label">{% translate "Quitter" %}</span>`;
exitBtn.addEventListener('click', (e) => {
e.preventDefault();
if (drawToolbar.isActive()) {
drawToolbar.toggleDrawMode();
}
});
row.insertBefore(exitBtn, drawToolbar.saveBtn);
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', initMap);
})();
</script>
{% endblock extra_scripts %}