import L from "leaflet"; import "leaflet/dist/leaflet.css"; // manually load the markers so that they can be embedded in the prod bundle import markerIconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png"; import markerIconUrl from "leaflet/dist/images/marker-icon.png"; import markerShadowUrl from "leaflet/dist/images/marker-shadow.png"; const defaultZoomLevel = 8; window.app = window.app || {}; window.app.components = window.app.components || {}; /** * Leaflet component for showing and adjust a single geo point value. * * @param {Object} [propsArg] * @return {Element} */ window.app.components.leaflet = function(propsArg = {}) { const props = store({ rid: undefined, id: undefined, hidden: undefined, inert: undefined, className: "", point: { lat: 0, lon: 0 }, onchange: function(point) {}, }); const watchers = app.utils.extendStore(props, propsArg); let map; let marker; let panTimeoutId; watchers.push( watch( () => { if (props.point.lat > 90) { props.point.lat = 90; } if (props.point.lat < -90) { props.point.lat = -90; } if (props.point.lon > 180) { props.point.lon = 180; } if (props.point.lon < -180) { props.point.lon = -180; } }, () => { panInside(); }, ), ); function panInside(debounce = 200) { if (!map) { return; } clearTimeout(panTimeoutId); panTimeoutId = setTimeout(() => { marker?.setLatLng([props.point.lat, props.point.lon]); map?.panInside([props.point.lat, props.point.lon], { padding: [20, 40] }); }, debounce); } function initMap(mapEl) { const latlon = [toFixedCoord(props.point.lat), toFixedCoord(props.point.lon)]; map = L.map(mapEl, { zoomControl: false }).setView(latlon, defaultZoomLevel); L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap", }).addTo(map); // reassign the default marker images with the loaded ones // (https://leafletjs.com/reference.html#icon-default-option) L.Icon.Default.prototype.options.iconUrl = markerIconUrl; L.Icon.Default.prototype.options.iconRetinaUrl = markerIconRetinaUrl; L.Icon.Default.prototype.options.shadowUrl = markerShadowUrl; L.Icon.Default.imagePath = ""; marker = L.marker(latlon, { draggable: true, autoPan: true, }).addTo(map); marker.bindTooltip("drag or right click anywhere on the map to move"); marker.on("moveend", (e) => { if (e.sourceTarget?._latlng) { select(e.sourceTarget._latlng.lat, e.sourceTarget._latlng.lng, false); } }); map.on("contextmenu", (e) => { select(e.latlng.lat, e.latlng.lng, false); }); } function destroyMap() { clearTimeout(panTimeoutId); marker?.remove(); map?.remove(); } function select(lat, lon, centerMap = true) { const point = { lat: toFixedCoord(lat), lon: toFixedCoord(lon), }; if (props.onchange && props.onchange(point) === false) { return; } props.point = point; // center the map if (centerMap) { marker?.setLatLng([props.point.lat, props.point.lon]); // optimistic marker update map?.panTo([props.point.lat, props.point.lon], { animate: false }); } map.getContainer()?.dispatchEvent(new CustomEvent("change", { detail: point })); resetSearch?.(); } const [searchEl, resetSearch] = initSearch(select); return t.div( { rid: props.rid, id: () => props.id, hidden: () => props.hidden, inert: () => props.inert, className: "map-container", onunmount: () => { watchers.forEach((w) => w?.unwatch()); }, }, searchEl, t.div({ className: "map-box", onmount: (el) => { initMap(el); }, onunmount: () => { destroyMap(); }, }), ); }; function toFixedCoord(coord) { return +(+coord).toFixed(6) || 0; } function initSearch(selectFunc = null) { const data = store({ searchTerm: "", isSearching: false, searchResults: [], }); let searchTimeoutId; let searchAbortController; function reset() { searchAbortController?.abort("reset"); clearTimeout(searchTimeoutId); data.isSearching = false; data.searchResults = []; data.searchTerm = ""; } // note: using debounce > 1s to minimize hitting the API rate limits // (see also https://operations.osmfoundation.org/policies/nominatim/) function search(debounce = 1100) { clearTimeout(searchTimeoutId); searchAbortController?.abort("search debounce"); data.isSearching = true; data.searchResults = []; if (!data.searchTerm) { data.isSearching = false; return; } searchTimeoutId = setTimeout(async () => { try { searchAbortController = new AbortController(); const response = await fetch( "https://nominatim.openstreetmap.org/search.php?format=jsonv2&q=" + encodeURIComponent(data.searchTerm), { signal: searchAbortController.signal }, ); if (response.status != 200) { throw new Error("OpenStreetMap API error " + response.status); } const results = []; const addresses = await response.json(); for (const item of addresses) { results.push({ lat: item.lat, lon: item.lon, name: item.display_name, }); } data.searchResults = results; } catch (err) { console.warn("[address search failed]", err); } data.isSearching = false; }, debounce); } const searchInput = t.div( { className: "fields" }, t.div( { className: "field" }, t.input({ type: "text", placeholder: "Search address...", value: () => data.searchTerm, oninput: (e) => (data.searchTerm = e.target.value), }), ), t.div({ className: "field addon p-l-10 p-r-10" }, () => { if (data.isSearching) { return t.span({ className: "loader sm" }); } if (data.searchTerm.length) { return t.button( { type: "button", className: "link-hint", title: "Clear search", onclick: () => reset(), }, t.i({ className: "ri-close-line", ariaHidden: true }), ); } }), ); const searchDropdown = t.div({ className: "dropdown", popover: "manual" }, () => { return data.searchResults.map((item) => { return t.button( { type: "button", className: "dropdown-item", title: "Select address coordinates", onclick: () => selectFunc?.(item.lat, item.lon), }, item.name, ); }); }); const watchers = []; return [ t.div( { className: "map-search", onmount: () => { watchers.push( watch( () => data.searchTerm, (searchTerm) => { search(searchTerm); }, ), ); watchers.push( watch( () => data.searchResults, (results) => { if (results.length) { searchDropdown.showPopover({ source: searchInput }); } else { searchDropdown.hidePopover(); } }, ), ); }, onunmount: () => { watchers.forEach((w) => w?.unwatch()); reset(); }, }, searchInput, searchDropdown, ), reset, ]; }