feat(trafficlights): add cable geometry copy, duplication modal and node editing hints in cross plan import

- Add explanatory guidelines for cable node operations (double-click segment to insert node, double-click node to delete, drag to reposition)
- Add floating reminder pill on map during cable editing mode
- Add "Copier tracé..." dropdown on cable cards to import trace from other cables
- Add "Appliquer aux autres câbles..." modal to duplicate active trace across multiple cables at once
- Add direct map jump button on cable rows in comparative table
- Include db_coords in compared_cables context to preserve pre-existing geometries
- Add tests and update translations (fr, nl, en)
This commit is contained in:
kdeterme 2026-09-30 14:39:32 +02:00
parent 1d99cdd09b
commit 244b16b3d3
9 changed files with 747 additions and 57 deletions

View file

@ -8588,6 +8588,87 @@ msgstr "Archive existing (do not recreate)"
msgid "Archiver l'existant"
msgstr "Archive existing"
msgid "Aide au tracé des câbles"
msgstr "Cable tracing help"
msgid "Tracer"
msgstr "Trace"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Double-click on the map to add a point."
msgid "Ajouter un nœud"
msgstr "Add a node"
msgid "Double-clic sur un segment du câble."
msgstr "Double-click on a cable segment."
msgid "Supprimer un nœud"
msgstr "Delete a node"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Double-click on a node (orange handle)."
msgid "Déplacer"
msgstr "Move"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Drag and drop handles to adjust."
msgid "Copier tracé"
msgstr "Copy trace"
msgid "Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr "Copy the trace of another cable following the same path (e.g. 48 and tel.)."
msgid "Copier tracé…"
msgstr "Copy trace…"
msgid "Copier le tracé d'un autre câble"
msgstr "Copy another cable's trace"
msgid "Appliquer aux autres câbles…"
msgstr "Apply to other cables…"
msgid "Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud pour le supprimer • Glisser pour déplacer"
msgstr "Double-click a segment to add a node • Double-click a node to delete • Drag to move"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Apply trace to other cables"
msgid "Copier le tracé de"
msgstr "Copy trace from"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "to the selected cables below:"
msgid "Copier le tracé"
msgstr "Copy trace"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "The source cable has no valid trace to copy."
msgid "Aucun tracé disponible"
msgstr "No trace available"
msgid "Tracé copié de"
msgstr "Trace copied from"
msgid "vers"
msgstr "to"
msgid "Veuillez d'abord tracer au moins deux points pour ce câble avant de le dupliquer."
msgstr "Please trace at least two points for this cable before duplicating."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "No other cable available at this intersection."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Please select at least one target cable."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "cable(s) updated with this trace!"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Missing from Excel file (Will be kept or archived)"

View file

@ -8639,6 +8639,87 @@ msgstr "Archiver l'existant (Ne pas recréer)"
msgid "Archiver l'existant"
msgstr "Archiver l'existant"
msgid "Aide au tracé des câbles"
msgstr "Aide au tracé des câbles"
msgid "Tracer"
msgstr "Tracer"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Double-clic sur la carte pour ajouter un point."
msgid "Ajouter un nœud"
msgstr "Ajouter un nœud"
msgid "Double-clic sur un segment du câble."
msgstr "Double-clic sur un segment du câble."
msgid "Supprimer un nœud"
msgstr "Supprimer un nœud"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Double-clic sur un nœud (poignée orange)."
msgid "Déplacer"
msgstr "Déplacer"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Glisser-déposer les poignées pour ajuster."
msgid "Copier tracé"
msgstr "Copier tracé"
msgid "Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr "Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgid "Copier tracé…"
msgstr "Copier tracé…"
msgid "Copier le tracé d'un autre câble"
msgstr "Copier le tracé d'un autre câble"
msgid "Appliquer aux autres câbles…"
msgstr "Appliquer aux autres câbles…"
msgid "Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud pour le supprimer • Glisser pour déplacer"
msgstr "Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud pour le supprimer • Glisser pour déplacer"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Appliquer le tracé à d'autres câbles"
msgid "Copier le tracé de"
msgstr "Copier le tracé de"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "vers les câbles sélectionnés ci-dessous :"
msgid "Copier le tracé"
msgstr "Copier le tracé"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "Le câble source ne possède aucun tracé valide à copier."
msgid "Aucun tracé disponible"
msgstr "Aucun tracé disponible"
msgid "Tracé copié de"
msgstr "Tracé copié de"
msgid "vers"
msgstr "vers"
msgid "Veuillez d'abord tracer au moins deux points pour ce câble avant de le dupliquer."
msgstr "Veuillez d'abord tracer au moins deux points pour ce câble avant de le dupliquer."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "Aucun autre câble disponible dans ce carrefour."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Veuillez sélectionner au moins un câble cible."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "câble(s) mis à jour avec ce tracé !"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Absent du fichier Excel (Sera conservé ou archivé)"

View file

@ -8587,6 +8587,87 @@ msgstr "Bestaande archiveren (niet opnieuw aanmaken)"
msgid "Archiver l'existant"
msgstr "Bestaande archiveren"
msgid "Aide au tracé des câbles"
msgstr "Hulp bij het traceren van kabels"
msgid "Tracer"
msgstr "Traceren"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Dubbelklik op de kaart om een punt toe te voegen."
msgid "Ajouter un nœud"
msgstr "Knooppunt toevoegen"
msgid "Double-clic sur un segment du câble."
msgstr "Dubbelklik op een segment van de kabel."
msgid "Supprimer un nœud"
msgstr "Knooppunt verwijderen"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Dubbelklik op een knooppunt (oranje handgreep)."
msgid "Déplacer"
msgstr "Verplaatsen"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Sleep de handgrepen om aan te passen."
msgid "Copier tracé"
msgstr "Tracé kopiëren"
msgid "Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr "Kopieer het tracé van een andere kabel die hetzelfde traject volgt (bv. 48 en tel.)."
msgid "Copier tracé…"
msgstr "Tracé kopiëren…"
msgid "Copier le tracé d'un autre câble"
msgstr "Het tracé van een andere kabel kopiëren"
msgid "Appliquer aux autres câbles…"
msgstr "Toepassen op andere kabels…"
msgid "Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud pour le supprimer • Glisser pour déplacer"
msgstr "Dubbelklik op een segment om een knooppunt toe te voegen • Dubbelklik op een knooppunt om te verwijderen • Sleep om te verplaatsen"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Tracé toepassen op andere kabels"
msgid "Copier le tracé de"
msgstr "Tracé kopiëren van"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "naar de onderstaande geselecteerde kabels:"
msgid "Copier le tracé"
msgstr "Tracé kopiëren"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "De bronkabel heeft geen geldig tracé om te kopiëren."
msgid "Aucun tracé disponible"
msgstr "Geen tracé beschikbaar"
msgid "Tracé copié de"
msgstr "Tracé gekopieerd van"
msgid "vers"
msgstr "naar"
msgid "Veuillez d'abord tracer au moins deux points pour ce câble avant de le dupliquer."
msgstr "Teken eerst ten minste twee punten voor deze kabel alvorens te dupliceren."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "Geen andere kabel beschikbaar op dit kruispunt."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Selecteer ten minste één doelkabel."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "kabel(s) bijgewerkt met dit tracé!"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Ontbreekt in het Excel-bestand (Wordt bewaard of gearchiveerd)"

View file

@ -661,6 +661,11 @@
{% else %}
<span class="badge bg-light text-muted border">{% translate "Non tracé" %}</span>
{% endif %}
{% if item.status != 'db_only' %}
<button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="{{ item.code }}" title="{% translate 'Voir et éditer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
</td>
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
@ -874,13 +879,19 @@
<!-- Map Cables Sub-pane -->
<div class="tab-pane fade h-100" id="map-cables-pane" role="tabpanel" aria-labelledby="map-cables-tab">
<div class="d-flex flex-column h-100 align-items-start justify-content-start">
<div class="mb-2 p-2 bg-light rounded-3 border w-100">
<div class="font-weight-semibold text-success mb-1">
<i class="bi bi-bezier"></i> {% translate "Tracé des câbles" %}
<div class="mb-2 p-2.5 bg-light rounded-3 border w-100 shadow-sm" style="font-size: 0.78rem;">
<div class="d-flex align-items-center justify-content-between mb-1.5 pb-1 border-bottom">
<span class="font-weight-semibold text-success d-flex align-items-center gap-1">
<i class="bi bi-bezier"></i> {% translate "Aide au tracé des câbles" %}
</span>
</div>
<p class="text-secondary mb-0 small">
{% translate "Cliquez sur 'Tracer / Éditer' puis double-cliquez sur la carte pour tracer la ligne du câble." %}
</p>
<ul class="list-unstyled mb-0 text-secondary d-flex flex-column gap-1" style="line-height: 1.35;">
<li><i class="bi bi-plus-circle text-primary me-1"></i><strong>{% translate "Tracer" %} :</strong> {% translate "Double-clic sur la carte pour ajouter un point." %}</li>
<li><i class="bi bi-node-plus text-success me-1"></i><strong>{% translate "Ajouter un nœud" %} :</strong> {% translate "Double-clic sur un segment du câble." %}</li>
<li><i class="bi bi-node-minus text-danger me-1"></i><strong>{% translate "Supprimer un nœud" %} :</strong> {% translate "Double-clic sur un nœud (poignée orange)." %}</li>
<li><i class="bi bi-arrows-move text-warning me-1"></i><strong>{% translate "Déplacer" %} :</strong> {% translate "Glisser-déposer les poignées pour ajuster." %}</li>
<li><i class="bi bi-files text-info me-1"></i><strong>{% translate "Copier tracé" %} :</strong> {% translate "Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)." %}</li>
</ul>
</div>
<div class="list-group list-group-flush overflow-auto flex-grow-1 w-100 align-self-stretch">
@ -889,12 +900,23 @@
<div class="list-group-item py-2 px-1 border-bottom d-flex flex-column gap-1 cable-config-item align-self-stretch" data-code="{{ item.code }}">
<div class="d-flex align-items-center justify-content-between w-100">
<strong class="text-dark font-monospace">{{ item.name }}</strong>
<span class="badge cable-geom-status-badge font-monospace" id="badge-cable-status-{{ item.code }}" style="font-size: 0.72rem;">
<span class="text-muted">…</span>
</span>
</div>
<div class="text-muted small font-monospace">{{ item.deduced_model_name }}</div>
<div class="mt-1 align-self-start">
<div class="mt-1 d-flex align-items-center gap-1 flex-wrap">
<button type="button" class="btn btn-xs btn-outline-success py-0.5 px-2 edit-cable-btn rounded-2" data-code="{{ item.code }}">
<i class="bi bi-pencil-square"></i> {% translate "Tracer / Éditer" %}
</button>
<div class="dropdown d-inline-block">
<button type="button" class="btn btn-xs btn-outline-secondary dropdown-toggle py-0.5 px-2 rounded-2 copy-cable-btn" data-bs-toggle="dropdown" aria-expanded="false" title="{% translate "Copier le tracé d'un autre câble" %}">
<i class="bi bi-files"></i> {% translate "Copier tracé…" %}
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm copy-cable-menu py-1" data-target-code="{{ item.code }}" style="font-size: 0.8rem; min-width: 220px;">
<!-- Populated dynamically by JS -->
</ul>
</div>
</div>
</div>
{% endif %}
@ -914,10 +936,17 @@
<!-- Mode indicator centered at the top -->
<div class="position-absolute top-0 start-50 translate-middle-x m-3 z-3 bg-white py-1 px-3 rounded-pill shadow border d-flex align-items-center gap-2" style="white-space: nowrap;">
<span class="small text-muted font-monospace font-weight-semibold mb-0" id="map-mode-indicator">{% translate "Mode: Lecture" %}</span>
<button type="button" id="btn-duplicate-trace" class="btn btn-xs btn-outline-primary rounded-pill py-0.5 px-2" style="display: none; font-size: 0.75rem;">
<i class="bi bi-copy"></i> {% translate "Appliquer aux autres câbles…" %}
</button>
<button type="button" id="btn-clear-trace" class="btn btn-xs btn-outline-danger rounded-pill py-0.5 px-2" style="display: none; font-size: 0.75rem;">
<i class="bi bi-trash"></i> {% translate "Effacer" %}
</button>
</div>
<div id="cable-edit-tip" class="position-absolute start-50 translate-middle-x z-2 px-3 py-1 bg-white bg-opacity-95 rounded-pill shadow-sm border small text-muted text-center" style="top: 56px; display: none; font-size: 0.75rem; white-space: nowrap;">
<i class="bi bi-info-circle text-primary me-1"></i>
<span>{% translate "Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud pour le supprimer • Glisser pour déplacer" %}</span>
</div>
<div id="map" class="w-100 h-100"></div>
</div>
@ -926,6 +955,46 @@
</div>
</form>
<!-- Modal: Appliquer le tracé d'un câble à d'autres câbles -->
<div class="modal fade" id="duplicateCableModal" tabindex="-1" aria-labelledby="duplicateCableModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-bottom py-2.5 px-3">
<h6 class="modal-title font-weight-semibold text-dark d-flex align-items-center gap-2 mb-0" id="duplicateCableModalLabel">
<i class="bi bi-copy text-primary"></i> {% translate "Appliquer le tracé à d'autres câbles" %}
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
<p class="text-secondary small mb-3">
{% translate "Copier le tracé de" %} <strong id="modal-source-cable-name" class="text-dark"></strong> <span id="modal-source-cable-info" class="badge bg-light text-secondary border"></span> {% translate "vers les câbles sélectionnés ci-dessous :" %}
</p>
<div class="p-2.5 bg-light rounded-3 border mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="modal-select-all-cables">
<label class="form-check-label font-weight-semibold small text-dark cursor-pointer" for="modal-select-all-cables">
{% translate "Tout sélectionner" %}
</label>
</div>
</div>
<div id="modal-target-cables-list" class="d-flex flex-column gap-1.5 overflow-auto pe-1" style="max-height: 240px;">
<!-- Dynamically generated list of cables -->
</div>
</div>
<div class="modal-footer border-top py-2 px-3 d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-outline-secondary rounded-2" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="button" id="btn-confirm-duplicate-cable" class="btn btn-sm btn-primary rounded-2 px-3 d-flex align-items-center gap-1.5">
<i class="bi bi-check-lg"></i> {% translate "Copier le tracé" %}
</button>
</div>
</div>
</div>
</div>
{% endif %}
</div>
@ -1084,11 +1153,7 @@ document.addEventListener('DOMContentLoaded', () => {
const tableView = document.getElementById('table-view');
const mapView = document.getElementById('map-view');
document.querySelectorAll('.show-map-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const targetTab = this.dataset.targetTab;
function switchToMapView(targetTab, cableCodeToEdit) {
tableView.classList.remove('d-block');
tableView.classList.add('d-none');
@ -1107,7 +1172,28 @@ document.addEventListener('DOMContentLoaded', () => {
} else if (targetTab === 'cables') {
const tabEl = document.getElementById('map-cables-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
if (cableCodeToEdit) {
activateCableEditingMode(cableCodeToEdit);
const item = document.querySelector(`.cable-config-item[data-code="${cableCodeToEdit}"]`);
if (item) {
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
}
document.querySelectorAll('.show-map-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
switchToMapView(this.dataset.targetTab);
});
});
document.querySelectorAll('.show-cable-on-map-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
switchToMapView('cables', this.dataset.code);
});
});
@ -1178,7 +1264,11 @@ document.addEventListener('DOMContentLoaded', () => {
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:"[]" }},
db_coords: {{ item.db_coords|safe|default:"[]" }},
has_db_geom: {{ item.has_db_geom|yesno:"true,false" }},
db_geom_length: {% if item.db_geom_length is not None %}{{ item.db_geom_length|unlocalize }}{% else %}null{% endif %},
deduced_model_name: "{{ item.deduced_model_name|default:''|escapejs }}"
},
{% endfor %}
];
@ -1213,6 +1303,87 @@ document.addEventListener('DOMContentLoaded', () => {
const placedPoles = {};
let layerSwitcher = null;
function calculateLineLengthMeters(coords) {
if (!coords || coords.length < 2) return 0;
const R = 6371000;
let total = 0;
for (let i = 0; i < coords.length - 1; i++) {
const lon1 = coords[i][0] * Math.PI / 180;
const lat1 = coords[i][1] * Math.PI / 180;
const lon2 = coords[i+1][0] * Math.PI / 180;
const lat2 = coords[i+1][1] * Math.PI / 180;
const dLat = lat2 - lat1;
const dLon = lon2 - lon1;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
total += R * c;
}
return Math.round(total * 10) / 10;
}
function getCableCoords(code) {
if (cableGeometries[code] && cableGeometries[code].length >= 2) {
return cableGeometries[code];
}
const c = cablesList.find(item => item.code === code);
if (!c) return [];
if (c.db_coords && c.db_coords.length >= 2) {
return c.db_coords;
}
const coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
return coords;
}
function updateCableBadge(code) {
const badge = document.getElementById(`badge-cable-status-${code}`);
const coords = getCableCoords(code);
const c = cablesList.find(item => item.code === code);
if (!badge || !c) return;
if (coords && coords.length >= 2) {
const len = calculateLineLengthMeters(coords);
const isModified = cableGeometries[code] !== undefined || c.geom_modified;
if (isModified) {
badge.className = 'badge bg-warning text-dark border border-warning-subtle font-monospace cable-geom-status-badge';
badge.innerHTML = `<i class="bi bi-bezier2"></i> ${coords.length} pts (${len} m)`;
} else {
badge.className = 'badge bg-info-subtle text-info-emphasis border border-info-subtle font-monospace cable-geom-status-badge';
badge.innerHTML = `<i class="bi bi-bezier2"></i> ${coords.length} pts (${len} m)`;
}
} else {
badge.className = 'badge bg-light text-muted border font-monospace cable-geom-status-badge';
badge.textContent = `{% translate "Non tracé" %}`;
}
}
function updateTableCableGeomCell(code) {
const geomCell = document.getElementById(`geom_cell_cable_${code}`);
if (!geomCell) return;
const coords = getCableCoords(code);
if (coords && coords.length >= 2) {
const len = calculateLineLengthMeters(coords);
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é" %} (${len} m)</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="${code}" title="{% translate 'Voir et éditer sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
} else {
geomCell.innerHTML = `<span class="badge bg-light text-muted border">{% translate "Non tracé" %}</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="${code}" title="{% translate 'Tracer sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
}
const btn = geomCell.querySelector('.show-cable-on-map-btn');
if (btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
switchToMapView('cables', code);
});
}
}
function markCableAsModified(code) {
const selectAction = document.querySelector(`select[name="action_cable_${code}"]`);
if (selectAction) {
@ -1225,10 +1396,8 @@ document.addEventListener('DOMContentLoaded', () => {
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>`;
}
updateTableCableGeomCell(code);
updateCableBadge(code);
}
const snapCandidates = initialDxfPoles.map(dp => [dp.lon, dp.lat, dp.x_3812, dp.y_3812]);
@ -1718,8 +1887,12 @@ document.addEventListener('DOMContentLoaded', () => {
deactivateCableEditingMode();
activeEditCable = code;
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Tracé" %} ${cablesList.find(c => c.code === code).name}`;
const cableInfo = cablesList.find(c => c.code === code);
const cableName = cableInfo ? cableInfo.name : code;
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Tracé" %} ${cableName}`;
document.getElementById('btn-clear-trace').style.display = 'block';
document.getElementById('btn-duplicate-trace').style.display = 'block';
document.getElementById('cable-edit-tip').style.display = 'block';
const mapPane = document.getElementById('map-cables-pane');
mapPane.querySelectorAll('.cable-config-item').forEach(item => item.classList.remove('bg-light'));
@ -1728,25 +1901,21 @@ document.addEventListener('DOMContentLoaded', () => {
let coords = cableGeometries[code];
if (!coords || coords.length === 0) {
const cableInfo = cablesList.find(c => c.code === code);
const connected = cableInfo.connected_poles || [];
coords = [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
const defaultCoords = getCableCoords(code);
coords = defaultCoords && defaultCoords.length > 0 ? defaultCoords.map(pt => [pt[0], pt[1]]) : [];
cableGeometries[code] = coords;
}
drawCableHandles(code);
redrawCablesOnMap();
updateCableBadge(code);
}
function deactivateCableEditingMode() {
activeEditCable = null;
document.getElementById('btn-clear-trace').style.display = 'none';
document.getElementById('btn-duplicate-trace').style.display = 'none';
document.getElementById('cable-edit-tip').style.display = 'none';
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Lecture" %}`;
cableHandles.forEach(h => h.remove());
@ -1795,6 +1964,7 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(code);
populateCopyCableMenus();
});
el.addEventListener('dblclick', function(e) {
@ -1804,6 +1974,7 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(code);
populateCopyCableMenus();
});
});
}
@ -1861,6 +2032,7 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
populateCopyCableMenus();
});
map.on('dblclick', function(e) {
@ -1884,6 +2056,7 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
populateCopyCableMenus();
});
// Hover mouse styles for active cable line
@ -1907,20 +2080,192 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
populateCopyCableMenus();
}
});
function copyCableGeometry(sourceCode, targetCode) {
const srcCoords = getCableCoords(sourceCode);
if (!srcCoords || srcCoords.length < 2) {
alert("{% translate "Le câble source ne possède aucun tracé valide à copier." %}");
return false;
}
cableGeometries[targetCode] = srcCoords.map(pt => [pt[0], pt[1]]);
saveCablesToForm();
markCableAsModified(targetCode);
populateCopyCableMenus();
if (activeEditCable === targetCode) {
drawCableHandles(targetCode);
redrawCablesOnMap();
}
return true;
}
function populateCopyCableMenus() {
document.querySelectorAll('.copy-cable-menu').forEach(menu => {
const targetCode = menu.dataset.targetCode;
menu.innerHTML = '';
let hasAvailable = false;
cablesList.forEach(other => {
if (other.code === targetCode || other.status === 'db_only') return;
const coords = getCableCoords(other.code);
if (coords && coords.length >= 2) {
hasAvailable = true;
const len = calculateLineLengthMeters(coords);
const li = document.createElement('li');
li.innerHTML = `
<button type="button" class="dropdown-item py-1.5 px-3 d-flex align-items-center justify-content-between copy-from-cable-btn" data-source="${other.code}" data-target="${targetCode}">
<span class="font-monospace text-truncate me-2">${other.name}</span>
<span class="badge bg-light text-secondary border font-monospace" style="font-size: 0.7rem;">${coords.length} pts (${len} m)</span>
</button>
`;
menu.appendChild(li);
}
});
if (!hasAvailable) {
const li = document.createElement('li');
li.className = 'dropdown-item text-muted small fst-italic py-2';
li.textContent = `{% translate "Aucun tracé disponible" %}`;
menu.appendChild(li);
}
});
// Rebind click listeners for dynamically populated dropdown items
document.querySelectorAll('.copy-from-cable-btn').forEach(btn => {
btn.onclick = function(e) {
e.preventDefault();
const source = this.dataset.source;
const target = this.dataset.target;
const success = copyCableGeometry(source, target);
if (success) {
const targetCable = cablesList.find(c => c.code === target);
const sourceCable = cablesList.find(c => c.code === source);
const targetName = targetCable ? targetCable.name : target;
const sourceName = sourceCable ? sourceCable.name : source;
const tipEl = document.getElementById('cable-edit-tip');
if (tipEl) {
tipEl.style.display = 'block';
const origContent = tipEl.innerHTML;
tipEl.innerHTML = `<i class="bi bi-check-circle-fill text-success me-1"></i><strong>{% translate "Tracé copié de" %} ${sourceName} {% translate "vers" %} ${targetName}</strong>`;
setTimeout(() => {
if (!activeEditCable) tipEl.style.display = 'none';
else tipEl.innerHTML = origContent;
}, 3000);
}
}
};
});
}
// Modal Duplicate Trace logic
const duplicateModalEl = document.getElementById('duplicateCableModal');
const duplicateModal = duplicateModalEl ? new bootstrap.Modal(duplicateModalEl) : null;
const btnDuplicateTrace = document.getElementById('btn-duplicate-trace');
if (btnDuplicateTrace) {
btnDuplicateTrace.addEventListener('click', function() {
if (!activeEditCable) return;
const currentCable = cablesList.find(c => c.code === activeEditCable);
const coords = getCableCoords(activeEditCable);
if (!coords || coords.length < 2) {
alert("{% translate "Veuillez d'abord tracer au moins deux points pour ce câble avant de le dupliquer." %}");
return;
}
const len = calculateLineLengthMeters(coords);
const srcNameEl = document.getElementById('modal-source-cable-name');
const srcInfoEl = document.getElementById('modal-source-cable-info');
if (srcNameEl) srcNameEl.textContent = currentCable ? currentCable.name : activeEditCable;
if (srcInfoEl) srcInfoEl.textContent = `${coords.length} pts • ${len} m`;
const listContainer = document.getElementById('modal-target-cables-list');
if (listContainer) {
listContainer.innerHTML = '';
const otherCables = cablesList.filter(c => c.code !== activeEditCable && c.status !== 'db_only');
if (otherCables.length === 0) {
listContainer.innerHTML = `<div class="text-center text-muted small py-3">{% translate "Aucun autre câble disponible dans ce carrefour." %}</div>`;
const confirmBtn = document.getElementById('btn-confirm-duplicate-cable');
if (confirmBtn) confirmBtn.disabled = true;
} else {
const confirmBtn = document.getElementById('btn-confirm-duplicate-cable');
if (confirmBtn) confirmBtn.disabled = false;
otherCables.forEach(c => {
const cCoords = getCableCoords(c.code);
const hasGeom = cCoords && cCoords.length >= 2;
const cLen = hasGeom ? calculateLineLengthMeters(cCoords) : null;
const geomBadge = hasGeom
? `<span class="badge bg-light text-secondary border font-monospace ms-auto" style="font-size: 0.7rem;">${cCoords.length} pts (${cLen} m)</span>`
: `<span class="badge bg-light text-muted border font-monospace ms-auto" style="font-size: 0.7rem;">{% translate "Non tracé" %}</span>`;
const itemDiv = document.createElement('div');
itemDiv.className = 'form-check d-flex align-items-center p-2 rounded border bg-white';
itemDiv.innerHTML = `
<input class="form-check-input modal-cable-target-cb me-2" type="checkbox" value="${c.code}" id="modal_cb_target_${c.code}">
<label class="form-check-label small text-dark d-flex align-items-center w-100 cursor-pointer mb-0" for="modal_cb_target_${c.code}">
<span class="font-monospace font-weight-semibold me-2">${c.name}</span>
<span class="text-muted small">${c.deduced_model_name ? '(' + c.deduced_model_name + ')' : ''}</span>
${geomBadge}
</label>
`;
listContainer.appendChild(itemDiv);
});
}
}
const selectAllCb = document.getElementById('modal-select-all-cables');
if (selectAllCb) selectAllCb.checked = false;
if (duplicateModal) duplicateModal.show();
});
}
const modalSelectAllCables = document.getElementById('modal-select-all-cables');
if (modalSelectAllCables) {
modalSelectAllCables.addEventListener('change', function() {
const checked = this.checked;
document.querySelectorAll('.modal-cable-target-cb').forEach(cb => {
cb.checked = checked;
});
});
}
const btnConfirmDuplicateCable = document.getElementById('btn-confirm-duplicate-cable');
if (btnConfirmDuplicateCable) {
btnConfirmDuplicateCable.addEventListener('click', function() {
if (!activeEditCable) return;
const selectedCbs = document.querySelectorAll('.modal-cable-target-cb:checked');
if (selectedCbs.length === 0) {
alert("{% translate "Veuillez sélectionner au moins un câble cible." %}");
return;
}
let appliedCount = 0;
selectedCbs.forEach(cb => {
const targetCode = cb.value;
copyCableGeometry(activeEditCable, targetCode);
appliedCount++;
});
if (duplicateModal) duplicateModal.hide();
const tipEl = document.getElementById('cable-edit-tip');
if (tipEl) {
const origContent = tipEl.innerHTML;
tipEl.innerHTML = `<i class="bi bi-check-circle-fill text-success me-1"></i><strong>${appliedCount} {% translate "câble(s) mis à jour avec ce tracé !" %}</strong>`;
setTimeout(() => {
if (activeEditCable) tipEl.innerHTML = origContent;
}, 3000);
}
});
}
function saveCablesToForm() {
cablesList.forEach(c => {
let coords = cableGeometries[c.code];
if (!coords || coords.length === 0) {
coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
coords = getCableCoords(c.code);
}
const input = document.getElementById(`geom_cable_${c.code}`);
if (input && coords && coords.length >= 2) {
@ -1953,13 +2298,7 @@ document.addEventListener('DOMContentLoaded', () => {
let coords = cableGeometries[c.code];
if (!coords || coords.length === 0) {
coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
coords = getCableCoords(c.code);
}
if (coords && coords.length >= 2) {
@ -1987,6 +2326,12 @@ document.addEventListener('DOMContentLoaded', () => {
map.triggerRepaint();
}
// Initial cable badges and copy menus update
cablesList.forEach(c => {
updateCableBadge(c.code);
});
populateCopyCableMenus();
// ── Georeferenced Plans & DXF Background Loader ──
const georeferencedDocumentsDataEl = document.getElementById('georeferencedDocumentsData');
let georeferencedDocuments = [];

View file

@ -3325,6 +3325,88 @@ class TrafficLightCrossPlanImportTest(TestCase):
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_compared_cables_db_coords_and_integrate_cable_geometry(self):
import openpyxl
import tempfile
import json
from django.contrib.gis.geos import LineString
from assets.models import TrafficLightCable, TrafficLightCableModel
from django.core.files.uploadedfile import SimpleUploadedFile
from pyproj import Transformer
cable_model = TrafficLightCableModel.objects.first()
transformer = Transformer.from_crs(4326, 3812, always_xy=True)
p1 = transformer.transform(4.3508, 50.8465)
p2 = transformer.transform(4.3518, 50.8475)
# Create active cable in DB with Lambert 3812 LineString
existing_cable = TrafficLightCable.objects.create(
intersection=self.intersection,
code="SWB01_CAB01",
model=cable_model,
status="active",
geom=LineString([p1, p2], srid=3812)
)
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="Câble 01 SVAVB 61x1,5mm²")
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=1, value="1")
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)
try:
self.client.force_login(self.user)
with open(tmp_path, "rb") as f:
uploaded_file = SimpleUploadedFile("test_cross_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
response = self.client.post(url_import, {"cross_plan_file_upload": uploaded_file})
self.assertEqual(response.status_code, 200)
cables_preview = response.context["cables"]
cab01 = next((c for c in cables_preview if c["code"] == "SWB01_CAB01"), None)
self.assertIsNotNone(cab01)
self.assertTrue(cab01["has_db_geom"])
self.assertIsNotNone(cab01["db_coords"])
self.assertEqual(len(cab01["db_coords"]), 2)
# Coordinates in WGS84 lon, lat
self.assertAlmostEqual(cab01["db_coords"][0][0], 4.35, delta=0.5)
# Test integrate with modified cable geometry
url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id])
new_geom_coords = [[4.3508, 50.8465], [4.3510, 50.8470], [4.3515, 50.8475]]
post_data = {
"cross_plan_filename": response.context["cross_plan_filename"],
"selected_cable_SWB01_CAB01": "on",
"action_cable_SWB01_CAB01": "modify",
"db_id_cable_SWB01_CAB01": str(existing_cable.id),
"geom_cable_SWB01_CAB01": json.dumps(new_geom_coords),
}
res_int = self.client.post(url_integrate, post_data)
self.assertEqual(res_int.status_code, 302)
existing_cable.refresh_from_db()
self.assertIsNotNone(existing_cable.geom)
self.assertEqual(len(existing_cable.geom.coords), 3)
self.assertEqual(existing_cable.geom.srid, 3812)
finally:
import os
if os.path.exists(tmp_path):
os.remove(tmp_path)

View file

@ -4401,6 +4401,15 @@ def import_cross_plan(request, intersection_id):
geom_modified = True
details_diff.append(_("Nouveau tracé déduit des poteaux"))
db_coords = []
if has_geom:
try:
gw = ec.geom.clone()
gw.transform(4326)
db_coords = [[round(c[0], 6), round(c[1], 6)] for c in gw.coords]
except Exception:
db_coords = []
status = 'modified' if (is_diff or geom_modified) else 'identical'
compared_cables.append({
'code': code,
@ -4414,6 +4423,7 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': has_geom,
'geom_modified': geom_modified,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
'db_coords': db_coords,
})
existing_cables.pop(code)
else:
@ -4432,9 +4442,18 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': False,
'geom_modified': False,
'db_geom_length': None,
'db_coords': [],
})
for code, ec in existing_cables.items():
has_geom = ec.geom is not None
db_coords = []
if has_geom:
try:
gw = ec.geom.clone()
gw.transform(4326)
db_coords = [[round(c[0], 6), round(c[1], 6)] for c in gw.coords]
except Exception:
db_coords = []
compared_cables.append({
'code': code,
'name': ec.name_fr or code,
@ -4446,6 +4465,7 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': has_geom,
'geom_modified': False,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
'db_coords': db_coords,
})
# ── 3. LANTERNS COMPARISON ──