315 lines
9.1 KiB
JavaScript
315 lines
9.1 KiB
JavaScript
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: "© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>",
|
|
}).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,
|
|
];
|
|
}
|