405 lines
14 KiB
JavaScript
405 lines
14 KiB
JavaScript
/**
|
||
* AddressSearchControl – MapLibre GL custom control
|
||
*
|
||
* A reusable address-search control that queries the internal
|
||
* `autocomplete_street` endpoint and places a draggable marker
|
||
* on the selected address.
|
||
*
|
||
* Follows the same patterns as base_map.js (EventTarget) and
|
||
* layer-switcher-control.js (IControl interface + IIFE).
|
||
*
|
||
* Usage
|
||
* -----
|
||
* // 1. Load after maplibre-gl.js and base_map.js:
|
||
* // <script src="{% static 'common/address_search_control.js' %}"></script>
|
||
*
|
||
* // 2. Instantiate and add to a map:
|
||
* const ctrl = new AddressSearchControl({
|
||
* autocompleteUrl : '/common/autocomplete-street/',
|
||
* placeholder : 'Rechercher une adresse…',
|
||
* lonInputId : 'id_lon', // optional – auto-syncs a form input
|
||
* latInputId : 'id_lat', // optional – auto-syncs a form input
|
||
* });
|
||
* map.addControl(ctrl, 'top-left');
|
||
*
|
||
* // 3. Listen to custom events (the control extends EventTarget):
|
||
* ctrl.addEventListener('address-selected', e => console.log(e.detail));
|
||
* ctrl.addEventListener('marker-placed', e => console.log(e.detail));
|
||
* ctrl.addEventListener('marker-moved', e => console.log(e.detail));
|
||
* ctrl.addEventListener('marker-cleared', () => { ... });
|
||
*
|
||
* // 4. Public methods:
|
||
* ctrl.setMarker(lon, lat, fly = true); // place marker programmatically
|
||
* ctrl.clearMarker(); // remove marker + clear inputs
|
||
*
|
||
* Events dispatched
|
||
* -----------------
|
||
* address-selected { lon, lat, label } – user picked a result
|
||
* marker-placed { lon, lat } – marker placed / moved by click
|
||
* marker-moved { lon, lat } – marker dragged by user
|
||
* marker-cleared – marker removed
|
||
*
|
||
* @version 1.0.0
|
||
* @requires maplibre-gl
|
||
*/
|
||
|
||
(function (window) {
|
||
'use strict';
|
||
|
||
/* ── Injected CSS (once per page) ─────────────────────────────────── */
|
||
const _CSS_ID = 'asc-ctrl-styles';
|
||
const _CSS = `
|
||
.asc-container {
|
||
background : #fff;
|
||
border-radius : 4px;
|
||
box-shadow : 0 0 0 2px rgba(0,0,0,.1);
|
||
padding : 4px;
|
||
min-width : 220px;
|
||
position : relative;
|
||
}
|
||
.asc-input-row {
|
||
display : flex;
|
||
align-items : center;
|
||
gap : 4px;
|
||
}
|
||
.asc-input {
|
||
flex : 1;
|
||
min-width : 0;
|
||
border : 1px solid #ccc;
|
||
border-radius : 4px;
|
||
padding : 4px 8px;
|
||
font-size : 13px;
|
||
line-height : 1.4;
|
||
outline : none;
|
||
transition : border-color .15s;
|
||
}
|
||
.asc-input:focus { border-color : #0d6efd; }
|
||
.asc-clear-btn {
|
||
flex-shrink : 0;
|
||
background : none;
|
||
border : none;
|
||
cursor : pointer;
|
||
color : #6c757d;
|
||
font-size : 14px;
|
||
line-height : 1;
|
||
padding : 2px 4px;
|
||
border-radius : 3px;
|
||
display : none;
|
||
}
|
||
.asc-clear-btn:hover { color : #212529; background : #f0f0f0; }
|
||
.asc-clear-btn.visible { display : block; }
|
||
.asc-results {
|
||
position : absolute;
|
||
left : 0;
|
||
right : 0;
|
||
top : calc(100% + 4px);
|
||
background : #fff;
|
||
border : 1px solid #dee2e6;
|
||
border-radius : 4px;
|
||
box-shadow : 0 4px 12px rgba(0,0,0,.12);
|
||
max-height : 220px;
|
||
overflow-y : auto;
|
||
z-index : 9999;
|
||
display : none;
|
||
}
|
||
.asc-results.open { display : block; }
|
||
.asc-result-item {
|
||
padding : 6px 10px;
|
||
font-size : 13px;
|
||
cursor : pointer;
|
||
border-bottom : 1px solid #f0f0f0;
|
||
white-space : nowrap;
|
||
overflow : hidden;
|
||
text-overflow : ellipsis;
|
||
}
|
||
.asc-result-item:last-child { border-bottom : none; }
|
||
.asc-result-item:hover { background : #eef2ff; }
|
||
.asc-no-results {
|
||
padding : 8px 10px;
|
||
font-size : 13px;
|
||
color : #6c757d;
|
||
}
|
||
.asc-loading {
|
||
padding : 8px 10px;
|
||
font-size : 12px;
|
||
color : #999;
|
||
font-style : italic;
|
||
}
|
||
`;
|
||
|
||
/* ── AddressSearchControl ─────────────────────────────────────────── */
|
||
|
||
class AddressSearchControl extends EventTarget {
|
||
|
||
/**
|
||
* @param {object} opts
|
||
* @param {string} opts.autocompleteUrl URL of autocomplete_street endpoint (required)
|
||
* @param {string} [opts.placeholder] Input placeholder text
|
||
* @param {number} [opts.minChars=2] Minimum characters before searching
|
||
* @param {number} [opts.debounce=250] Debounce delay (ms)
|
||
* @param {string} [opts.markerColor] Hex colour for the marker (default: #0d6efd)
|
||
* @param {boolean} [opts.markerDraggable] Whether the marker can be dragged (default: true)
|
||
* @param {string} [opts.lonInputId] DOM id of a hidden lon form field to auto-sync
|
||
* @param {string} [opts.latInputId] DOM id of a hidden lat form field to auto-sync
|
||
*/
|
||
constructor(opts = {}) {
|
||
super();
|
||
this._opts = Object.assign({
|
||
placeholder : 'Rechercher une adresse…',
|
||
minChars : 2,
|
||
debounce : 250,
|
||
markerColor : '#0d6efd',
|
||
markerDraggable: true,
|
||
lonInputId : null,
|
||
latInputId : null,
|
||
}, opts);
|
||
|
||
if (!this._opts.autocompleteUrl) {
|
||
console.error('[AddressSearchControl] autocompleteUrl option is required.');
|
||
}
|
||
|
||
this._map = null;
|
||
this._marker = null;
|
||
this._container = null;
|
||
this._input = null;
|
||
this._results = null;
|
||
this._clearBtn = null;
|
||
this._timer = null;
|
||
}
|
||
|
||
/* ── MapLibre IControl interface ────────────────────────────── */
|
||
|
||
onAdd(map) {
|
||
this._map = map;
|
||
this._injectCSS();
|
||
this._container = this._buildDOM();
|
||
return this._container;
|
||
}
|
||
|
||
onRemove() {
|
||
this.clearMarker();
|
||
this._container?.remove();
|
||
this._map = null;
|
||
}
|
||
|
||
/* ── Public API ─────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Programmatically place the marker and optionally fly to it.
|
||
* @param {number} lon
|
||
* @param {number} lat
|
||
* @param {boolean} [fly=true]
|
||
*/
|
||
setMarker(lon, lat, fly = true) {
|
||
this._placeMarker(lon, lat, fly);
|
||
}
|
||
|
||
/**
|
||
* Remove the marker and clear any synced form inputs.
|
||
*/
|
||
clearMarker() {
|
||
if (this._marker) {
|
||
this._marker.remove();
|
||
this._marker = null;
|
||
}
|
||
this._syncInputs(null, null);
|
||
this.dispatchEvent(new CustomEvent('marker-cleared'));
|
||
}
|
||
|
||
/* ── Private ────────────────────────────────────────────────── */
|
||
|
||
_injectCSS() {
|
||
if (document.getElementById(_CSS_ID)) return;
|
||
const style = document.createElement('style');
|
||
style.id = _CSS_ID;
|
||
style.textContent = _CSS;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
_buildDOM() {
|
||
/* Outer wrapper */
|
||
const container = document.createElement('div');
|
||
container.className = 'maplibregl-ctrl asc-container';
|
||
|
||
/* Input row */
|
||
const row = document.createElement('div');
|
||
row.className = 'asc-input-row';
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'asc-input';
|
||
input.placeholder = this._opts.placeholder;
|
||
input.autocomplete = 'off';
|
||
input.spellcheck = false;
|
||
this._input = input;
|
||
|
||
const clearBtn = document.createElement('button');
|
||
clearBtn.type = 'button';
|
||
clearBtn.className = 'asc-clear-btn';
|
||
clearBtn.innerHTML = '✕';
|
||
clearBtn.title = 'Effacer';
|
||
this._clearBtn = clearBtn;
|
||
|
||
row.append(input, clearBtn);
|
||
|
||
/* Dropdown results */
|
||
const results = document.createElement('div');
|
||
results.className = 'asc-results';
|
||
this._results = results;
|
||
|
||
container.append(row, results);
|
||
this._bindEvents();
|
||
return container;
|
||
}
|
||
|
||
_bindEvents() {
|
||
const { _input: input, _clearBtn: clearBtn, _results: results } = this;
|
||
|
||
/* Typing */
|
||
input.addEventListener('input', () => {
|
||
const q = input.value.trim();
|
||
clearBtn.classList.toggle('visible', q.length > 0);
|
||
clearTimeout(this._timer);
|
||
|
||
if (q.length < this._opts.minChars) {
|
||
this._closeResults();
|
||
return;
|
||
}
|
||
|
||
this._showLoading();
|
||
this._timer = setTimeout(() => this._search(q), this._opts.debounce);
|
||
});
|
||
|
||
/* Keyboard navigation */
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { input.blur(); this._closeResults(); }
|
||
});
|
||
|
||
/* Close on blur (allow click on result first) */
|
||
input.addEventListener('blur', () => {
|
||
setTimeout(() => this._closeResults(), 180);
|
||
});
|
||
|
||
/* Clear search text + marker */
|
||
clearBtn.addEventListener('click', () => {
|
||
input.value = '';
|
||
clearBtn.classList.remove('visible');
|
||
this._closeResults();
|
||
this.clearMarker();
|
||
input.focus();
|
||
});
|
||
|
||
/* Result selection */
|
||
results.addEventListener('click', (e) => {
|
||
const item = e.target.closest('[data-lon]');
|
||
if (!item) return;
|
||
|
||
const lon = parseFloat(item.dataset.lon);
|
||
const lat = parseFloat(item.dataset.lat);
|
||
const label = item.dataset.label || '';
|
||
|
||
input.value = label;
|
||
clearBtn.classList.add('visible');
|
||
this._closeResults();
|
||
|
||
this._placeMarker(lon, lat, true);
|
||
this.dispatchEvent(new CustomEvent('address-selected', {
|
||
detail: { lon, lat, label }
|
||
}));
|
||
});
|
||
}
|
||
|
||
async _search(q) {
|
||
const url = `${this._opts.autocompleteUrl}?q=${encodeURIComponent(q)}`;
|
||
try {
|
||
const resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
if (!resp.ok) { this._closeResults(); return; }
|
||
|
||
const items = await resp.json();
|
||
this._renderResults(items);
|
||
} catch {
|
||
this._closeResults();
|
||
}
|
||
}
|
||
|
||
_renderResults(items) {
|
||
const r = this._results;
|
||
if (!Array.isArray(items) || !items.length) {
|
||
r.innerHTML = '<div class="asc-no-results">Aucun résultat</div>';
|
||
r.classList.add('open');
|
||
return;
|
||
}
|
||
r.innerHTML = items.map(it => {
|
||
const label = (it.label || '').replace(/"/g, '"');
|
||
return `<div class="asc-result-item"
|
||
data-lon="${it.lon}"
|
||
data-lat="${it.lat}"
|
||
data-label="${label}"
|
||
title="${label}">
|
||
${it.label}
|
||
</div>`;
|
||
}).join('');
|
||
r.classList.add('open');
|
||
}
|
||
|
||
_showLoading() {
|
||
const r = this._results;
|
||
r.innerHTML = '<div class="asc-loading">Recherche…</div>';
|
||
r.classList.add('open');
|
||
}
|
||
|
||
_closeResults() {
|
||
this._results.classList.remove('open');
|
||
this._results.innerHTML = '';
|
||
}
|
||
|
||
_placeMarker(lon, lat, fly = true) {
|
||
if (!this._map) return;
|
||
|
||
if (this._marker) this._marker.remove();
|
||
|
||
this._marker = new maplibregl.Marker({
|
||
color : this._opts.markerColor,
|
||
draggable : this._opts.markerDraggable,
|
||
})
|
||
.setLngLat([lon, lat])
|
||
.addTo(this._map);
|
||
|
||
if (this._opts.markerDraggable) {
|
||
this._marker.on('dragend', () => {
|
||
const ll = this._marker.getLngLat();
|
||
this._syncInputs(ll.lng, ll.lat);
|
||
this.dispatchEvent(new CustomEvent('marker-moved', {
|
||
detail: { lon: ll.lng, lat: ll.lat }
|
||
}));
|
||
});
|
||
}
|
||
|
||
this._syncInputs(lon, lat);
|
||
|
||
if (fly) {
|
||
this._map.flyTo({
|
||
center : [lon, lat],
|
||
zoom : Math.max(this._map.getZoom(), 17),
|
||
duration : 800,
|
||
});
|
||
}
|
||
|
||
this.dispatchEvent(new CustomEvent('marker-placed', { detail: { lon, lat } }));
|
||
}
|
||
|
||
_syncInputs(lon, lat) {
|
||
const lonEl = this._opts.lonInputId
|
||
? document.getElementById(this._opts.lonInputId) : null;
|
||
const latEl = this._opts.latInputId
|
||
? document.getElementById(this._opts.latInputId) : null;
|
||
|
||
if (lonEl) lonEl.value = lon !== null ? Number(lon).toFixed(6) : '';
|
||
if (latEl) latEl.value = lat !== null ? Number(lat).toFixed(6) : '';
|
||
}
|
||
}
|
||
|
||
/* ── Expose globally ─────────────────────────────────────────────── */
|
||
window.AddressSearchControl = AddressSearchControl;
|
||
|
||
})(window);
|