1414 lines
64 KiB
HTML
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 %}
|