424 lines
14 KiB
JavaScript
424 lines
14 KiB
JavaScript
/**
|
|
* Leaflet Map Picker - Reusable Component
|
|
*
|
|
* Usage:
|
|
* const mapPicker = new LeafletMapPicker({
|
|
* mapElementId: 'map-picker',
|
|
* latInputId: 'latitude-input',
|
|
* lngInputId: 'longitude-input',
|
|
* searchInputId: 'search-location',
|
|
* searchResultsId: 'search-results',
|
|
* clearSearchBtnId: 'clear-search',
|
|
* alamatInputSelector: 'textarea[name="alamat_lengkap"]',
|
|
* initialLat: -6.2088,
|
|
* initialLng: 106.8456,
|
|
* initialZoom: 12
|
|
* });
|
|
*/
|
|
|
|
class LeafletMapPicker {
|
|
constructor(options) {
|
|
// Default options
|
|
this.options = {
|
|
mapElementId: 'map-picker',
|
|
latInputId: 'latitude-input',
|
|
lngInputId: 'longitude-input',
|
|
searchInputId: 'search-location',
|
|
searchResultsId: 'search-results',
|
|
clearSearchBtnId: 'clear-search',
|
|
alamatInputSelector: null,
|
|
initialLat: -6.2088,
|
|
initialLng: 106.8456,
|
|
initialZoom: 12,
|
|
maxZoom: 18,
|
|
minZoom: 4,
|
|
searchDebounceMs: 500,
|
|
countryCode: 'id',
|
|
language: 'id',
|
|
...options
|
|
};
|
|
|
|
// State
|
|
this.currentMarker = null;
|
|
this.searchTimeout = null;
|
|
|
|
// Initialize
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
// Get DOM elements
|
|
this.mapElement = document.getElementById(this.options.mapElementId);
|
|
this.latInput = document.getElementById(this.options.latInputId);
|
|
this.lngInput = document.getElementById(this.options.lngInputId);
|
|
this.searchInput = document.getElementById(this.options.searchInputId);
|
|
this.searchResults = document.getElementById(this.options.searchResultsId);
|
|
this.clearSearchBtn = document.getElementById(this.options.clearSearchBtnId);
|
|
this.alamatInput = this.options.alamatInputSelector
|
|
? document.querySelector(this.options.alamatInputSelector)
|
|
: null;
|
|
|
|
if (!this.mapElement) {
|
|
console.error(`Map element with id "${this.options.mapElementId}" not found`);
|
|
return;
|
|
}
|
|
|
|
// Initialize map
|
|
this.initMap();
|
|
|
|
// Bind events
|
|
this.bindEvents();
|
|
|
|
// Initialize marker if coordinates already exist
|
|
this.initializeExistingMarker();
|
|
}
|
|
|
|
initMap() {
|
|
// Initialize map
|
|
this.map = L.map(this.mapElement).setView(
|
|
[this.options.initialLat, this.options.initialLng],
|
|
this.options.initialZoom
|
|
);
|
|
|
|
// Add tile layer
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: this.options.maxZoom,
|
|
minZoom: this.options.minZoom
|
|
}).addTo(this.map);
|
|
|
|
// Handle map click
|
|
this.map.on('click', (e) => this.onMapClick(e));
|
|
}
|
|
|
|
bindEvents() {
|
|
// Search input events
|
|
if (this.searchInput) {
|
|
this.searchInput.addEventListener('input', (e) => this.onSearchInput(e));
|
|
this.searchInput.addEventListener('keydown', (e) => this.onSearchKeydown(e));
|
|
}
|
|
|
|
// Clear search button
|
|
if (this.clearSearchBtn) {
|
|
this.clearSearchBtn.addEventListener('click', () => this.clearSearch());
|
|
}
|
|
|
|
// Manual coordinate input events
|
|
if (this.latInput) {
|
|
this.latInput.addEventListener('change', () => this.onCoordinateChange());
|
|
}
|
|
|
|
if (this.lngInput) {
|
|
this.lngInput.addEventListener('change', () => this.onCoordinateChange());
|
|
}
|
|
|
|
// Close search results when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (this.searchInput && this.searchResults &&
|
|
!this.searchInput.contains(e.target) &&
|
|
!this.searchResults.contains(e.target)) {
|
|
this.searchResults.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
initializeExistingMarker() {
|
|
window.addEventListener('load', () => {
|
|
const lat = parseFloat(this.latInput?.value);
|
|
const lng = parseFloat(this.lngInput?.value);
|
|
|
|
if (!isNaN(lat) && !isNaN(lng)) {
|
|
this.updateMarker(lat, lng);
|
|
}
|
|
});
|
|
}
|
|
|
|
onMapClick(e) {
|
|
const lat = e.latlng.lat;
|
|
const lng = e.latlng.lng;
|
|
|
|
// Update form inputs
|
|
if (this.latInput) this.latInput.value = lat.toFixed(8);
|
|
if (this.lngInput) this.lngInput.value = lng.toFixed(8);
|
|
|
|
// Update marker and fetch address
|
|
this.updateMarker(lat, lng, true);
|
|
}
|
|
|
|
onSearchInput(e) {
|
|
const query = e.target.value.trim();
|
|
|
|
// Show/hide clear button
|
|
if (this.clearSearchBtn) {
|
|
if (query.length > 0) {
|
|
this.clearSearchBtn.classList.remove('hidden');
|
|
} else {
|
|
this.clearSearchBtn.classList.add('hidden');
|
|
if (this.searchResults) {
|
|
this.searchResults.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Debounce search
|
|
clearTimeout(this.searchTimeout);
|
|
this.searchTimeout = setTimeout(() => {
|
|
this.searchLocation(query);
|
|
}, this.options.searchDebounceMs);
|
|
}
|
|
|
|
onSearchKeydown(e) {
|
|
// Prevent form submission on Enter
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
onCoordinateChange() {
|
|
const lat = parseFloat(this.latInput?.value);
|
|
const lng = parseFloat(this.lngInput?.value);
|
|
|
|
if (!isNaN(lat) && !isNaN(lng)) {
|
|
this.updateMarker(lat, lng);
|
|
}
|
|
}
|
|
|
|
async searchLocation(query) {
|
|
if (!this.searchResults || query.length < 3) {
|
|
if (this.searchResults) {
|
|
this.searchResults.classList.add('hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
this.searchResults.innerHTML = `
|
|
<div class="p-4 text-center">
|
|
<div class="inline-flex items-center gap-3">
|
|
<div class="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
|
<span class="text-gray-600">Mencari lokasi...</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
this.searchResults.classList.remove('hidden');
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&countrycodes=${this.options.countryCode}&limit=5&addressdetails=1`,
|
|
{
|
|
headers: {
|
|
'Accept-Language': this.options.language
|
|
}
|
|
}
|
|
);
|
|
const data = await response.json();
|
|
|
|
this.displaySearchResults(data);
|
|
} catch (error) {
|
|
console.error('Error searching location:', error);
|
|
this.searchResults.innerHTML = `
|
|
<div class="p-4 text-center text-red-600">
|
|
<i class="ph ph-warning me-2"></i>
|
|
Gagal mencari lokasi. Silakan coba lagi.
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
displaySearchResults(results) {
|
|
if (!this.searchResults) return;
|
|
|
|
if (results.length === 0) {
|
|
this.searchResults.innerHTML = `
|
|
<div class="p-4 text-center text-gray-500">
|
|
<i class="ph ph-info me-2"></i>
|
|
Lokasi tidak ditemukan
|
|
</div>
|
|
`;
|
|
this.searchResults.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
this.searchResults.innerHTML = results.map(result => {
|
|
const escapedDisplayName = result.display_name.replace(/'/g, "\\'");
|
|
return `
|
|
<div class="p-3 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0 transition-colors"
|
|
onclick="window.leafletMapPickerInstance.selectSearchResult(${result.lat}, ${result.lon}, '${escapedDisplayName}')">
|
|
<div class="flex items-start gap-3">
|
|
<i class="ph ph-map-pin text-primary mt-1"></i>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-800 truncate">${result.display_name}</p>
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
${parseFloat(result.lat).toFixed(6)}, ${parseFloat(result.lon).toFixed(6)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
this.searchResults.classList.remove('hidden');
|
|
}
|
|
|
|
selectSearchResult(lat, lon, displayName) {
|
|
// Update coordinates
|
|
if (this.latInput) this.latInput.value = parseFloat(lat).toFixed(8);
|
|
if (this.lngInput) this.lngInput.value = parseFloat(lon).toFixed(8);
|
|
|
|
// Update marker
|
|
this.updateMarker(lat, lon, true);
|
|
|
|
// Clear search
|
|
if (this.searchResults) this.searchResults.classList.add('hidden');
|
|
if (this.searchInput) this.searchInput.value = '';
|
|
if (this.clearSearchBtn) this.clearSearchBtn.classList.add('hidden');
|
|
}
|
|
|
|
clearSearch() {
|
|
if (this.searchInput) this.searchInput.value = '';
|
|
if (this.searchResults) this.searchResults.classList.add('hidden');
|
|
if (this.clearSearchBtn) this.clearSearchBtn.classList.add('hidden');
|
|
if (this.searchInput) this.searchInput.focus();
|
|
}
|
|
|
|
async fetchAddress(lat, lng) {
|
|
if (!this.alamatInput) return;
|
|
|
|
// Show loading state
|
|
const originalPlaceholder = this.alamatInput.placeholder;
|
|
this.alamatInput.placeholder = 'Mengambil alamat dari koordinat...';
|
|
this.alamatInput.disabled = true;
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1`,
|
|
{
|
|
headers: {
|
|
'Accept-Language': this.options.language
|
|
}
|
|
}
|
|
);
|
|
const data = await response.json();
|
|
|
|
if (data && data.display_name) {
|
|
// Build formatted address
|
|
const addr = data.address || {};
|
|
let formattedAddress = '';
|
|
|
|
// Try to build a structured address
|
|
if (addr.road || addr.suburb || addr.village) {
|
|
const parts = [];
|
|
if (addr.road) parts.push(addr.road);
|
|
if (addr.house_number) parts.push('No. ' + addr.house_number);
|
|
if (addr.suburb || addr.village) parts.push(addr.suburb || addr.village);
|
|
if (addr.city || addr.town || addr.city_district) {
|
|
parts.push(addr.city || addr.town || addr.city_district);
|
|
}
|
|
if (addr.state) parts.push(addr.state);
|
|
if (addr.postcode) parts.push(addr.postcode);
|
|
|
|
formattedAddress = parts.join(', ');
|
|
} else {
|
|
// Fallback to display_name
|
|
formattedAddress = data.display_name;
|
|
}
|
|
|
|
this.alamatInput.value = formattedAddress;
|
|
this.alamatInput.placeholder = originalPlaceholder;
|
|
|
|
// Show success feedback
|
|
this.alamatInput.classList.add('border-success');
|
|
setTimeout(() => this.alamatInput.classList.remove('border-success'), 2000);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching address:', error);
|
|
this.alamatInput.placeholder = 'Gagal mengambil alamat, silakan isi manual...';
|
|
setTimeout(() => this.alamatInput.placeholder = originalPlaceholder, 3000);
|
|
} finally {
|
|
this.alamatInput.disabled = false;
|
|
}
|
|
}
|
|
|
|
updateMarker(lat, lng, fetchAddr = false) {
|
|
// Remove existing marker
|
|
if (this.currentMarker) {
|
|
this.map.removeLayer(this.currentMarker);
|
|
}
|
|
|
|
// Add new draggable marker
|
|
this.currentMarker = L.marker([lat, lng], {
|
|
draggable: true
|
|
}).addTo(this.map);
|
|
|
|
this.currentMarker.bindPopup(`
|
|
<div style="text-align: center;">
|
|
<strong>Lokasi Dipilih</strong><br>
|
|
<small>${lat.toFixed(8)}, ${lng.toFixed(8)}</small><br>
|
|
<small style="color: #6b7280; margin-top: 4px; display: block;">Drag marker untuk mengubah lokasi</small>
|
|
</div>
|
|
`).openPopup();
|
|
|
|
// Handle marker drag
|
|
this.currentMarker.on('dragend', (e) => {
|
|
const marker = e.target;
|
|
const position = marker.getLatLng();
|
|
|
|
// Update input values
|
|
if (this.latInput) this.latInput.value = position.lat.toFixed(8);
|
|
if (this.lngInput) this.lngInput.value = position.lng.toFixed(8);
|
|
|
|
// Update popup content
|
|
marker.setPopupContent(`
|
|
<div style="text-align: center;">
|
|
<strong>Lokasi Dipilih</strong><br>
|
|
<small>${position.lat.toFixed(8)}, ${position.lng.toFixed(8)}</small><br>
|
|
<small style="color: #6b7280; margin-top: 4px; display: block;">Drag marker untuk mengubah lokasi</small>
|
|
</div>
|
|
`);
|
|
|
|
// Fetch address for new position
|
|
this.fetchAddress(position.lat, position.lng);
|
|
});
|
|
|
|
// Center map on marker
|
|
this.map.setView([lat, lng], 15);
|
|
|
|
// Fetch address if requested
|
|
if (fetchAddr) {
|
|
this.fetchAddress(lat, lng);
|
|
}
|
|
}
|
|
|
|
// Public API methods
|
|
getCoordinates() {
|
|
return {
|
|
lat: parseFloat(this.latInput?.value),
|
|
lng: parseFloat(this.lngInput?.value)
|
|
};
|
|
}
|
|
|
|
setCoordinates(lat, lng, updateMarker = true) {
|
|
if (this.latInput) this.latInput.value = lat.toFixed(8);
|
|
if (this.lngInput) this.lngInput.value = lng.toFixed(8);
|
|
|
|
if (updateMarker) {
|
|
this.updateMarker(lat, lng);
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
// Clean up event listeners and map
|
|
if (this.map) {
|
|
this.map.remove();
|
|
}
|
|
clearTimeout(this.searchTimeout);
|
|
}
|
|
}
|
|
|
|
// Export for use in modules or expose globally
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = LeafletMapPicker;
|
|
} else {
|
|
window.LeafletMapPicker = LeafletMapPicker;
|
|
}
|