loko/streetup/common/static/common/address_search_control.js
2026-07-22 14:48:40 +02:00

405 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = '&#x2715;';
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, '&quot;');
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);