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

1414 lines
64 KiB
HTML

{% extends "documents/base_documents.html" %}
{% load i18n static documents_tags %}
{% block title %}
{% translate "Géolocaliser" %} - {{ document.title }}
{% endblock title %}
{% block head %}
{{ block.super }}
<style>
#map .maplibregl-ctrl-top-right,
#map .mapboxgl-ctrl-top-right {
top: 10px !important;
}
#map.placement-cursor .maplibregl-canvas {
cursor: crosshair !important;
}
.georef-header {
background: linear-gradient(135deg, #1e293b, #0f172a);
color: #fff;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
margin-bottom: 1.5rem;
}
.map-container {
height: 650px;
width: 100%;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
position: relative;
}
.plan-wrapper {
position: relative;
max-height: 650px;
height: 650px;
overflow: auto;
background-color: #1e293b;
border-radius: 8px;
border: 2px dashed #475569;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
cursor: grab;
user-select: none;
}
.plan-wrapper.grabbing,
.plan-wrapper.grabbing * {
cursor: grabbing !important;
}
.plan-img {
max-width: 100%;
height: auto;
cursor: crosshair;
display: block;
margin: 0 auto;
}
.anchor-marker-img {
position: absolute;
width: 24px;
height: 24px;
background-color: #ef4444;
color: white;
border: 2px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
transform: translate(-50%, -50%);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
pointer-events: none;
transition: all 0.2s;
}
.overlay-control-card {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-radius: 8px;
width: 280px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.corner-marker {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #0284c7;
border: 2px solid #ffffff;
cursor: move;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.1s ease;
}
.corner-marker:hover {
transform: scale(1.3);
background-color: #0ea5e9;
}
.center-move-marker {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: #22c55e;
border: 2px solid #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.15);
transition: transform 0.1s ease;
z-index: 12;
}
.center-move-marker:hover {
transform: scale(1.2);
background-color: #16a34a;
}
.rotate-handle {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #0284c7;
border: 2px solid #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 13;
transition: transform 0.1s ease;
}
.rotate-handle:hover {
transform: scale(1.2);
background-color: #0ea5e9;
}
.scale-handle {
width: 24px;
height: 24px;
border-radius: 4px;
background-color: #e11d48;
border: 2px solid #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 13;
transition: transform 0.1s ease;
}
.scale-handle:hover {
transform: scale(1.2);
background-color: #f43f5e;
}
.anchor-point-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-weight: bold;
font-size: 12px;
color: white;
}
.badge-red { background-color: #ef4444; }
.badge-green { background-color: #22c55e; }
.badge-blue { background-color: #3b82f6; }
.associated-asset-marker {
position: relative;
}
.associated-asset-marker::after {
content: '';
position: absolute;
top: -2px;
left: -2px;
width: 16px;
height: 16px;
border-radius: 50%;
box-shadow: 0 0 0 2px #f43f5e;
animation: pulse 1.8s infinite ease-out;
}
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.7);
}
70% {
transform: scale(2.2);
box-shadow: 0 0 0 6px rgba(244, 63, 94, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(244, 63, 94, 0);
}
}
</style>
{% endblock head %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="georef-header d-flex justify-content-between align-items-center flex-wrap gap-3">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="{% url 'documents:list' %}" class="text-white-50">{% translate "Documents" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'documents:detail' document.pk %}" class="text-white-50">{{ document.title }}</a></li>
<li class="breadcrumb-item active text-white" aria-current="page">{% translate "Géolocalisation" %}</li>
</ol>
</nav>
<h3 class="mb-0 fw-bold">
<i class="bi bi-geo-alt-fill text-primary"></i>
{{ document.title }}
<span class="fs-6 fw-normal text-white-50">
({% if mode == 'overlay' %}{% translate "Mode Superposition" %}{% else %}{% translate "Mode Points d'ancrage" %}{% endif %})
</span>
</h3>
</div>
<div class="d-flex gap-2">
<a href="{% url 'documents:detail' document.pk %}" class="btn btn-outline-light btn-sm px-3">
<i class="bi bi-arrow-left"></i> {% translate "Retour" %}
</a>
<button id="save-btn" class="btn btn-primary btn-sm px-4">
<i class="bi bi-check-circle"></i> {% translate "Sauvegarder" %}
</button>
</div>
</div>
<!-- Interface Main Body -->
{% if mode == 'overlay' %}
<!-- Superposition Mode -->
<div class="card border-0 mb-4">
<div class="card-body p-0 position-relative">
<div id="map" class="map-container"></div>
<!-- Floating Control Panel -->
<div class="overlay-control-card p-3 border">
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-sliders me-2 text-primary"></i>{% translate "Contrôles du plan" %}</h6>
<p class="text-muted small mb-3">Faites glisser les 4 poignées bleues aux angles du plan pour l'aligner précisément sur la carte.</p>
<div class="mb-3">
<label for="opacityRange" class="form-label small fw-bold text-secondary">{% translate "Opacité du plan" %}</label>
<input type="range" class="form-range" id="opacityRange" min="0" max="1" step="0.05" value="0.6">
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-secondary">{% translate "Taille globale (Échelle)" %}</label>
<div class="d-flex align-items-center gap-2">
<button type="button" id="scale-down-btn" class="btn btn-outline-secondary btn-sm flex-fill" title="{% translate 'Rétrécir de 5%' %}"><i class="bi bi-dash"></i> 5%</button>
<button type="button" id="scale-up-btn" class="btn btn-outline-secondary btn-sm flex-fill" title="{% translate 'Agrandir de 5%' %}"><i class="bi bi-plus"></i> 5%</button>
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-secondary">{% translate "Rotation globale" %}</label>
<div class="d-flex align-items-center gap-2">
<button type="button" id="rotate-ccw-btn" class="btn btn-outline-secondary btn-sm flex-fill" title="{% translate 'Tourner à gauche de 1°' %}"><i class="bi bi-arrow-counterclockwise"></i> 1°</button>
<button type="button" id="rotate-cw-btn" class="btn btn-outline-secondary btn-sm flex-fill" title="{% translate 'Tourner à droite de 1°' %}"><i class="bi bi-arrow-clockwise"></i> 1°</button>
</div>
<div class="d-flex align-items-center gap-2 mt-1">
<button type="button" id="rotate-ccw-90-btn" class="btn btn-outline-secondary btn-sm flex-fill" style="font-size: 11px;" title="{% translate 'Pivoter de -90°' %}"><i class="bi bi-reply"></i> -90°</button>
<button type="button" id="rotate-cw-90-btn" class="btn btn-outline-secondary btn-sm flex-fill" style="font-size: 11px;" title="{% translate 'Pivoter de +90°' %}"><i class="bi bi-share"></i> +90°</button>
</div>
</div>
<div class="d-grid gap-2">
<button id="reset-overlay-btn" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-counterclockwise"></i> {% translate "Réinitialiser la position" %}
</button>
</div>
</div>
</div>
</div>
{% else %}
<!-- Anchor Points Mode -->
<div class="row g-4 mb-4">
<div class="col-lg-6">
<div class="card border-0 h-100 shadow-sm">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold text-dark"><i class="bi bi-image me-2 text-primary"></i>1. {% translate "Plan du document" %}</h6>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" id="zoom-out-btn" title="{% translate 'Zoom arrière' %}"><i class="bi bi-zoom-out"></i></button>
<button type="button" class="btn btn-outline-secondary" id="zoom-reset-btn" title="{% translate 'Adapter/Réinitialiser' %}"><span id="zoom-display">100%</span></button>
<button type="button" class="btn btn-outline-secondary" id="zoom-in-btn" title="{% translate 'Zoom avant' %}"><i class="bi bi-zoom-in"></i></button>
</div>
<span class="badge bg-secondary">Page {{ document.georeference_page }}</span>
</div>
</div>
<div class="card-body bg-light p-2">
<p class="text-muted small mb-2 text-center">Cliquez sur un repère remarquable ci-dessous (max 3 points).</p>
<div class="plan-wrapper position-relative" id="plan-wrapper-div" style="overflow: auto;">
<div id="plan-zoomable" style="position: relative; transform-origin: 0 0; width: fit-content; height: fit-content;">
<img src="{% url 'documents:page_image' document.pk document.georeference_page %}" class="plan-img" id="plan-img-element" alt="Plan document" style="max-width: none; width: auto; height: auto;" draggable="false">
<!-- Click markers container -->
<div id="plan-markers-container" style="position: absolute; top:0; left:0; width:100%; height:100%; pointer-events: none;"></div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 h-100 shadow-sm">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold text-dark"><i class="bi bi-map-fill me-2 text-primary"></i>2. {% translate "Carte réelle" %}</h6>
<div class="d-flex gap-2 align-items-center">
<label for="opacityRangeAnchor" class="form-label small fw-bold text-secondary mb-0 me-1">{% translate "Opacité" %}</label>
<input type="range" class="form-range d-inline-block" id="opacityRangeAnchor" min="0" max="1" step="0.05" value="0.6" style="width: 100px;">
</div>
</div>
<div class="card-body p-0 position-relative">
<div id="map" class="map-container" style="height: 606px;"></div>
</div>
</div>
</div>
</div>
<!-- Dashboard of anchor points -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h6 class="mb-0 fw-bold text-dark"><i class="bi bi-list-check me-2 text-primary"></i>3. {% translate "Points d'ancrage configurés" %}</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-9">
<table class="table table-bordered table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 80px;">Point</th>
<th>Coordonnées Plan (Pixels)</th>
<th>Coordonnées Carte (Latitude/Longitude)</th>
<th style="width: 80px;" class="text-center">Action</th>
</tr>
</thead>
<tbody id="anchors-table-body">
<tr>
<td colspan="4" class="text-center text-muted py-3">Aucun point d'ancrage configuré. Commencez à cliquer sur le plan à gauche.</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-3 d-flex flex-column justify-content-center align-items-stretch border-start ps-4">
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-info-circle-fill"></i> Ajoutez exactement 3 points d'ancrage non alignés pour pouvoir calculer et afficher la superposition.
</div>
<button id="preview-anchor-btn" class="btn btn-success btn-sm mb-2" disabled>
<i class="bi bi-play-circle"></i> {% translate "Prévisualiser le calage" %}
</button>
<button id="clear-anchors-btn" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash"></i> {% translate "Tout effacer" %}
</button>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock content %}
{% block script %}
{{ block.super }}
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'common/address_search_control.js' %}"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
const mode = "{{ mode }}";
const documentId = "{{ document.pk }}";
const pageNum = {{ document.georeference_page }};
const pageImageUrl = "{% url 'documents:page_image' document.pk document.georeference_page %}";
let mapStyle = window.MAP_STYLE_LIGHT;
if (!mapStyle) {
mapStyle = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json";
}
// Initialize MapLibre Map
const map = new maplibregl.Map({
container: 'map',
style: mapStyle,
center: {% if associated_coords %}{{ associated_coords|safe }}{% else %}[4.3517, 50.8503]{% endif %}, // Default center (Brussels), will fit bounds if coords exist
zoom: {% if associated_coords %}18{% else %}13{% endif %}
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
{% if associated_coords %}
// Add a non-interactive pulsing marker for the associated asset
const assetEl = document.createElement('div');
assetEl.className = 'associated-asset-marker';
assetEl.style.width = '16px';
assetEl.style.height = '16px';
assetEl.style.borderRadius = '50%';
assetEl.style.backgroundColor = '#f43f5e';
assetEl.style.border = '2px solid white';
assetEl.style.boxShadow = '0 0 6px rgba(0,0,0,0.4)';
assetEl.style.pointerEvents = 'none'; // prevents interfering with clicks!
new maplibregl.Marker({ element: assetEl })
.setLngLat({{ associated_coords|safe }})
.addTo(map);
{% endif %}
// Keep overlay layer and gizmo layer always on top of base layers and orthophotos
map.on('styledata', () => {
const layerId = (mode === 'overlay') ? 'overlay-lyr' : 'overlay-lyr-anchor';
if (map.getLayer(layerId)) {
const layers = map.getStyle().layers;
if (layers && layers.length > 0) {
const topLyrId = layers[layers.length - 1].id;
const secondTopLyrId = layers.length > 1 ? layers[layers.length - 2].id : null;
if (mode === 'overlay') {
if (map.getLayer('overlay-gizmo-lyr')) {
if (topLyrId !== 'overlay-gizmo-lyr' || secondTopLyrId !== layerId) {
map.moveLayer(layerId);
map.moveLayer('overlay-gizmo-lyr');
}
} else {
if (topLyrId !== layerId) {
map.moveLayer(layerId);
}
}
} else {
if (topLyrId !== layerId) {
map.moveLayer(layerId);
}
}
}
}
});
// Add Geocoding/Address search control
if (typeof AddressSearchControl !== 'undefined') {
const searchCtrl = new AddressSearchControl({
autocompleteUrl: "{% url 'common:autocomplete_street' %}"
});
map.addControl(searchCtrl, 'top-left');
searchCtrl.addEventListener('address-selected', function(e) {
if (e.detail && e.detail.lon && e.detail.lat) {
map.flyTo({
center: [e.detail.lon, e.detail.lat],
zoom: 17
});
}
});
}
// Add Satellite Layer Switcher Control
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [
{
id: 'ortho',
name: 'Orthophotos',
icon: 'satellite',
group: 'ortho',
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '© Brussels UrbIS'
}
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: [
'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
],
tileSize: 256,
attribution: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}
]
});
map.addControl(layerSwitcher, 'top-left');
}
// Georeference coordinates from DB
let currentCoords = {% if document.georeference_coords %}{{ document.georeference_coords|safe }}{% else %}null{% endif %};
// Fit Map bounds if coordinates already exist
if (currentCoords) {
const lngs = currentCoords.map(c => c[0]);
const lats = currentCoords.map(c => c[1]);
map.fitBounds([
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)]
], { padding: 80, maxZoom: 18, animate: false });
}
if (mode === 'overlay') {
// ============================================
// OVERLAY MODE IMPLEMENTATION
// ============================================
let cornerMarkers = [];
let centerMarker = null;
let rotateMarker = null;
let scaleMarker = null;
// Set initial coordinates
function getInitialCoords() {
if (currentCoords) {
return JSON.parse(JSON.stringify(currentCoords));
}
const center = map.getCenter();
const delta = 0.001; // approximately 100 meters
return [
[center.lng - delta, center.lat + delta], // TL
[center.lng + delta, center.lat + delta], // TR
[center.lng + delta, center.lat - delta], // BR
[center.lng - delta, center.lat - delta] // BL
];
}
let coords = getInitialCoords();
map.on('load', function() {
// Add Image Source & Layer
map.addSource('overlay-src', {
type: 'image',
url: pageImageUrl,
coordinates: coords
});
map.addLayer({
id: 'overlay-lyr',
type: 'raster',
source: 'overlay-src',
paint: {
'raster-opacity': 0.6
}
});
// Add GeoJSON source/layer for the circle/square alignment gizmos
map.addSource('overlay-gizmo-src', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
});
map.addLayer({
id: 'overlay-gizmo-lyr',
type: 'line',
source: 'overlay-gizmo-src',
paint: {
'line-color': '#0284c7',
'line-width': 1.5,
'line-dasharray': [4, 4]
}
});
// Add Corner Markers
setupCornerMarkers();
});
function updateGizmo() {
if (!map.getSource('overlay-gizmo-src')) return null;
const avgLng = (coords[0][0] + coords[1][0] + coords[2][0] + coords[3][0]) / 4;
const avgLat = (coords[0][1] + coords[1][1] + coords[2][1] + coords[3][1]) / 4;
const center = [avgLng, avgLat];
// Calculate radius of circle based on smaller dimension of the plan
const w_deg = Math.hypot(coords[1][0] - coords[0][0], coords[1][1] - coords[0][1]);
const h_deg = Math.hypot(coords[3][0] - coords[0][0], coords[3][1] - coords[0][1]);
const R = Math.min(w_deg, h_deg) * 0.35;
const cosLat = Math.cos(center[1] * Math.PI / 180);
// 1. Generate circle points (mercator-corrected)
const circlePoints = [];
const steps = 64;
for (let i = 0; i <= steps; i++) {
const theta = (i / steps) * 2 * Math.PI;
const dx = R * Math.cos(theta) / cosLat;
const dy = R * Math.sin(theta);
circlePoints.push([center[0] + dx, center[1] + dy]);
}
// 2. Generate rotated square points
const topEdgeLng = coords[1][0] - coords[0][0];
const topEdgeLat = coords[1][1] - coords[0][1];
const currentRotation = Math.atan2(topEdgeLat, topEdgeLng * cosLat);
const S = R * 1.35; // square is larger than the circle
const cosRot = Math.cos(currentRotation);
const sinRot = Math.sin(currentRotation);
const localCorners = [
[-S, S],
[S, S],
[S, -S],
[-S, -S],
[-S, S]
];
const squarePoints = localCorners.map(lc => {
const rx = lc[0] * cosRot - lc[1] * sinRot;
const ry = lc[0] * sinRot + lc[1] * cosRot;
return [center[0] + rx / cosLat, center[1] + ry];
});
// Update lines on map
map.getSource('overlay-gizmo-src').setData({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: circlePoints
}
},
{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: squarePoints
}
}
]
});
return { center, R, currentRotation, squarePoints, cosLat };
}
function setupCornerMarkers() {
// Remove existing corner markers
cornerMarkers.forEach(m => m.remove());
cornerMarkers = [];
// Clean up central handles
if (centerMarker) { centerMarker.remove(); centerMarker = null; }
if (rotateMarker) { rotateMarker.remove(); rotateMarker = null; }
if (scaleMarker) { scaleMarker.remove(); scaleMarker = null; }
// 1. Create Corner Markers
coords.forEach((coord, i) => {
const el = document.createElement('div');
el.className = 'corner-marker';
el.title = `Angle ${i + 1}`;
const marker = new maplibregl.Marker({
element: el,
draggable: true
})
.setLngLat(coord)
.addTo(map);
marker.on('drag', () => {
const newLL = marker.getLngLat();
coords[i] = [newLL.lng, newLL.lat];
map.getSource('overlay-src').setCoordinates(coords);
// Update center and handles position dynamically
const giz = updateGizmo();
if (giz) {
if (centerMarker) centerMarker.setLngLat(giz.center);
if (rotateMarker) {
const newRotHandle = [giz.center[0] + giz.R * Math.cos(giz.currentRotation + Math.PI/2) / giz.cosLat, giz.center[1] + giz.R * Math.sin(giz.currentRotation + Math.PI/2)];
rotateMarker.setLngLat(newRotHandle);
}
if (scaleMarker) scaleMarker.setLngLat(giz.squarePoints[1]);
}
});
cornerMarkers.push(marker);
});
// 2. Build the visual gizmo and get parameters for placing handles
const giz = updateGizmo();
if (!giz) return;
const { center, R, currentRotation, squarePoints, cosLat } = giz;
// 3. Add Center Move Marker
const centerEl = document.createElement('div');
centerEl.className = 'center-move-marker';
centerEl.innerHTML = '<i class="bi bi-arrows-move text-white" style="font-size: 14px;"></i>';
centerEl.title = "Déplacer tout le plan";
centerMarker = new maplibregl.Marker({
element: centerEl,
draggable: true
})
.setLngLat(center)
.addTo(map);
let startCenterLL = null;
let startCoords = null;
centerMarker.on('dragstart', () => {
startCenterLL = centerMarker.getLngLat();
startCoords = JSON.parse(JSON.stringify(coords));
});
centerMarker.on('drag', () => {
const currentCenterLL = centerMarker.getLngLat();
const dLng = currentCenterLL.lng - startCenterLL.lng;
const dLat = currentCenterLL.lat - startCenterLL.lat;
coords = startCoords.map(coord => [coord[0] + dLng, coord[1] + dLat]);
map.getSource('overlay-src').setCoordinates(coords);
// Move the corner markers along with the image
cornerMarkers.forEach((marker, idx) => {
marker.setLngLat(coords[idx]);
});
// Move rotate and scale handles too
const newGiz = updateGizmo();
if (newGiz) {
if (rotateMarker) {
const newRotHandle = [newGiz.center[0] + newGiz.R * Math.cos(newGiz.currentRotation + Math.PI/2) / newGiz.cosLat, newGiz.center[1] + newGiz.R * Math.sin(newGiz.currentRotation + Math.PI/2)];
rotateMarker.setLngLat(newRotHandle);
}
if (scaleMarker) scaleMarker.setLngLat(newGiz.squarePoints[1]);
}
});
centerMarker.on('dragend', () => {
const finalAvgLng = (coords[0][0] + coords[1][0] + coords[2][0] + coords[3][0]) / 4;
const finalAvgLat = (coords[0][1] + coords[1][1] + coords[2][1] + coords[3][1]) / 4;
centerMarker.setLngLat([finalAvgLng, finalAvgLat]);
});
// 4. Add Rotation Handle (placed on the circle line, at the top/north of plan)
const rotHandleCoord = [center[0] + R * Math.cos(currentRotation + Math.PI/2) / cosLat, center[1] + R * Math.sin(currentRotation + Math.PI/2)];
const rotEl = document.createElement('div');
rotEl.className = 'rotate-handle';
rotEl.innerHTML = '<i class="bi bi-arrow-repeat text-white" style="font-size: 11px;"></i>';
rotEl.title = "Faire pivoter le plan autour du centre";
rotateMarker = new maplibregl.Marker({
element: rotEl,
draggable: true
})
.setLngLat(rotHandleCoord)
.addTo(map);
let startAngle = null;
let startCoordsForRot = null;
rotateMarker.on('dragstart', () => {
const c = centerMarker.getLngLat();
const h = rotateMarker.getLngLat();
startAngle = Math.atan2(h.lat - c.lat, (h.lng - c.lng) * cosLat);
startCoordsForRot = JSON.parse(JSON.stringify(coords));
});
rotateMarker.on('drag', () => {
const c = centerMarker.getLngLat();
const h = rotateMarker.getLngLat();
const currentAngle = Math.atan2(h.lat - c.lat, (h.lng - c.lng) * cosLat);
const theta = currentAngle - startAngle;
// Rotate coords around center with metric correction
coords = startCoordsForRot.map(coord => {
const dx = (coord[0] - c.lng) * cosLat;
const dy = coord[1] - c.lat;
const newDx = dx * Math.cos(theta) - dy * Math.sin(theta);
const newDy = dx * Math.sin(theta) + dy * Math.cos(theta);
return [c.lng + newDx / cosLat, c.lat + newDy];
});
map.getSource('overlay-src').setCoordinates(coords);
// Update corner markers
cornerMarkers.forEach((marker, idx) => {
marker.setLngLat(coords[idx]);
});
// Update gizmo lines and scale handle position
const rGiz = updateGizmo();
if (rGiz && scaleMarker) {
scaleMarker.setLngLat(rGiz.squarePoints[1]);
}
});
rotateMarker.on('dragend', () => {
setupCornerMarkers();
});
// 5. Add Scaling Handle (placed at the top-right corner of the square)
const scaleHandleCoord = squarePoints[1];
const scaleEl = document.createElement('div');
scaleEl.className = 'scale-handle';
scaleEl.innerHTML = '<i class="bi bi-aspect-ratio text-white" style="font-size: 11px;"></i>';
scaleEl.title = "Agrandir ou rétrécir le plan";
scaleMarker = new maplibregl.Marker({
element: scaleEl,
draggable: true
})
.setLngLat(scaleHandleCoord)
.addTo(map);
let startDist = null;
let startCoordsForScale = null;
scaleMarker.on('dragstart', () => {
const c = centerMarker.getLngLat();
const h = scaleMarker.getLngLat();
startDist = Math.hypot((h.lng - c.lng) * cosLat, h.lat - c.lat);
startCoordsForScale = JSON.parse(JSON.stringify(coords));
});
scaleMarker.on('drag', () => {
const c = centerMarker.getLngLat();
const h = scaleMarker.getLngLat();
const currentDist = Math.hypot((h.lng - c.lng) * cosLat, h.lat - c.lat);
if (startDist < 1e-9) return;
const scaleFactor = currentDist / startDist;
// Scale coords around center with metric correction
coords = startCoordsForScale.map(coord => {
const dx = (coord[0] - c.lng) * cosLat;
const dy = coord[1] - c.lat;
return [c.lng + (dx * scaleFactor) / cosLat, c.lat + dy * scaleFactor];
});
map.getSource('overlay-src').setCoordinates(coords);
// Update corner markers
cornerMarkers.forEach((marker, idx) => {
marker.setLngLat(coords[idx]);
});
// Update gizmo lines and rotate handle position
const sGiz = updateGizmo();
if (sGiz && rotateMarker) {
const newRotHandle = [sGiz.center[0] + sGiz.R * Math.cos(sGiz.currentRotation + Math.PI/2) / sGiz.cosLat, sGiz.center[1] + sGiz.R * Math.sin(sGiz.currentRotation + Math.PI/2)];
rotateMarker.setLngLat(newRotHandle);
}
});
scaleMarker.on('dragend', () => {
setupCornerMarkers();
});
}
// Opacity slider
const opacityRange = document.getElementById('opacityRange');
opacityRange.addEventListener('input', function() {
if (map.getLayer('overlay-lyr')) {
map.setPaintProperty('overlay-lyr', 'raster-opacity', parseFloat(this.value));
}
});
// Reset position
const resetBtn = document.getElementById('reset-overlay-btn');
resetBtn.addEventListener('click', function() {
currentCoords = null;
coords = getInitialCoords();
map.getSource('overlay-src').setCoordinates(coords);
setupCornerMarkers();
map.setCenter([coords[0][0], coords[0][1]]);
});
// Universal transform helper (rotation around center + scaling)
function applyTransformToOverlay(scaleFactor, thetaDegrees) {
const avgLng = (coords[0][0] + coords[1][0] + coords[2][0] + coords[3][0]) / 4;
const avgLat = (coords[0][1] + coords[1][1] + coords[2][1] + coords[3][1]) / 4;
const rad = thetaDegrees * Math.PI / 180;
coords = coords.map(coord => {
const dx = coord[0] - avgLng;
const dy = coord[1] - avgLat;
const newDx = scaleFactor * (dx * Math.cos(rad) - dy * Math.sin(rad));
const newDy = scaleFactor * (dx * Math.sin(rad) + dy * Math.cos(rad));
return [avgLng + newDx, avgLat + newDy];
});
map.getSource('overlay-src').setCoordinates(coords);
// Update corner and center markers
cornerMarkers.forEach((marker, idx) => {
marker.setLngLat(coords[idx]);
});
if (centerMarker) {
centerMarker.setLngLat([avgLng, avgLat]);
}
// Update gizmo lines and handles
const giz = updateGizmo();
if (giz) {
if (rotateMarker) {
const newRotHandle = [giz.center[0] + giz.R * Math.cos(giz.currentRotation + Math.PI/2) / giz.cosLat, giz.center[1] + giz.R * Math.sin(giz.currentRotation + Math.PI/2)];
rotateMarker.setLngLat(newRotHandle);
}
if (scaleMarker) {
scaleMarker.setLngLat(giz.squarePoints[1]);
}
}
}
document.getElementById('scale-up-btn').addEventListener('click', () => applyTransformToOverlay(1.05, 0));
document.getElementById('scale-down-btn').addEventListener('click', () => applyTransformToOverlay(0.95, 0));
document.getElementById('rotate-cw-btn').addEventListener('click', () => applyTransformToOverlay(1.0, 1));
document.getElementById('rotate-ccw-btn').addEventListener('click', () => applyTransformToOverlay(1.0, -1));
document.getElementById('rotate-cw-90-btn').addEventListener('click', () => applyTransformToOverlay(1.0, 90));
document.getElementById('rotate-ccw-90-btn').addEventListener('click', () => applyTransformToOverlay(1.0, -90));
// Save button
document.getElementById('save-btn').addEventListener('click', function() {
saveGeoreference(coords);
});
} else {
// ============================================
// ANCHOR POINTS MODE IMPLEMENTATION
// ============================================
let anchorPoints = []; // items like: { id: 1, src: [x, y], dst: [lng, lat], markerPlan: DOMElement, markerMap: Marker }
let activePointId = null; // tracking which point we are placing (null, or 1, 2, 3)
function updateActivePointId(id) {
activePointId = id;
const mapEl = document.getElementById('map');
if (id) {
mapEl.classList.add('placement-cursor');
} else {
mapEl.classList.remove('placement-cursor');
}
}
let calculatedCoords = null;
let zoomLevel = 1.0;
const planImg = document.getElementById('plan-img-element');
const planWrapper = document.getElementById('plan-wrapper-div');
const planZoomable = document.getElementById('plan-zoomable');
const planMarkersContainer = document.getElementById('plan-markers-container');
const tableBody = document.getElementById('anchors-table-body');
const previewBtn = document.getElementById('preview-anchor-btn');
const clearBtn = document.getElementById('clear-anchors-btn');
const zoomInBtn = document.getElementById('zoom-in-btn');
const zoomOutBtn = document.getElementById('zoom-out-btn');
const zoomResetBtn = document.getElementById('zoom-reset-btn');
const zoomDisplay = document.getElementById('zoom-display');
// Helper to get natural dimensions
let imgNaturalW = 0;
let imgNaturalH = 0;
function updateZoom() {
planZoomable.style.transform = `scale(${zoomLevel})`;
zoomDisplay.textContent = `${Math.round(zoomLevel * 100)}%`;
// Adjust markers scale so they retain constant size
document.querySelectorAll('.anchor-marker-img').forEach(marker => {
marker.style.transform = `translate(-50%, -50%) scale(${1 / zoomLevel})`;
});
}
zoomInBtn.addEventListener('click', function(e) {
e.stopPropagation();
if (zoomLevel < 3.0) {
zoomLevel += 0.25;
updateZoom();
}
});
zoomOutBtn.addEventListener('click', function(e) {
e.stopPropagation();
if (zoomLevel > 0.15) {
zoomLevel -= 0.25;
updateZoom();
}
});
zoomResetBtn.addEventListener('click', function(e) {
e.stopPropagation();
if (imgNaturalW > 0) {
const wrapperW = planWrapper.clientWidth;
const fitZoom = Math.min(1.0, (wrapperW - 16) / imgNaturalW);
zoomLevel = fitZoom;
updateZoom();
}
});
planImg.onload = function() {
imgNaturalW = planImg.naturalWidth;
imgNaturalH = planImg.naturalHeight;
// Auto-fit zoom initially
const wrapperW = planWrapper.clientWidth;
zoomLevel = Math.min(1.0, (wrapperW - 16) / imgNaturalW);
updateZoom();
};
// Trigger onload manually if already loaded in cache
if (planImg.complete) {
imgNaturalW = planImg.naturalWidth;
imgNaturalH = planImg.naturalHeight;
const wrapperW = planWrapper.clientWidth;
zoomLevel = Math.min(1.0, (wrapperW - 16) / imgNaturalW);
updateZoom();
}
// Pan (drag to scroll) implementation
let isDown = false;
let startX, startY;
let scrollLeft, scrollTop;
let hasDragged = false;
planWrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // Only left click for panning
isDown = true;
planWrapper.classList.add('grabbing');
startX = e.clientX;
startY = e.clientY;
scrollLeft = planWrapper.scrollLeft;
scrollTop = planWrapper.scrollTop;
hasDragged = false;
});
// Prevent browser native image dragging ghost effect
planImg.addEventListener('dragstart', (e) => {
e.preventDefault();
});
window.addEventListener('mouseup', () => {
if (isDown) {
isDown = false;
planWrapper.classList.remove('grabbing');
}
});
planWrapper.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.clientX;
const y = e.clientY;
const walkX = x - startX;
const walkY = y - startY;
if (Math.abs(walkX) > 5 || Math.abs(walkY) > 5) {
hasDragged = true;
}
planWrapper.scrollLeft = scrollLeft - walkX;
planWrapper.scrollTop = scrollTop - walkY;
});
// Wheel zoom implementation
planWrapper.addEventListener('wheel', (e) => {
e.preventDefault();
// Get mouse position relative to plan-zoomable
const rect = planZoomable.getBoundingClientRect();
const mouseXScaled = e.clientX - rect.left;
const mouseYScaled = e.clientY - rect.top;
const mouseXUnscaled = mouseXScaled / zoomLevel;
const mouseYUnscaled = mouseYScaled / zoomLevel;
const zoomFactor = 1.1;
const oldZoom = zoomLevel;
if (e.deltaY < 0) {
if (zoomLevel < 3.0) {
zoomLevel = Math.min(3.0, zoomLevel * zoomFactor);
}
} else {
if (zoomLevel > 0.15) {
zoomLevel = Math.max(0.15, zoomLevel / zoomFactor);
}
}
if (zoomLevel !== oldZoom) {
updateZoom();
const newScaledX = mouseXUnscaled * zoomLevel;
const newScaledY = mouseYUnscaled * zoomLevel;
planWrapper.scrollLeft += (newScaledX - mouseXScaled);
planWrapper.scrollTop += (newScaledY - mouseYScaled);
}
}, { passive: false });
// Click handler on Plan Zoomable (scaled wrapper)
planZoomable.addEventListener('click', function(e) {
if (hasDragged) {
hasDragged = false;
return;
}
if (anchorPoints.length >= 3) {
alert("Vous pouvez placer un maximum de 3 points d'ancrage.");
return;
}
// Click coordinates relative to the scaled plan-zoomable container
const rect = planZoomable.getBoundingClientRect();
const clickXScaled = e.clientX - rect.left;
const clickYScaled = e.clientY - rect.top;
// Map to unscaled image pixels
const srcX = Math.round(clickXScaled / zoomLevel);
const srcY = Math.round(clickYScaled / zoomLevel);
// Create a temporary anchor point entry
const nextId = getNextAvailablePointId();
updateActivePointId(nextId);
// Add marker visual inside scaled plan-markers-container
const markerDiv = document.createElement('div');
markerDiv.className = `anchor-marker-img badge-${getBadgeColorClass(nextId)}`;
markerDiv.style.left = `${srcX}px`;
markerDiv.style.top = `${srcY}px`;
markerDiv.style.transform = `translate(-50%, -50%) scale(${1 / zoomLevel})`;
markerDiv.textContent = nextId;
planMarkersContainer.appendChild(markerDiv);
anchorPoints.push({
id: nextId,
src: [srcX, srcY],
dst: null,
markerPlan: markerDiv,
markerMap: null
});
updateUI();
// Highlight Map component
const mapEl = document.getElementById('map');
mapEl.style.border = '3px solid #3b82f6';
setTimeout(() => { mapEl.style.border = ''; }, 1500);
});
// Click handler on Map
map.on('click', function(e) {
if (!activePointId) {
alert("Veuillez d'abord cliquer sur un point du plan à gauche avant de désigner sa position correspondante sur la carte.");
return;
}
const currentPoint = anchorPoints.find(p => p.id === activePointId);
if (!currentPoint) return;
// Set geographical coordinates
currentPoint.dst = [e.lngLat.lng, e.lngLat.lat];
// Create map marker
const el = document.createElement('div');
el.className = `anchor-point-badge badge-${getBadgeColorClass(activePointId)}`;
el.textContent = activePointId;
const mapMarker = new maplibregl.Marker({
element: el,
draggable: true
})
.setLngLat(e.lngLat)
.addTo(map);
currentPoint.markerMap = mapMarker;
// Handle drag on map marker
mapMarker.on('dragend', () => {
const newLL = mapMarker.getLngLat();
currentPoint.dst = [newLL.lng, newLL.lat];
updateUI();
triggerAutoPreviewIfReady();
});
updateActivePointId(null); // reset
updateUI();
triggerAutoPreviewIfReady();
});
function getNextAvailablePointId() {
const ids = anchorPoints.map(p => p.id);
for (let i = 1; i <= 3; i++) {
if (!ids.includes(i)) return i;
}
return 1;
}
function getBadgeColorClass(id) {
if (id === 1) return 'red';
if (id === 2) return 'green';
return 'blue';
}
function updateUI() {
// Table render
if (anchorPoints.length === 0) {
tableBody.innerHTML = `
<tr>
<td colspan="4" class="text-center text-muted py-3">Aucun point d'ancrage configuré. Commencez à cliquer sur le plan à gauche.</td>
</tr>
`;
} else {
tableBody.innerHTML = '';
anchorPoints.sort((a,b) => a.id - b.id).forEach(pt => {
const coordsMapText = pt.dst ? `${pt.dst[1].toFixed(6)} , ${pt.dst[0].toFixed(6)}` : `<span class="text-danger fw-bold"><i class="bi bi-exclamation-triangle"></i> Cliquez sur la carte</span>`;
const tr = document.createElement('tr');
tr.innerHTML = `
<td><span class="anchor-point-badge badge-${getBadgeColorClass(pt.id)}">${pt.id}</span></td>
<td>X: ${pt.src[0]}px, Y: ${pt.src[1]}px</td>
<td>${coordsMapText}</td>
<td class="text-center">
<button class="btn btn-outline-danger btn-sm py-0 px-2 delete-pt-btn" data-id="${pt.id}">
<i class="bi bi-trash"></i>
</button>
</td>
`;
tableBody.appendChild(tr);
});
// Attach delete events
document.querySelectorAll('.delete-pt-btn').forEach(btn => {
btn.addEventListener('click', function() {
const id = parseInt(this.dataset.id);
deletePoint(id);
});
});
}
// Enable/disable preview button
const completePoints = anchorPoints.filter(p => p.dst !== null);
if (completePoints.length === 3) {
previewBtn.removeAttribute('disabled');
} else {
previewBtn.setAttribute('disabled', 'true');
}
}
function deletePoint(id) {
const idx = anchorPoints.findIndex(p => p.id === id);
if (idx !== -1) {
const pt = anchorPoints[idx];
if (pt.markerPlan) pt.markerPlan.remove();
if (pt.markerMap) pt.markerMap.remove();
anchorPoints.splice(idx, 1);
}
if (activePointId === id) {
updateActivePointId(null);
}
// Clear map preview layer if exists
clearOverlayLayer();
updateUI();
}
function clearOverlayLayer() {
if (map.getLayer('overlay-lyr-anchor')) {
map.removeLayer('overlay-lyr-anchor');
}
if (map.getSource('overlay-src-anchor')) {
map.removeSource('overlay-src-anchor');
}
calculatedCoords = null;
}
function triggerAutoPreviewIfReady() {
const completePoints = anchorPoints.filter(p => p.dst !== null);
if (completePoints.length === 3) {
previewGeoreference();
}
}
// Affine Transform solver
function solveAffine(pts) {
const x1 = pts[0].src[0], y1 = pts[0].src[1], u1 = pts[0].dst[0], v1 = pts[0].dst[1];
const x2 = pts[1].src[0], y2 = pts[1].src[1], u2 = pts[1].dst[0], v2 = pts[1].dst[1];
const x3 = pts[2].src[0], y3 = pts[2].src[1], u3 = pts[2].dst[0], v3 = pts[2].dst[1];
const D = x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2);
if (Math.abs(D) < 1e-7) {
return null; // Points are collinear
}
const a = (u1 * (y2 - y3) + u2 * (y3 - y1) + u3 * (y1 - y2)) / D;
const b = (u1 * (x3 - x2) + u2 * (x1 - x3) + u3 * (x2 - x1)) / D;
const c = (u1 * (x2 * y3 - x3 * y2) + u2 * (x3 * y1 - x1 * y3) + u3 * (x1 * y2 - x2 * y1)) / D;
const d = (v1 * (y2 - y3) + v2 * (y3 - y1) + v3 * (y1 - y2)) / D;
const e = (v1 * (x3 - x2) + v2 * (x1 - x3) + v3 * (x2 - x1)) / D;
const f = (v1 * (x2 * y3 - x3 * y2) + v2 * (x3 * y1 - x1 * y3) + v3 * (x1 * y2 - x2 * y1)) / D;
return { a, b, c, d, e, f };
}
function previewGeoreference() {
const completePoints = anchorPoints.filter(p => p.dst !== null);
if (completePoints.length < 3) return;
const transform = solveAffine(completePoints);
if (!transform) {
alert("Erreur : Les 3 points d'ancrage sont colinéaires (alignés). Veuillez les placer différemment.");
return;
}
const W = imgNaturalW;
const H = imgNaturalH;
// Compute coordinates of the 4 corners: TL, TR, BR, BL
const tl = [transform.c, transform.f];
const tr = [transform.a * W + transform.c, transform.d * W + transform.f];
const br = [transform.a * W + transform.b * H + transform.c, transform.d * W + transform.e * H + transform.f];
const bl = [transform.b * H + transform.c, transform.e * H + transform.f];
// Display overlay on map
clearOverlayLayer();
calculatedCoords = [tl, tr, br, bl];
map.addSource('overlay-src-anchor', {
type: 'image',
url: pageImageUrl,
coordinates: calculatedCoords
});
const opacityVal = parseFloat(document.getElementById('opacityRangeAnchor').value);
map.addLayer({
id: 'overlay-lyr-anchor',
type: 'raster',
source: 'overlay-src-anchor',
paint: {
'raster-opacity': opacityVal
}
});
// Zoom map to the coordinates
const lngs = calculatedCoords.map(c => c[0]);
const lats = calculatedCoords.map(c => c[1]);
map.fitBounds([
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)]
], { padding: 50, maxZoom: 18 });
}
// Opacity slider for anchor mode
const opacityRangeAnchor = document.getElementById('opacityRangeAnchor');
opacityRangeAnchor.addEventListener('input', function() {
if (map.getLayer('overlay-lyr-anchor')) {
map.setPaintProperty('overlay-lyr-anchor', 'raster-opacity', parseFloat(this.value));
}
});
previewBtn.addEventListener('click', previewGeoreference);
clearBtn.addEventListener('click', function() {
// Delete all anchors
while (anchorPoints.length > 0) {
deletePoint(anchorPoints[0].id);
}
});
// Save button
document.getElementById('save-btn').addEventListener('click', function() {
if (!calculatedCoords) {
alert("Veuillez d'abord placer vos 3 points et générer/prévisualiser le calage avant d'enregistrer.");
return;
}
saveGeoreference(calculatedCoords);
});
}
// Save Georeference Ajax Post
function saveGeoreference(coords) {
const url = "{% url 'documents:georeference_save' document.pk %}";
const saveBtn = document.getElementById('save-btn');
saveBtn.setAttribute('disabled', 'true');
saveBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> {% translate "Enregistrement..." %}`;
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
coordinates: coords,
page: pageNum
})
})
.then(response => {
if (!response.ok) {
return response.json().then(err => { throw new Error(err.message || 'Error saving'); });
}
return response.json();
})
.then(data => {
window.location.href = "{% url 'documents:detail' document.pk %}";
})
.catch(error => {
alert(`Erreur lors de la sauvegarde : ${error.message}`);
saveBtn.removeAttribute('disabled');
saveBtn.innerHTML = `<i class="bi bi-check-circle"></i> {% translate "Sauvegarder" %}`;
});
}
});
</script>
{% endblock script %}