Compare commits

...

2 commits

6 changed files with 883 additions and 332 deletions

View file

@ -10,37 +10,7 @@ const map = new maplibregl.Map({
antialias: true antialias: true
}); });
// Custom Plans Control in Top Right (first, at the top) // Plans & Calques are integrated into LayerSwitcherControl below
class CustomPlansControl {
onAdd(map) {
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
const button = document.createElement('button');
button.type = 'button';
button.id = 'btn-load-georef-plan';
button.className = 'btn btn-xs btn-light bg-white border shadow-sm d-flex align-items-center gap-1';
button.style.border = 'none';
button.style.padding = '8px 12px';
button.style.height = 'auto';
button.style.width = 'auto';
button.style.fontWeight = 'bold';
button.style.fontSize = '0.75rem';
button.innerHTML = '<i class="bi bi-geo-alt-fill text-primary"></i> Plans';
// Toggle bootstrap modal natively
button.setAttribute('data-bs-toggle', 'modal');
button.setAttribute('data-bs-target', '#georefPlanModal');
this._container.appendChild(button);
return this._container;
}
onRemove() {
this._container.parentNode.removeChild(this._container);
this._map = undefined;
}
}
map.addControl(new CustomPlansControl(), 'top-right');
// ── Helper to determine where to place the orthophoto layer ── // ── Helper to determine where to place the orthophoto layer ──
function getOrthoBeforeLayer(m) { function getOrthoBeforeLayer(m) {
@ -140,9 +110,10 @@ function reorderIntersectionLayers() {
}); });
} }
// --- Contrôle photo aérienne (LayerSwitcherControl) --- (second, below Plans button) // --- Contrôle photo aérienne & Plans (LayerSwitcherControl) ---
let layerSwitcher = null;
if (typeof LayerSwitcherControl !== 'undefined') { if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({ layerSwitcher = new LayerSwitcherControl({
layers: [ layers: [
{ {
id: 'ortho', id: 'ortho',
@ -883,7 +854,25 @@ if (georeferencedDocumentsDataEl) {
} }
} }
const dxfFilesDataEl = document.getElementById('dxfFilesData');
let dxfFiles = [];
if (dxfFilesDataEl) {
try {
dxfFiles = JSON.parse(dxfFilesDataEl.textContent);
} catch (e) {
console.error("Error parsing dxf files data:", e);
}
}
const crossroadPlansConfigEl = document.getElementById('crossroadPlansConfig');
let crossroadPlansConfig = {};
if (crossroadPlansConfigEl) {
try {
crossroadPlansConfig = JSON.parse(crossroadPlansConfigEl.textContent);
} catch (e) {
console.error("Error parsing crossroad plans config:", e);
}
}
function showGeorefImage(doc, active, opacity) { function showGeorefImage(doc, active, opacity) {
const srcId = `georef-image-overlay-${doc.id}`; const srcId = `georef-image-overlay-${doc.id}`;
@ -932,11 +921,13 @@ function showGeorefImage(doc, active, opacity) {
} }
// Zoom/fitBounds to document // Zoom/fitBounds to document
const coords = doc.coords; if (doc.coords && doc.coords.length > 0) {
const bounds = coords.reduce((acc, coord) => { const coords = doc.coords;
return acc.extend(coord); const bounds = coords.reduce((acc, coord) => {
}, new maplibregl.LngLatBounds(coords[0], coords[0])); return acc.extend(coord);
map.fitBounds(bounds, { padding: 40, maxZoom: 19 }); }, new maplibregl.LngLatBounds(coords[0], coords[0]));
map.fitBounds(bounds, { padding: 40, maxZoom: 19 });
}
} }
let fullDxfGeoJson = null; let fullDxfGeoJson = null;
@ -981,119 +972,33 @@ function applyDxfVectorFilters() {
if (map.getSource('dxf-layout')) { if (map.getSource('dxf-layout')) {
map.getSource('dxf-layout').setData(filteredData); map.getSource('dxf-layout').setData(filteredData);
const isVisible = selectedLayers.length > 0;
['dxf-layout-fills', 'dxf-layout-lines', 'dxf-layout-symbols'].forEach(lId => { ['dxf-layout-fills', 'dxf-layout-lines', 'dxf-layout-symbols'].forEach(lId => {
if (map.getLayer(lId)) { if (map.getLayer(lId)) {
map.setLayoutProperty(lId, 'visibility', 'visible'); map.setLayoutProperty(lId, 'visibility', isVisible ? 'visible' : 'none');
} }
}); });
reorderIntersectionLayers(); reorderIntersectionLayers();
} }
} }
function updateButtonActiveState() {
const btn = document.getElementById('btn-load-georef-plan');
if (!btn) return;
let anyImageActive = false;
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
if (cb.checked) anyImageActive = true;
});
let anyVectorActive = false;
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
if (cb.checked) anyVectorActive = true;
});
if (anyImageActive || anyVectorActive) {
btn.classList.remove('btn-light', 'bg-white', 'border');
btn.classList.add('btn-primary');
} else {
btn.classList.remove('btn-primary');
btn.classList.add('btn-light', 'bg-white', 'border');
}
}
const noGeorefImagesAlert = document.getElementById('no-georef-images-alert');
const imagePlansList = document.getElementById('image-plans-list');
if (imagePlansList) {
imagePlansList.innerHTML = '';
if (georeferencedDocuments.length === 0) {
if (noGeorefImagesAlert) noGeorefImagesAlert.style.display = 'block';
} else {
if (noGeorefImagesAlert) noGeorefImagesAlert.style.display = 'none';
georeferencedDocuments.forEach(doc => {
const item = document.createElement('div');
item.className = 'image-plan-item p-3 bg-light border rounded-3 d-flex flex-column gap-2';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center">
<span class="text-dark font-weight-bold" style="font-size: 0.9rem;">
<i class="bi ${doc.is_dxf ? 'bi-file-earmark-code text-success' : 'bi-file-earmark-image text-primary'} me-2"></i>${doc.title}
</span>
<div class="form-check form-switch">
<input class="form-check-input image-plan-toggle" type="checkbox" role="switch" id="switch-georef-img-${doc.id}" data-id="${doc.id}">
</div>
</div>
<div class="image-plan-opacity-container" id="opacity-container-${doc.id}" style="display: none;">
<label class="form-label font-weight-semibold text-muted small uppercase d-flex justify-content-between mb-1">
<span>Opacité</span>
<span class="opacity-val text-primary font-weight-bold">95%</span>
</label>
<input type="range" class="form-range image-plan-opacity-slider" min="0" max="100" value="95" id="range-georef-img-opacity-${doc.id}" data-id="${doc.id}">
</div>
`;
imagePlansList.appendChild(item);
const toggle = item.querySelector('.image-plan-toggle');
const opacityContainer = item.querySelector('.image-plan-opacity-container');
const slider = item.querySelector('.image-plan-opacity-slider');
const opacityVal = item.querySelector('.opacity-val');
toggle.addEventListener('change', () => {
const active = toggle.checked;
if (active) {
opacityContainer.style.display = 'block';
} else {
opacityContainer.style.display = 'none';
}
const opacity = parseFloat(slider.value) / 100.0;
showGeorefImage(doc, active, opacity);
updateButtonActiveState();
});
slider.addEventListener('input', () => {
const val = slider.value;
opacityVal.textContent = val + '%';
const opacity = parseFloat(val) / 100.0;
showGeorefImage(doc, toggle.checked, opacity);
});
});
}
}
const dxfFileSelect = document.getElementById('select-dxf-file');
let detailLayersFetched = {}; let detailLayersFetched = {};
let detailLayersResponseData = {}; let detailLayersResponseData = {};
function fetchDetailLayers() { function fetchDetailLayers(file, autoCheckAll = false) {
if (!dxfFileSelect) return; if (!file) return;
const file = dxfFileSelect.value;
if (!file) { const layersAccordion = document.getElementById('dxf-layers-accordion');
const section = document.getElementById('dxf-detail-layers-section'); if (layersAccordion) layersAccordion.style.display = 'block';
if (section) section.style.display = 'none';
return;
}
if (detailLayersFetched[file]) { if (detailLayersFetched[file]) {
renderDetailLayers(file); renderDetailLayers(file, autoCheckAll);
fetchDxfGeoJson(file); fetchDxfGeoJson(file);
return; return;
} }
const spinner = document.getElementById('dxf-detail-layers-spinner'); const spinner = document.getElementById('dxf-detail-layers-spinner');
const list = document.getElementById('dxf-detail-layers-list'); const list = document.getElementById('dxf-detail-layers-list');
const section = document.getElementById('dxf-detail-layers-section');
if (section) section.style.display = 'block';
if (spinner) spinner.style.display = 'block'; if (spinner) spinner.style.display = 'block';
if (list) list.style.display = 'none'; if (list) list.style.display = 'none';
@ -1102,101 +1007,389 @@ function fetchDetailLayers() {
.then(data => { .then(data => {
detailLayersFetched[file] = data.layers || []; detailLayersFetched[file] = data.layers || [];
detailLayersResponseData[file] = data; detailLayersResponseData[file] = data;
renderDetailLayers(file); renderDetailLayers(file, autoCheckAll);
fetchDxfGeoJson(file); fetchDxfGeoJson(file);
if (spinner) spinner.style.display = 'none'; if (spinner) spinner.style.display = 'none';
if (list) list.style.display = 'block'; if (list) list.style.display = 'flex';
}) })
.catch(err => { .catch(err => {
console.error("Error fetching detail layers:", err); console.error("Error fetching detail layers:", err);
if (spinner) { if (spinner) {
spinner.innerHTML = `<div class="text-danger small"><i class="bi bi-exclamation-triangle-fill"></i> Erreur de chargement.</div>`; spinner.innerHTML = `<div class="text-danger" style="font-size: 10px;"><i class="bi bi-exclamation-triangle-fill"></i> Erreur de chargement.</div>`;
} }
}); });
} }
function renderDetailLayers(file) { function renderDetailLayers(file, autoCheckAll = false) {
const container = document.getElementById('dxf-detail-layers-list'); const container = document.getElementById('dxf-detail-layers-list');
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
const layers = detailLayersFetched[file] || []; const layers = detailLayersFetched[file] || [];
const responseData = detailLayersResponseData[file] || {};
const selectedLayers = responseData.selected_layers || [];
layers.forEach(layer => { layers.forEach(layer => {
const isChecked = false;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'form-check py-1 d-flex align-items-center justify-content-between'; div.className = 'd-flex align-items-center justify-content-between py-1 px-1 rounded';
div.style.fontSize = '11px';
div.innerHTML = ` div.innerHTML = `
<div class="d-flex align-items-center"> <div class="d-flex align-items-center gap-1 text-truncate" style="max-width: 200px;">
<span class="color-indicator me-2" style="display: inline-block; width: 14px; height: 14px; border-radius: 3px; background-color: ${layer.color}; border: 1px solid rgba(0,0,0,0.1);"></span> <span class="color-indicator" style="width: 9px; height: 9px; border-radius: 2px; background-color: ${layer.color}; border: 1px solid rgba(0,0,0,0.15); flex-shrink: 0;"></span>
<label class="form-check-label text-dark font-weight-medium small" for="dxf-detail-layer-${layer.name}"> <label class="form-check-label text-truncate mb-0" for="dxf-detail-layer-${layer.name}" style="cursor: pointer; font-size: 11px; color: #334155;" title="${layer.name}">
${layer.name} ${layer.name}
</label> </label>
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch mb-0" style="padding-left: 1.8em;">
<input class="form-check-input dxf-vector-layer-toggle" type="checkbox" role="switch" <input class="form-check-input dxf-vector-layer-toggle" type="checkbox" role="switch"
id="dxf-detail-layer-${layer.name}" data-layer="${layer.name}" ${isChecked ? 'checked' : ''}> id="dxf-detail-layer-${layer.name}" data-layer="${layer.name}" ${autoCheckAll ? 'checked' : ''} style="cursor: pointer; width: 28px; height: 14px;">
</div> </div>
`; `;
container.appendChild(div); container.appendChild(div);
const toggle = div.querySelector('.dxf-vector-layer-toggle'); const toggle = div.querySelector('.dxf-vector-layer-toggle');
toggle.addEventListener('change', () => { toggle.addEventListener('change', () => {
applyDxfVectorFilters(); applyDxfVectorFilters();
updateButtonActiveState(); updatePlansActiveState();
}); });
}); });
const totalCountEl = document.getElementById('dxf-total-layers-count');
if (totalCountEl) totalCountEl.textContent = layers.length;
updatePlansActiveState();
} }
if (dxfFileSelect) { function updatePlansActiveState() {
dxfFileSelect.addEventListener('change', () => { let activeImages = 0;
fetchDetailLayers(); document.querySelectorAll('.image-plan-toggle').forEach(cb => {
if (cb.checked) activeImages++;
}); });
// Auto-load if there is a default select value or single dxf let activeDxfLayers = 0;
if (dxfFileSelect.value) { let totalDxfLayers = 0;
fetchDetailLayers(); document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
totalDxfLayers++;
if (cb.checked) activeDxfLayers++;
});
const activeCount = activeImages + (activeDxfLayers > 0 ? 1 : 0);
const hasAnyActive = activeImages > 0 || activeDxfLayers > 0;
const countBadge = document.getElementById('plans-active-count-badge');
if (countBadge) {
if (hasAnyActive) {
countBadge.style.display = 'inline-block';
countBadge.textContent = `${activeCount} actif${activeCount > 1 ? 's' : ''}`;
} else {
countBadge.style.display = 'none';
}
}
const dxfActiveCountEl = document.getElementById('dxf-active-layers-count');
if (dxfActiveCountEl) dxfActiveCountEl.textContent = activeDxfLayers;
const dxfTotalCountEl = document.getElementById('dxf-total-layers-count');
if (dxfTotalCountEl) dxfTotalCountEl.textContent = totalDxfLayers;
const switchDxfMaster = document.getElementById('switch-dxf-master');
if (switchDxfMaster) {
switchDxfMaster.checked = activeDxfLayers > 0;
}
const planDxfCard = document.getElementById('plan-dxf-card');
if (planDxfCard) {
planDxfCard.classList.toggle('active', activeDxfLayers > 0);
}
const footerActions = document.getElementById('plans-footer-actions');
if (footerActions) {
footerActions.style.display = hasAnyActive ? 'block' : 'none';
}
const dxfToggleAllBtn = document.getElementById('dxf-detail-toggle-all-layers');
if (dxfToggleAllBtn && totalDxfLayers > 0) {
dxfToggleAllBtn.textContent = (activeDxfLayers === totalDxfLayers) ? "Décocher tout" : "Tout cocher";
}
if (layerSwitcher && typeof layerSwitcher.setOverlayActive === 'function') {
layerSwitcher.setOverlayActive(hasAnyActive);
} }
} }
const btnToggleAllLayers = document.getElementById('dxf-detail-toggle-all-layers'); function renderCrossroadPlansSection(container, control) {
if (btnToggleAllLayers) { const importDxfUrl = crossroadPlansConfig && crossroadPlansConfig.importDxfUrl;
btnToggleAllLayers.addEventListener('click', () => { const hasImages = georeferencedDocuments && georeferencedDocuments.length > 0;
const checkboxes = document.querySelectorAll('.dxf-vector-layer-toggle'); const hasDxf = dxfFiles && dxfFiles.length > 0;
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
checkboxes.forEach(cb => {
cb.checked = !allChecked;
});
btnToggleAllLayers.textContent = allChecked ? "Tout cocher" : "Décocher tout";
applyDxfVectorFilters();
updateButtonActiveState();
});
}
const btnDisableAllPlans = document.getElementById('btn-disable-all-plans'); // 1. Header with title & active badge
if (btnDisableAllPlans) { const headerEl = document.createElement('div');
btnDisableAllPlans.addEventListener('click', () => { headerEl.className = 'layer-switcher-subtitle';
headerEl.innerHTML = `
<span class="d-flex align-items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
Plans du carrefour
</span>
<span class="badge bg-light text-primary border" id="plans-active-count-badge" style="font-size: 9px; display: none;">0 actif</span>
`;
container.appendChild(headerEl);
// Empty state if neither image nor DXF
if (!hasImages && !hasDxf) {
const emptyEl = document.createElement('div');
emptyEl.className = 'text-center py-2 px-1';
emptyEl.innerHTML = `
<div class="text-muted" style="font-size: 11px; margin-bottom: 4px;">Aucun plan géoréférencé</div>
${importDxfUrl ? `<a href="${importDxfUrl}" class="layer-switcher-btn-action"><i class="bi bi-upload"></i> Importer un plan DXF</a>` : ''}
`;
container.appendChild(emptyEl);
return;
}
// 2. Image Plans Sub-section
if (hasImages) {
const imgTitleEl = document.createElement('div');
imgTitleEl.className = 'layer-switcher-subtitle text-muted';
imgTitleEl.style.fontSize = '9px';
imgTitleEl.innerHTML = `<span><i class="bi bi-file-earmark-image me-1"></i> Plans images (${georeferencedDocuments.length})</span>`;
container.appendChild(imgTitleEl);
georeferencedDocuments.forEach(doc => {
const card = document.createElement('div');
card.className = 'layer-switcher-card';
card.id = `plan-img-card-${doc.id}`;
card.innerHTML = `
<div class="d-flex align-items-center justify-content-between">
<label class="form-check-label d-flex align-items-center gap-1 mb-0" for="switch-georef-img-${doc.id}" style="cursor: pointer; font-size: 11.5px; font-weight: 600; color: #1e293b; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${doc.title}">
<i class="bi ${doc.is_dxf ? 'bi-file-earmark-code text-success' : 'bi-file-earmark-image text-primary'}" style="flex-shrink: 0; font-size: 12px;"></i>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${doc.title}</span>
</label>
<div class="form-check form-switch mb-0" style="padding-left: 2em;">
<input class="form-check-input image-plan-toggle" type="checkbox" role="switch" id="switch-georef-img-${doc.id}" data-id="${doc.id}" style="cursor: pointer; width: 28px; height: 14px;">
</div>
</div>
<div class="layer-switcher-slider-row" id="opacity-container-${doc.id}" style="display: none;">
<label>Opacité</label>
<input type="range" class="form-range image-plan-opacity-slider" min="0" max="100" value="95" id="range-georef-img-opacity-${doc.id}" data-id="${doc.id}">
<span class="layer-switcher-slider-val opacity-val">95%</span>
</div>
`;
container.appendChild(card);
const toggle = card.querySelector('.image-plan-toggle');
const opacityRow = card.querySelector('.layer-switcher-slider-row');
const slider = card.querySelector('.image-plan-opacity-slider');
const opacityVal = card.querySelector('.opacity-val');
toggle.addEventListener('change', () => {
const active = toggle.checked;
card.classList.toggle('active', active);
opacityRow.style.display = active ? 'flex' : 'none';
const opacity = parseFloat(slider.value) / 100.0;
showGeorefImage(doc, active, opacity);
updatePlansActiveState();
});
slider.addEventListener('input', () => {
const val = slider.value;
opacityVal.textContent = val + '%';
const opacity = parseFloat(val) / 100.0;
const lyrId = `georef-image-overlay-layer-${doc.id}`;
if (map.getLayer(lyrId)) {
map.setPaintProperty(lyrId, 'raster-opacity', opacity);
}
});
});
}
// 3. DXF Vector Plan Sub-section
if (hasDxf) {
const dxfTitleEl = document.createElement('div');
dxfTitleEl.className = 'layer-switcher-subtitle text-muted mt-1';
dxfTitleEl.style.fontSize = '9px';
dxfTitleEl.innerHTML = `<span><i class="bi bi-file-earmark-code me-1"></i> Plan CAO (DXF)</span>`;
container.appendChild(dxfTitleEl);
const dxfCard = document.createElement('div');
dxfCard.className = 'layer-switcher-card';
dxfCard.id = 'plan-dxf-card';
let dxfSelectHtml = '';
if (dxfFiles.length === 1) {
dxfSelectHtml = `
<input type="hidden" id="select-dxf-file" value="${dxfFiles[0]}">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="small font-weight-bold text-dark text-truncate" style="font-size: 11.5px; max-width: 190px;" title="${dxfFiles[0]}">
<i class="bi bi-file-earmark-check-fill text-success me-1"></i>${dxfFiles[0]}
</span>
<div class="form-check form-switch mb-0" style="padding-left: 2em;">
<input class="form-check-input" type="checkbox" role="switch" id="switch-dxf-master" style="cursor: pointer; width: 28px; height: 14px;" title="Afficher/masquer le plan CAO">
</div>
</div>
`;
} else {
dxfSelectHtml = `
<div class="d-flex align-items-center justify-content-between gap-1 mb-2">
<select id="select-dxf-file" class="form-select form-select-sm" style="font-size: 11px; padding: 2px 6px; flex-grow: 1;">
<option value="">-- Sélectionner un fichier DXF --</option>
${dxfFiles.map(f => `<option value="${f}">${f}</option>`).join('')}
</select>
<div class="form-check form-switch mb-0 ps-2">
<input class="form-check-input" type="checkbox" role="switch" id="switch-dxf-master" style="cursor: pointer; width: 28px; height: 14px;" title="Afficher/masquer le plan CAO">
</div>
</div>
`;
}
dxfCard.innerHTML = `
${dxfSelectHtml}
<div id="dxf-layers-accordion" style="display: none; margin-top: 4px;">
<div class="d-flex align-items-center justify-content-between py-1 px-1 bg-white border rounded mb-1" style="font-size: 10.5px;">
<button type="button" id="btn-toggle-dxf-accordion" class="btn btn-link p-0 text-decoration-none text-dark font-weight-bold d-flex align-items-center gap-1" style="font-size: 10.5px;">
<i class="bi bi-chevron-down" id="dxf-accordion-chevron" style="transition: transform 0.2s ease; font-size: 10px;"></i>
<span>Couches (<span id="dxf-active-layers-count">0</span>/<span id="dxf-total-layers-count">0</span>)</span>
</button>
<button type="button" id="dxf-detail-toggle-all-layers" class="btn btn-link p-0 text-decoration-none text-primary font-weight-semibold" style="font-size: 10px;">
Tout cocher
</button>
</div>
<div id="dxf-detail-layers-spinner" class="text-center py-2" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary" style="width: 14px; height: 14px; border-width: 1.5px;" role="status"></div>
<span class="text-muted ms-1" style="font-size: 10px;">Chargement...</span>
</div>
<div id="dxf-detail-layers-list" style="max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; padding-right: 2px;">
<!-- Checkboxes populated dynamically -->
</div>
</div>
`;
container.appendChild(dxfCard);
// Wire up DXF select and master toggle
const dxfSelectEl = dxfCard.querySelector('#select-dxf-file');
const dxfMasterSwitch = dxfCard.querySelector('#switch-dxf-master');
const accordionBtn = dxfCard.querySelector('#btn-toggle-dxf-accordion');
const accordionChevron = dxfCard.querySelector('#dxf-accordion-chevron');
const layersListEl = dxfCard.querySelector('#dxf-detail-layers-list');
const layersAccordion = dxfCard.querySelector('#dxf-layers-accordion');
const toggleAllBtn = dxfCard.querySelector('#dxf-detail-toggle-all-layers');
let isAccordionExpanded = true;
if (accordionBtn) {
accordionBtn.addEventListener('click', (e) => {
e.stopPropagation();
isAccordionExpanded = !isAccordionExpanded;
layersListEl.style.display = isAccordionExpanded ? 'flex' : 'none';
if (toggleAllBtn) toggleAllBtn.style.display = isAccordionExpanded ? 'inline' : 'none';
accordionChevron.style.transform = isAccordionExpanded ? 'rotate(0deg)' : 'rotate(-90deg)';
});
}
if (dxfMasterSwitch) {
dxfMasterSwitch.addEventListener('change', (e) => {
e.stopPropagation();
const isMasterOn = dxfMasterSwitch.checked;
dxfCard.classList.toggle('active', isMasterOn);
const file = dxfSelectEl ? dxfSelectEl.value : '';
if (!file) {
dxfMasterSwitch.checked = false;
return;
}
if (!detailLayersFetched[file]) {
fetchDetailLayers(file, isMasterOn);
} else {
const checkboxes = dxfCard.querySelectorAll('.dxf-vector-layer-toggle');
checkboxes.forEach(cb => {
cb.checked = isMasterOn;
});
applyDxfVectorFilters();
updatePlansActiveState();
}
if (isMasterOn) {
layersAccordion.style.display = 'block';
}
});
}
if (dxfSelectEl && dxfSelectEl.tagName === 'SELECT') {
dxfSelectEl.addEventListener('change', (e) => {
e.stopPropagation();
const file = dxfSelectEl.value;
if (file) {
fetchDetailLayers(file, dxfMasterSwitch.checked);
} else {
layersAccordion.style.display = 'none';
if (dxfMasterSwitch) dxfMasterSwitch.checked = false;
applyDxfVectorFilters();
updatePlansActiveState();
}
});
}
if (toggleAllBtn) {
toggleAllBtn.addEventListener('click', (e) => {
e.stopPropagation();
const checkboxes = dxfCard.querySelectorAll('.dxf-vector-layer-toggle');
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
checkboxes.forEach(cb => {
cb.checked = !allChecked;
});
toggleAllBtn.textContent = allChecked ? "Tout cocher" : "Décocher tout";
applyDxfVectorFilters();
updatePlansActiveState();
});
}
// If single DXF, prepare/fetch layers
if (dxfSelectEl && dxfSelectEl.value) {
fetchDetailLayers(dxfSelectEl.value, false);
}
}
// 4. Footer Actions (Désactiver tous les plans)
const footerEl = document.createElement('div');
footerEl.id = 'plans-footer-actions';
footerEl.style.display = 'none';
footerEl.style.marginTop = '6px';
footerEl.innerHTML = `
<button type="button" id="btn-disable-all-plans" class="layer-switcher-btn-action btn-danger-light">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
Désactiver tous les plans
</button>
`;
container.appendChild(footerEl);
const btnDisableAll = footerEl.querySelector('#btn-disable-all-plans');
btnDisableAll.addEventListener('click', (e) => {
e.stopPropagation();
// Turn off image plans
document.querySelectorAll('.image-plan-toggle').forEach(cb => { document.querySelectorAll('.image-plan-toggle').forEach(cb => {
if (cb.checked) { if (cb.checked) {
cb.checked = false; cb.checked = false;
cb.dispatchEvent(new Event('change')); cb.dispatchEvent(new Event('change'));
} }
}); });
// Turn off DXF layers
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => { document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
if (cb.checked) { cb.checked = false;
cb.checked = false;
}
}); });
const switchMaster = document.getElementById('switch-dxf-master');
if (switchMaster) switchMaster.checked = false;
const dxfCard = document.getElementById('plan-dxf-card');
if (dxfCard) dxfCard.classList.remove('active');
applyDxfVectorFilters(); applyDxfVectorFilters();
updatePlansActiveState();
const modalEl = document.getElementById('georefPlanModal'); });
if (modalEl) { }
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide(); // Register section with LayerSwitcherControl
if (layerSwitcher) {
layerSwitcher.addCustomSection({
id: 'crossroad-plans',
render: (container, control) => {
renderCrossroadPlansSection(container, control);
} }
updateButtonActiveState();
}); });
} }

View file

@ -597,7 +597,7 @@
<th>{% translate "Code Asset" %}</th> <th>{% translate "Code Asset" %}</th>
<th>{% translate "Nom" %}</th> <th>{% translate "Nom" %}</th>
<th>{% translate "Statut" %}</th> <th>{% translate "Statut" %}</th>
<th>{% translate "Géométrie (DB)" %}</th> <th>{% translate "Géométrie" %}</th>
<th>{% translate "Modèle Déduit (Excel)" %}</th> <th>{% translate "Modèle Déduit (Excel)" %}</th>
<th>{% translate "Modèle Actuel (DB)" %}</th> <th>{% translate "Modèle Actuel (DB)" %}</th>
<th>{% translate "Décision" %}</th> <th>{% translate "Décision" %}</th>
@ -628,8 +628,12 @@
</div> </div>
{% endfor %} {% endfor %}
</td> </td>
<td> <td id="geom_cell_cable_{{ item.code }}">
{% if item.has_db_geom %} {% if item.geom_modified %}
<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1">
<i class="bi bi-arrows-move"></i> {% translate "Tracé modifié" %}
</span>
{% elif item.has_db_geom %}
<span class="badge bg-info text-dark d-inline-flex align-items-center gap-1"> <span class="badge bg-info text-dark d-inline-flex align-items-center gap-1">
<i class="bi bi-bezier2"></i> {{ item.db_geom_length }} m <i class="bi bi-bezier2"></i> {{ item.db_geom_length }} m
</span> </span>
@ -1235,6 +1239,8 @@ document.addEventListener('DOMContentLoaded', () => {
{ {
code: "{{ item.code|escapejs }}", code: "{{ item.code|escapejs }}",
name: "{{ item.name|escapejs }}", name: "{{ item.name|escapejs }}",
status: "{{ item.status|escapejs }}",
geom_modified: {{ item.geom_modified|yesno:"true,false" }},
connected_poles: {{ item.connected_poles|safe|default:"[]" }} connected_poles: {{ item.connected_poles|safe|default:"[]" }}
}, },
{% endfor %} {% endfor %}
@ -1265,9 +1271,26 @@ document.addEventListener('DOMContentLoaded', () => {
let activePlacementPole = null; let activePlacementPole = null;
let activeEditCable = null; let activeEditCable = null;
const placedPoles = {};
const cableGeometries = {};
let cableHandles = []; let cableHandles = [];
const cableGeometries = {};
function markCableAsModified(code) {
const selectAction = document.querySelector(`select[name="action_cable_${code}"]`);
if (selectAction) {
if (selectAction.querySelector('option[value="modify"]')) {
selectAction.value = 'modify';
} else if (selectAction.querySelector('option[value="add"]')) {
selectAction.value = 'add';
}
}
const cb = document.querySelector(`input[name="selected_cable_${code}"]`);
if (cb) cb.checked = true;
const geomCell = document.getElementById(`geom_cell_cable_${code}`);
if (geomCell) {
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-arrows-move"></i> {% translate "Tracé modifié" %}</span>`;
}
}
const snapCandidates = initialDxfPoles.map(dp => [dp.lon, dp.lat, dp.x_3812, dp.y_3812]); const snapCandidates = initialDxfPoles.map(dp => [dp.lon, dp.lat, dp.x_3812, dp.y_3812]);
@ -1530,7 +1553,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Automatically place markers for poles that have known coordinates (e.g. from DXF or previous georeferencing) // Automatically place markers for poles that have known coordinates (e.g. from DXF or previous georeferencing)
polesList.forEach(p => { polesList.forEach(p => {
if (p.status !== 'db_only' && p.new_lon !== null && p.new_lat !== null) { if (p.status !== 'db_only' && p.new_lon !== null && p.new_lat !== null) {
placePoleMarker(p.code, p.new_lon, p.new_lat, p.new_x_3812, p.new_y_3812, false); placePoleMarker(p.code, p.new_lon, p.new_lat, p.new_x_3812, p.new_y_3812, false, true);
} }
}); });
@ -1585,7 +1608,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('placed-poles-count').textContent = `${placed} / ${total}`; document.getElementById('placed-poles-count').textContent = `${placed} / ${total}`;
} }
function markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812) { function markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812, isInitialLoad = false) {
const badge = document.getElementById(`georef_badge_${code}`); const badge = document.getElementById(`georef_badge_${code}`);
if (badge) { if (badge) {
badge.classList.remove('bg-danger'); badge.classList.remove('bg-danger');
@ -1602,24 +1625,49 @@ document.addEventListener('DOMContentLoaded', () => {
const inputY = document.getElementById(`y_3812_pole_${code}`); const inputY = document.getElementById(`y_3812_pole_${code}`);
if (inputY) inputY.value = (y_3812 !== undefined && y_3812 !== null) ? y_3812 : ""; if (inputY) inputY.value = (y_3812 !== undefined && y_3812 !== null) ? y_3812 : "";
// Dynamically update the table row for this pole if position changed // If not initial load (i.e. user moved or placed the pole interactively), update decision and geom cell
const poleMeta = polesList.find(p => p.code === code); if (!isInitialLoad) {
if (poleMeta) { const poleMeta = polesList.find(p => p.code === code);
const selectAction = document.querySelector(`select[name="action_pole_${code}"]`); if (poleMeta) {
if (selectAction) { const selectAction = document.querySelector(`select[name="action_pole_${code}"]`);
if (selectAction.querySelector('option[value="modify"]')) { const geomCell = document.getElementById(`geom_cell_pole_${code}`);
selectAction.value = 'modify'; const hasDbPos = (poleMeta.db_x_3812 !== null && poleMeta.db_y_3812 !== null && x_3812 !== null && y_3812 !== null);
let dist = hasDbPos ? Math.sqrt((poleMeta.db_x_3812 - x_3812)**2 + (poleMeta.db_y_3812 - y_3812)**2) : null;
const isIdenticalPos = (hasDbPos && dist < 0.05);
if (isIdenticalPos && poleMeta.status === 'identical') {
// Position identical and attributes identical: propose "Ne rien faire"
if (selectAction && selectAction.querySelector('option[value="keep"]')) {
selectAction.value = 'keep';
}
if (geomCell) {
geomCell.innerHTML = `<span class="badge bg-light text-muted border d-inline-flex align-items-center gap-1"><i class="bi bi-check2"></i> {% translate "Inchangée" %}</span> <button type="button" class="btn btn-xs btn-outline-secondary ms-1 show-pole-on-map-btn" data-code="${code}" title="{% translate 'Voir sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
}
} else {
// Position changed or newly placed: propose "Mise à jour"
if (selectAction) {
if (selectAction.querySelector('option[value="modify"]')) {
selectAction.value = 'modify';
} else if (selectAction.querySelector('option[value="add"]')) {
selectAction.value = 'add';
}
}
const cb = document.querySelector(`input[name="selected_pole_${code}"]`);
if (cb) cb.checked = true;
if (geomCell) {
let distText = (dist !== null) ? ` (${dist.toFixed(1)} m)` : "";
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-arrows-move"></i> {% translate "Position modifiée" %}${distText}</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-pole-on-map-btn" data-code="${code}" title="{% translate 'Voir et ajuster sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
}
// Any cable connected to this pole also has its geometry modified
cablesList.forEach(c => {
if (c.connected_poles && c.connected_poles.includes(code)) {
markCableAsModified(c.code);
}
});
} }
} }
const geomCell = document.getElementById(`geom_cell_pole_${code}`);
if (geomCell) {
let distText = "";
if (poleMeta.db_x_3812 !== null && poleMeta.db_y_3812 !== null && x_3812 && y_3812) {
const d = Math.sqrt((poleMeta.db_x_3812 - x_3812)**2 + (poleMeta.db_y_3812 - y_3812)**2);
distText = ` (${d.toFixed(1)} m)`;
}
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-geo-alt-fill"></i> {% translate "Positionné sur carte" %}${distText}</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-pole-on-map-btn" data-code="${code}" title="{% translate 'Voir et ajuster sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
}
} }
redrawCablesOnMap(); redrawCablesOnMap();
@ -1633,10 +1681,10 @@ document.addEventListener('DOMContentLoaded', () => {
const originalCoords = [e.lngLat.lng, e.lngLat.lat]; const originalCoords = [e.lngLat.lng, e.lngLat.lat];
const snapped = getSnappedCoords(originalCoords[0], originalCoords[1]); const snapped = getSnappedCoords(originalCoords[0], originalCoords[1]);
placePoleMarker(activePlacementPole, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1]); placePoleMarker(activePlacementPole, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1], true, false);
}); });
function placePoleMarker(code, lon, lat, x_3812, y_3812, autoAdvance = true) { function placePoleMarker(code, lon, lat, x_3812, y_3812, autoAdvance = true, isInitialLoad = false) {
if (placedPoles[code]) { if (placedPoles[code]) {
placedPoles[code].marker.remove(); placedPoles[code].marker.remove();
} }
@ -1678,10 +1726,10 @@ document.addEventListener('DOMContentLoaded', () => {
placedPoles[code].lat = snapped.coords[1]; placedPoles[code].lat = snapped.coords[1];
placedPoles[code].x_3812 = snapped.lambert[0]; placedPoles[code].x_3812 = snapped.lambert[0];
placedPoles[code].y_3812 = snapped.lambert[1]; placedPoles[code].y_3812 = snapped.lambert[1];
markPoleAsGeoreferenced(code, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1]); markPoleAsGeoreferenced(code, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1], false);
}); });
markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812); markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812, isInitialLoad);
const mapPane = document.getElementById('map-poles-pane'); const mapPane = document.getElementById('map-poles-pane');
mapPane.querySelectorAll('.pole-config-item').forEach(item => item.classList.remove('bg-light')); mapPane.querySelectorAll('.pole-config-item').forEach(item => item.classList.remove('bg-light'));
@ -1821,6 +1869,7 @@ document.addEventListener('DOMContentLoaded', () => {
cableGeometries[code][index] = snapped.coords; cableGeometries[code][index] = snapped.coords;
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(code);
}); });
el.addEventListener('dblclick', function(e) { el.addEventListener('dblclick', function(e) {
@ -1829,6 +1878,7 @@ document.addEventListener('DOMContentLoaded', () => {
drawCableHandles(code); drawCableHandles(code);
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(code);
}); });
}); });
} }
@ -1885,6 +1935,7 @@ document.addEventListener('DOMContentLoaded', () => {
drawCableHandles(activeEditCable); drawCableHandles(activeEditCable);
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(activeEditCable);
}); });
map.on('dblclick', function(e) { map.on('dblclick', function(e) {
@ -1907,6 +1958,7 @@ document.addEventListener('DOMContentLoaded', () => {
drawCableHandles(activeEditCable); drawCableHandles(activeEditCable);
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(activeEditCable);
}); });
// Hover mouse styles for active cable line // Hover mouse styles for active cable line
@ -1929,6 +1981,7 @@ document.addEventListener('DOMContentLoaded', () => {
drawCableHandles(activeEditCable); drawCableHandles(activeEditCable);
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(activeEditCable);
} }
}); });

View file

@ -241,6 +241,12 @@
<script type="application/json" id="georeferencedDocumentsData"> <script type="application/json" id="georeferencedDocumentsData">
{{ georeferenced_documents_json|safe }} {{ georeferenced_documents_json|safe }}
</script> </script>
{{ dxf_files|json_script:"dxfFilesData" }}
<script type="application/json" id="crossroadPlansConfig">
{
"importDxfUrl": "{% url 'assets:import_dxf' intersection.id %}"
}
</script>
<!-- Modal form add document --> <!-- Modal form add document -->
<div class="modal fade" id="addDocumentModal" tabindex="-1" aria-labelledby="addDocumentModalLabel" aria-hidden="true" style="z-index: 10050;"> <div class="modal fade" id="addDocumentModal" tabindex="-1" aria-labelledby="addDocumentModalLabel" aria-hidden="true" style="z-index: 10050;">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
@ -1108,107 +1114,6 @@
</div> </div>
{% endif %} {% endif %}
<!-- Georeferenced Plans Selection Modal -->
<div class="modal fade" id="georefPlanModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content rounded-3 border-0 shadow-lg">
<div class="modal-header bg-light border-bottom-0 py-3">
<h5 class="modal-title font-weight-bold text-dark"><i class="bi bi-geo-alt-fill text-primary me-2"></i>{% translate "Plans géoréférencés" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body py-3">
<!-- Nav Tabs -->
<ul class="nav nav-tabs nav-fill mb-3" id="georefPlanTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active font-weight-bold" id="tab-image-plan" data-bs-toggle="tab" data-bs-target="#panel-image-plan" type="button" role="tab" aria-controls="panel-image-plan" aria-selected="true">
<i class="bi bi-file-earmark-image me-1"></i>{% translate "Plan image" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link font-weight-bold" id="tab-vector-plan" data-bs-toggle="tab" data-bs-target="#panel-vector-plan" type="button" role="tab" aria-controls="panel-vector-plan" aria-selected="false">
<i class="bi bi-file-earmark-code me-1"></i>{% translate "Plan CAO" %}
</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content pt-2" id="georefPlanTabContent">
<!-- Tab 1: Image Plan -->
<div class="tab-pane fade show active" id="panel-image-plan" role="tabpanel" aria-labelledby="tab-image-plan">
<div id="image-plan-selection-container">
<div id="image-plans-list" class="d-flex flex-column gap-3 mb-2">
<!-- Dynamically populated list of images with switches and sliders -->
</div>
<div id="no-georef-images-alert" class="text-center py-4" style="display: none;">
<div class="text-muted mb-3"><i class="bi bi-info-circle" style="font-size: 2.5rem;"></i></div>
<p class="text-dark font-weight-semibold mb-1">{% translate "Aucun plan d'image géoréférencé disponible" %}</p>
<p class="text-muted small">{% translate "Géoréférencez d'abord un plan PDF ou image dans l'onglet Documents de la fiche." %}</p>
</div>
</div>
</div>
<!-- Tab 2: Vector Plan (DXF) -->
<div class="tab-pane fade" id="panel-vector-plan" role="tabpanel" aria-labelledby="tab-vector-plan">
<div id="dxf-selection-container">
{% if not dxf_files %}
<div class="text-center py-4">
<div class="text-muted mb-3"><i class="bi bi-exclamation-triangle" style="font-size: 2.5rem;"></i></div>
<p class="text-dark font-weight-semibold mb-1">{% translate "Aucun fichier DXF disponible pour ce carrefour" %}</p>
<p class="text-muted small">{% translate "Veuillez d'abord en importer un." %}</p>
<a href="{% url 'assets:import_dxf' intersection.id %}" class="btn btn-primary rounded-3 btn-sm mt-2 px-3">
<i class="bi bi-upload me-1"></i>{% translate "Importer un plan DXF" %}
</a>
</div>
{% else %}
<!-- File Selection -->
<div class="mb-3">
<label class="form-label font-weight-semibold text-muted small uppercase">{% translate "Sélectionner le plan DXF" %}</label>
{% if dxf_files|length == 1 %}
<div class="p-3 bg-light border rounded-3 font-weight-semibold text-dark mb-3">
<i class="bi bi-file-earmark-check-fill text-success me-2"></i>{{ dxf_files.0 }}
<input type="hidden" id="select-dxf-file" value="{{ dxf_files.0 }}">
</div>
{% else %}
<select id="select-dxf-file" class="form-select rounded-3 p-3 mb-3">
<option value="">{% translate "-- Sélectionner un plan CAO (DXF) --" %}</option>
{% for f in dxf_files %}
<option value="{{ f }}">{{ f }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<!-- Layers selection checklist -->
<div id="dxf-detail-layers-section" style="display: none;" class="mb-3">
<label class="form-label font-weight-semibold text-muted small uppercase d-flex justify-content-between align-items-center mb-2">
<span>{% translate "Couches vectorielles CAO" %}</span>
<button type="button" id="dxf-detail-toggle-all-layers" class="btn btn-link btn-sm p-0 text-decoration-none" style="font-size: 0.75rem;">
{% translate "Décocher tout" %}
</button>
</label>
<div id="dxf-detail-layers-spinner" class="text-center py-4" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<div class="text-muted small mt-2">{% translate "Lecture des couches du plan..." %}</div>
</div>
<div id="dxf-detail-layers-list" class="border rounded-3 p-3 bg-white" style="max-height: 250px; overflow-y: auto;">
<!-- Checkboxes will be populated dynamically -->
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="modal-footer border-top-0 pt-0 pb-4 justify-content-between px-4">
<button type="button" id="btn-disable-all-plans" class="btn btn-outline-danger px-3 rounded-3">
<i class="bi bi-eye-slash-fill me-1"></i>{% translate "Désactiver tout" %}
</button>
<button type="button" class="btn btn-secondary px-4 rounded-3 font-weight-semibold shadow-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endblock content%} {% endblock content%}

View file

@ -2715,6 +2715,164 @@ class TrafficLightCrossPlanImportTest(TestCase):
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.remove(tmp_path) os.remove(tmp_path)
def test_import_cross_plan_default_decisions_identical_vs_modified(self):
"""
Verify that:
- When pole & cable position and model are identical: status is 'identical',
default proposed decision in HTML is 'Ne rien faire (Identique)'.
- When pole position changed (DXF shift): status is 'modified',
default proposed decision in HTML is 'Mettre à jour l'asset'.
- When connected pole moved, the cable status becomes 'modified',
default proposed decision in HTML is 'Mettre à jour l'asset'.
"""
import openpyxl
import tempfile
from unittest.mock import patch
from django.contrib.gis.geos import Point as DjangoPoint, LineString as DjangoLineString
from assets.models import (
TrafficLightPole, TrafficLightCable,
TrafficLightPoleModel, TrafficLightCableModel
)
from django.core.files.uploadedfile import SimpleUploadedFile
pole_model = TrafficLightPoleModel.objects.first()
cable_model = TrafficLightCableModel.objects.first()
# Pole A01: position will be identical (DXF matches DB)
pole_a = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_A01",
name_fr="Poteau A01",
geom=DjangoPoint(149200.0, 170500.0, srid=3812),
lon=4.350,
lat=50.840,
model=pole_model,
status="active"
)
# Pole B01: position will change (DXF differs by 2.0m)
pole_b = TrafficLightPole.objects.create(
intersection=self.intersection,
code="SWB01_B01",
name_fr="Poteau B01",
geom=DjangoPoint(149300.0, 170600.0, srid=3812),
lon=4.351,
lat=50.841,
model=pole_model,
status="active"
)
# Cable 01: connects A01 and B01
cable_01 = TrafficLightCable.objects.create(
intersection=self.intersection,
code="SWB01_CAB01",
name_fr="Câble 01",
geom=DjangoLineString([(149200.0, 170500.0), (149300.0, 170600.0)], srid=3812),
model=cable_model,
status="active"
)
# Create mock workbook
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "kruisjesplan"
ws.cell(row=1, column=1, value="Sleutel:")
ws.cell(row=1, column=2, value="SWB01")
ws.cell(row=5, column=1, value=f"Câble 01 {cable_model.name_fr}")
ws.cell(row=6, column=3, value="richting")
ws.cell(row=6, column=4, value="kringen")
ws.cell(row=7, column=5, value="A01")
ws.cell(row=7, column=6, value="B01")
ws.cell(row=8, column=2, value="1")
ws.cell(row=8, column=3, value="A")
ws.cell(row=8, column=4, value="V")
ws.cell(row=8, column=5, value="X")
ws.cell(row=8, column=6, value="X")
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
wb.save(tmp_path)
# Mock DXF poles: A01 identical (0.0m diff), B01 shifted by 2.0m
mock_dxf_poles = [
{'label': 'A01', 'x_3812': 149200.0, 'y_3812': 170500.0, 'lon': 4.350, 'lat': 50.840},
{'label': 'B01', 'x_3812': 149302.0, 'y_3812': 170600.0, 'lon': 4.35102, 'lat': 50.841},
]
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("plan_test_decisions.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
with patch('assets.views.trafficlights.resolve_dxf_file_path', return_value='/dummy/test.dxf'), \
patch('assets.views.trafficlights.parse_dxf_poles', return_value=mock_dxf_poles):
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.post(url_import, {
"cross_plan_file_upload": uploaded_file,
"selected_dxf": "test.dxf"
})
self.assertEqual(response.status_code, 200)
# Context assertions
poles = response.context["poles"]
cables = response.context["cables"]
p_a = next(p for p in poles if p["code"] == "SWB01_A01")
p_b = next(p for p in poles if p["code"] == "SWB01_B01")
c_1 = next(c for c in cables if c["code"] == "SWB01_CAB01")
# Pole A: Identical position & model
self.assertEqual(p_a["status"], "identical")
self.assertFalse(p_a["geom_modified"])
# Pole B: Position shifted
self.assertEqual(p_b["status"], "modified")
self.assertTrue(p_b["geom_modified"])
self.assertEqual(p_b["distance_diff"], 2.0)
# Cable 1: Connected pole B01 moved -> Cable position is modified!
self.assertEqual(c_1["status"], "modified")
self.assertTrue(c_1["geom_modified"])
self.assertTrue(any("déplacement de poteaux connectés" in d for d in c_1["details_diff"]))
# HTML rendered assertions
content = response.content.decode("utf-8")
# Pole A should have 'keep' (Ne rien faire) selected
self.assertIn('name="action_pole_SWB01_A01"', content)
self.assertRegex(content, r'<select name="action_pole_SWB01_A01"[^>]*>[\s\S]*?<option value="keep" selected>')
# Pole B should have 'modify' (Mettre à jour l'asset) selected
self.assertIn('name="action_pole_SWB01_B01"', content)
self.assertRegex(content, r'<select name="action_pole_SWB01_B01"[^>]*>[\s\S]*?<option value="modify" selected>')
# Cable 1 should have 'modify' (Mettre à jour l'asset) selected because B01 moved
self.assertIn('name="action_cable_SWB01_CAB01"', content)
self.assertRegex(content, r'<select name="action_cable_SWB01_CAB01"[^>]*>[\s\S]*?<option value="modify" selected>')
# Check that cable geometry cell rendered
self.assertIn('id="geom_cell_cable_SWB01_CAB01"', content)
# Test POST integrate with 'keep' on pole A01: verifies position and properties are untouched
url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id])
response_integrate = self.client.post(url_integrate, {
"cross_plan_filename": response.context["cross_plan_filename"],
"selected_pole_SWB01_A01": "on",
"action_pole_SWB01_A01": "keep",
"name_pole_SWB01_A01": "Ignored Custom Name",
"x_3812_pole_SWB01_A01": "999999.0",
"y_3812_pole_SWB01_A01": "999999.0",
})
self.assertEqual(response_integrate.status_code, 302)
pole_a.refresh_from_db()
# Must remain untouched because action was "keep" (Ne rien faire)
self.assertEqual(pole_a.name_fr, "Poteau A01")
self.assertEqual(pole_a.geom.x, 149200.0)
self.assertEqual(pole_a.geom.y, 170500.0)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)
class AssetDefaultPositionTest(TestCase): class AssetDefaultPositionTest(TestCase):
def test_assign_default_position_hierarchy(self): def test_assign_default_position_hierarchy(self):

View file

@ -4287,16 +4287,48 @@ def import_cross_plan(request, intersection_id):
# ── 2. CABLES COMPARISON ── # ── 2. CABLES COMPARISON ──
existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')} existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')}
compared_cables = [] compared_cables = []
# Track poles that moved or are newly placed to see if connected cable paths changed
modified_pole_codes = {
p['code'] for p in compared_poles
if p.get('geom_modified') or p.get('status') == 'new'
}
modified_pole_short_codes = {
p['short_code'] for p in compared_poles
if p.get('geom_modified') or p.get('status') == 'new'
}
for dc in deduced["cables"]: for dc in deduced["cables"]:
code = dc["code"] code = dc["code"]
model_name = dc["model_name"] model_name = dc["model_name"]
db_model = TrafficLightCableModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightCableModel.objects.first() db_model = TrafficLightCableModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightCableModel.objects.first()
connected = dc.get("connected_poles", [])
has_connected_moved = any(
p_code in modified_pole_codes or p_code.split('_')[-1] in modified_pole_short_codes
for p_code in connected
)
if code in existing_cables: if code in existing_cables:
ec = existing_cables[code] ec = existing_cables[code]
is_diff = ec.model_id != db_model.id
status = 'modified' if is_diff else 'identical'
has_geom = ec.geom is not None has_geom = ec.geom is not None
is_diff = (ec.model_id != db_model.id) if (ec.model and db_model) else False
details_diff = []
if is_diff:
details_diff.append(_("Modèle : %(db_m)s → %(new_m)s") % {
'db_m': ec.model.name_fr if ec.model else 'Aucun',
'new_m': model_name
})
geom_modified = False
if has_connected_moved:
geom_modified = True
details_diff.append(_("Position modifiée : déplacement de poteaux connectés"))
elif not has_geom and len(connected) >= 2:
geom_modified = True
details_diff.append(_("Nouveau tracé déduit des poteaux"))
status = 'modified' if (is_diff or geom_modified) else 'identical'
compared_cables.append({ compared_cables.append({
'code': code, 'code': code,
'name': ec.name_fr or dc['name'], 'name': ec.name_fr or dc['name'],
@ -4304,13 +4336,17 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': model_name, 'deduced_model_name': model_name,
'db_model_name': ec.model.name_fr if ec.model else '', 'db_model_name': ec.model.name_fr if ec.model else '',
'db_id': ec.id, 'db_id': ec.id,
'details_diff': [f"Modèle : {ec.model.name_fr if ec.model else 'Aucun'} → {model_name}"] if is_diff else [], 'details_diff': details_diff,
'connected_poles': dc.get("connected_poles", []), 'connected_poles': connected,
'has_db_geom': has_geom, 'has_db_geom': has_geom,
'geom_modified': geom_modified,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None, 'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
}) })
existing_cables.pop(code) existing_cables.pop(code)
else: else:
details_diff = []
if len(connected) >= 2:
details_diff.append(_("Tracé déduit des poteaux"))
compared_cables.append({ compared_cables.append({
'code': code, 'code': code,
'name': dc['name'], 'name': dc['name'],
@ -4318,9 +4354,10 @@ def import_cross_plan(request, intersection_id):
'deduced_model_name': model_name, 'deduced_model_name': model_name,
'db_model_name': '', 'db_model_name': '',
'db_id': None, 'db_id': None,
'details_diff': [], 'details_diff': details_diff,
'connected_poles': dc.get("connected_poles", []), 'connected_poles': connected,
'has_db_geom': False, 'has_db_geom': False,
'geom_modified': False,
'db_geom_length': None, 'db_geom_length': None,
}) })
for code, ec in existing_cables.items(): for code, ec in existing_cables.items():
@ -4334,6 +4371,7 @@ def import_cross_plan(request, intersection_id):
'db_id': ec.id, 'db_id': ec.id,
'details_diff': [_("Absent du fichier Excel (présent en DB)")], 'details_diff': [_("Absent du fichier Excel (présent en DB)")],
'has_db_geom': has_geom, 'has_db_geom': has_geom,
'geom_modified': False,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None, 'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
}) })
@ -5291,12 +5329,8 @@ def integrate_cross_plan(request, intersection_id):
except Exception: except Exception:
pass pass
else: else:
# 'keep' # 'keep' (Ne rien faire)
if existing: if existing:
existing.name_fr = custom_name
existing.name_nl = custom_name
update_pole_geometry(existing, code)
existing.save()
created_poles[code] = existing created_poles[code] = existing
# Handle poles present in DB only (orphans) # Handle poles present in DB only (orphans)
@ -5381,12 +5415,9 @@ def integrate_cross_plan(request, intersection_id):
existing.replaced_by = new_cable existing.replaced_by = new_cable
existing.save() existing.save()
else: else:
# 'keep' # 'keep' (Ne rien faire)
if existing: if existing:
existing.name_fr = custom_name pass
existing.name_nl = custom_name
update_cable_geometry(existing, code)
existing.save()
for key in request.POST.keys(): for key in request.POST.keys():
if key.startswith("action_cable_db_"): if key.startswith("action_cable_db_"):

View file

@ -73,14 +73,42 @@
padding: 8px; padding: 8px;
z-index: 9999; z-index: 9999;
display: none; display: none;
min-width: 260px; min-width: 280px;
max-width: 320px; max-width: 340px;
max-height: min(85vh, 620px);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #1e293b; color: #1e293b;
user-select: none; user-select: none;
animation: layerSwitcherFadeIn 0.18s ease-out; animation: layerSwitcherFadeIn 0.18s ease-out;
} }
.layer-switcher-popover::-webkit-scrollbar {
width: 5px;
}
.layer-switcher-popover::-webkit-scrollbar-track {
background: transparent;
}
.layer-switcher-popover::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.layer-switcher-dot {
position: absolute;
top: 3px;
right: 3px;
width: 7px;
height: 7px;
background-color: #2563eb;
border-radius: 50%;
border: 1.5px solid #ffffff;
pointer-events: none;
}
@keyframes layerSwitcherFadeIn { @keyframes layerSwitcherFadeIn {
from { opacity: 0; transform: scale(0.96) translateX(4px); } from { opacity: 0; transform: scale(0.96) translateX(4px); }
to { opacity: 1; transform: scale(1) translateX(0); } to { opacity: 1; transform: scale(1) translateX(0); }
@ -215,12 +243,136 @@
.badge-region-bru { background-color: #dbeafe; color: #2563eb; border: 1px solid #bfdbfe; } .badge-region-bru { background-color: #dbeafe; color: #2563eb; border: 1px solid #bfdbfe; }
.badge-region-vlg { background-color: #fef3c7; color: #d97706; border: 1px solid #fde68a; } .badge-region-vlg { background-color: #fef3c7; color: #d97706; border: 1px solid #fde68a; }
.badge-region-all { background-color: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; } .badge-region-all { background-color: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
/* Custom Sections & Plan Elements */
.layer-switcher-divider {
height: 1px;
background-color: #e2e8f0;
margin: 8px 0 6px 0;
}
.layer-switcher-custom-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.layer-switcher-subtitle {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
padding: 4px 6px 2px 6px;
display: flex;
align-items: center;
justify-content: space-between;
}
.layer-switcher-card {
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 7px 9px;
margin-bottom: 3px;
transition: all 0.15s ease;
}
.layer-switcher-card:hover {
border-color: #cbd5e1;
background-color: #f1f5f9;
}
.layer-switcher-card.active {
border-color: #bfdbfe;
background-color: #f0f7ff;
}
.layer-switcher-slider-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 5px;
padding-top: 4px;
border-top: 1px dashed #e2e8f0;
}
.layer-switcher-slider-row label {
font-size: 10px;
font-weight: 600;
color: #64748b;
margin: 0;
white-space: nowrap;
}
.layer-switcher-slider-row input[type="range"] {
flex-grow: 1;
height: 4px;
accent-color: #2563eb;
cursor: pointer;
}
.layer-switcher-slider-val {
font-size: 10.5px;
font-weight: 700;
color: #2563eb;
min-width: 28px;
text-align: right;
}
.layer-switcher-btn-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
padding: 5px 8px;
border-radius: 5px;
font-size: 11px;
font-weight: 600;
border: 1px solid #e2e8f0;
background-color: #ffffff;
color: #475569;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.layer-switcher-btn-action:hover {
background-color: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
}
.layer-switcher-btn-action.btn-danger-light {
color: #dc2626;
border-color: #fecaca;
background-color: #fef2f2;
}
.layer-switcher-btn-action.btn-danger-light:hover {
background-color: #fee2e2;
border-color: #fca5a5;
}
.layer-switcher-collapse-content {
display: none;
margin-top: 6px;
border-top: 1px solid #e2e8f0;
padding-top: 6px;
}
.layer-switcher-collapse-content.expanded {
display: block;
}
`; `;
class LayerSwitcherControl { class LayerSwitcherControl {
constructor(options = {}) { constructor(options = {}) {
this.options = options; this.options = options;
this.layers = options.layers || []; this.layers = options.layers || [];
this.customSections = options.customSections || [];
this.hasActiveOverlays = false;
this.map = null; this.map = null;
this.container = null; this.container = null;
this.popover = null; this.popover = null;
@ -381,9 +533,70 @@
}); });
} }
// Render custom sections if configured
if (this.customSections && this.customSections.length > 0) {
this.customSections.forEach(section => {
this._renderCustomSection(section, popover);
});
}
return popover; return popover;
} }
_renderCustomSection(section, popoverEl = this.popover) {
if (!popoverEl) return;
const divider = document.createElement('div');
divider.className = 'layer-switcher-divider';
popoverEl.appendChild(divider);
if (section.title) {
const sectionTitle = document.createElement('div');
sectionTitle.className = 'layer-switcher-section-title';
sectionTitle.innerHTML = section.title;
popoverEl.appendChild(sectionTitle);
}
const container = document.createElement('div');
container.className = 'layer-switcher-custom-section';
if (section.id) container.id = `layer-switcher-section-${section.id}`;
popoverEl.appendChild(container);
if (typeof section.render === 'function') {
section.render(container, this);
}
}
addCustomSection(section) {
this.customSections.push(section);
if (this.popover) {
this._renderCustomSection(section, this.popover);
}
}
setOverlayActive(active) {
this.hasActiveOverlays = !!active;
this._updateButtonState();
}
_updateButtonState() {
if (!this.button) return;
const isBasemapActive = this.activeBasemapId && this.activeBasemapId !== 'default';
const isActive = isBasemapActive || this.hasActiveOverlays;
this.button.classList.toggle('active', isActive);
let dot = this.button.querySelector('.layer-switcher-dot');
if (this.hasActiveOverlays) {
if (!dot) {
dot = document.createElement('span');
dot.className = 'layer-switcher-dot';
this.button.appendChild(dot);
}
} else if (dot) {
dot.remove();
}
}
_createBasemapItem(bg, iconSvg) { _createBasemapItem(bg, iconSvg) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'layer-switcher-item' + (this.activeBasemapId === bg.id ? ' active' : ''); item.className = 'layer-switcher-item' + (this.activeBasemapId === bg.id ? ' active' : '');
@ -458,9 +671,7 @@
} }
// Update button active style // Update button active style
if (this.button) { this._updateButtonState();
this.button.classList.toggle('active', id !== 'default');
}
// Apply to map // Apply to map
if (id === 'default') { if (id === 'default') {
@ -478,7 +689,7 @@
const bgConfig = this.basemaps.find(b => b.id === this.activeBasemapId); const bgConfig = this.basemaps.find(b => b.id === this.activeBasemapId);
if (bgConfig) { if (bgConfig) {
this._applyRasterBasemap(bgConfig); this._applyRasterBasemap(bgConfig);
if (this.button) this.button.classList.add('active'); this._updateButtonState();
return; return;
} }
} }
@ -487,7 +698,7 @@
if (defaultBg) { if (defaultBg) {
this.selectBasemap(defaultBg.id); this.selectBasemap(defaultBg.id);
} else { } else {
if (this.button) this.button.classList.remove('active'); this._updateButtonState();
} }
} }