diff --git a/Views/Main/Profil/Edit.cshtml b/Views/Main/Profil/Edit.cshtml index dbf9b18..f6508b0 100644 --- a/Views/Main/Profil/Edit.cshtml +++ b/Views/Main/Profil/Edit.cshtml @@ -1,7 +1,12 @@ -ο»Ώ@{ +@{ ViewData["Title"] = "Profil Bank Sampah"; } +@section Styles { + + +} +
@@ -274,7 +279,7 @@ Latitude - +
@@ -282,10 +287,24 @@ Longitude - +
+ + +
+ @{ + ViewData["MapId"] = "map-picker"; + ViewData["LatInputId"] = "latitude-input"; + ViewData["LngInputId"] = "longitude-input"; + ViewData["SearchInputId"] = "search-location"; + ViewData["SearchResultsId"] = "search-results"; + ViewData["ClearSearchBtnId"] = "clear-search"; + ViewData["Label"] = "Pilih Lokasi di Peta"; + } + @await Html.PartialAsync("_LeafletMapPicker") +
@@ -320,4 +339,24 @@
- \ No newline at end of file + + +@section Scripts { + + + +} \ No newline at end of file diff --git a/Views/Main/Profil/Index.cshtml b/Views/Main/Profil/Index.cshtml index 82a60a5..714abc4 100644 --- a/Views/Main/Profil/Index.cshtml +++ b/Views/Main/Profil/Index.cshtml @@ -2,6 +2,17 @@ ViewData["Title"] = "Profil Bank Sampah"; } +@section Styles { + + +} +
@@ -149,12 +160,55 @@
Latitude - 324242 + -6.2088
Longitude - -371872 + 106.8456
+ +
+ + +
+

+ + Lokasi di Peta +

+
+

+ + Peta menampilkan lokasi Bank Sampah +

+
-
\ No newline at end of file + + +@section Scripts { + + +} \ No newline at end of file diff --git a/Views/Shared/_LeafletMapPicker.cshtml b/Views/Shared/_LeafletMapPicker.cshtml new file mode 100644 index 0000000..6608dbe --- /dev/null +++ b/Views/Shared/_LeafletMapPicker.cshtml @@ -0,0 +1,78 @@ +@* + Leaflet Map Picker - Reusable Partial View + + Usage: + @await Html.PartialAsync("_LeafletMapPicker", new LeafletMapPickerModel + { + MapId = "map-picker", + LatInputId = "latitude-input", + LngInputId = "longitude-input", + SearchInputId = "search-location", + SearchResultsId = "search-results", + ClearSearchBtnId = "clear-search", + Label = "Pilih Lokasi di Peta", + HelpText = "Cari lokasi menggunakan search box di atas, atau klik pada peta untuk menentukan lokasi secara manual" + }) +*@ + +@model dynamic + +@{ + var mapId = ViewData["MapId"]?.ToString() ?? "map-picker"; + var latInputId = ViewData["LatInputId"]?.ToString() ?? "latitude-input"; + var lngInputId = ViewData["LngInputId"]?.ToString() ?? "longitude-input"; + var searchInputId = ViewData["SearchInputId"]?.ToString() ?? "search-location"; + var searchResultsId = ViewData["SearchResultsId"]?.ToString() ?? "search-results"; + var clearSearchBtnId = ViewData["ClearSearchBtnId"]?.ToString() ?? "clear-search"; + var label = ViewData["Label"]?.ToString() ?? "Pilih Lokasi di Peta"; + var helpText = ViewData["HelpText"]?.ToString() ?? "Cari lokasi menggunakan search box di atas, atau klik pada peta untuk menentukan lokasi secara manual"; + var showLabel = ViewData["ShowLabel"] == null ? true : (ViewData["ShowLabel"] is bool labelValue && labelValue); + var showSearch = ViewData["ShowSearch"] == null ? true : (ViewData["ShowSearch"] is bool searchValue && searchValue); + var showHelpText = ViewData["ShowHelpText"] == null ? true : (ViewData["ShowHelpText"] is bool helpValue && helpValue); +} + +
+ @if (showLabel) + { + + } + + @if (showSearch) + { + + + } + +
+ + @if (showHelpText) + { +

+ + @helpText +

+ } +
diff --git a/wwwroot/css/leaflet-map-picker.css b/wwwroot/css/leaflet-map-picker.css new file mode 100644 index 0000000..ea6a489 --- /dev/null +++ b/wwwroot/css/leaflet-map-picker.css @@ -0,0 +1,107 @@ +/** + * Leaflet Map Picker - Reusable Styles + */ + +.leaflet-map-picker-container { + width: 100%; +} + +.leaflet-map-picker { + height: 400px; + width: 100%; + cursor: crosshair; + border-radius: 0.5rem; +} + +.leaflet-map-picker-search { + position: relative; + margin-bottom: 1rem; +} + +.leaflet-map-picker-search-input { + width: 100%; + padding-left: 2.5rem; +} + +.leaflet-map-picker-search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; +} + +.leaflet-map-picker-clear-btn { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; + transition: color 0.2s; +} + +.leaflet-map-picker-clear-btn:hover { + color: #4b5563; +} + +.leaflet-map-picker-results { + position: absolute; + left: 0; + right: 0; + margin-top: 0.25rem; + background-color: white; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + max-height: 16rem; + overflow-y: auto; + z-index: 9999; +} + +.leaflet-map-picker-results.hidden { + display: none; +} + +.leaflet-map-picker-result-item { + padding: 0.75rem; + cursor: pointer; + border-bottom: 1px solid #f3f4f6; + transition: background-color 0.2s; +} + +.leaflet-map-picker-result-item:last-child { + border-bottom: none; +} + +.leaflet-map-picker-result-item:hover { + background-color: #f9fafb; +} + +.leaflet-map-picker-help-text { + font-size: 0.75rem; + color: #6b7280; + margin-top: 0.5rem; +} + +/* Loading spinner */ +.leaflet-map-picker-spinner { + width: 1.25rem; + height: 1.25rem; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: leaflet-map-picker-spin 0.6s linear infinite; +} + +@keyframes leaflet-map-picker-spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .leaflet-map-picker { + height: 300px; + } +} diff --git a/wwwroot/js/leaflet-map-picker.README.md b/wwwroot/js/leaflet-map-picker.README.md new file mode 100644 index 0000000..376756f --- /dev/null +++ b/wwwroot/js/leaflet-map-picker.README.md @@ -0,0 +1,309 @@ +# Leaflet Map Picker - Dokumentasi + +Komponen reusable untuk memilih lokasi menggunakan Leaflet Maps dengan fitur pencarian dan reverse geocoding. + +## Fitur + +- πŸ—ΊοΈ Interactive map dengan OpenStreetMap +- πŸ” Pencarian lokasi dengan Nominatim API +- πŸ“ Click-to-select koordinat +- 🏠 Reverse geocoding untuk mendapatkan alamat otomatis +- πŸ“± Responsive design +- ⚑ Mudah dikonfigurasi dan reusable + +## Instalasi + +### 1. Include CSS dan JavaScript + +Tambahkan di section `Styles` dan `Scripts` pada view Anda: + +```cshtml +@section Styles { + + +} + +@section Scripts { + + +} +``` + +## Penggunaan + +### Metode 1: Menggunakan Partial View (Recommended) + +#### Langkah 1: Tambahkan Input Fields untuk Latitude dan Longitude + +```cshtml +
+ Latitude + +
+ +
+ Longitude + +
+``` + +#### Langkah 2: Include Partial View + +```cshtml +
+ @{ + ViewData["MapId"] = "map-picker"; + ViewData["LatInputId"] = "latitude-input"; + ViewData["LngInputId"] = "longitude-input"; + ViewData["SearchInputId"] = "search-location"; + ViewData["SearchResultsId"] = "search-results"; + ViewData["ClearSearchBtnId"] = "clear-search"; + ViewData["Label"] = "Pilih Lokasi di Peta"; + } + @await Html.PartialAsync("_LeafletMapPicker") +
+``` + +#### Langkah 3: Initialize JavaScript Component + +```cshtml +@section Scripts { + + + +} +``` + +### Metode 2: HTML Manual + +```html +
+ + + + + +
+

+ + Cari lokasi menggunakan search box di atas, atau klik pada peta untuk menentukan lokasi secara manual +

+
+``` + +## Konfigurasi Options + +| Option | Type | Default | Deskripsi | +|--------|------|---------|-----------| +| `mapElementId` | string | `'map-picker'` | ID elemen untuk map container | +| `latInputId` | string | `'latitude-input'` | ID input field untuk latitude | +| `lngInputId` | string | `'longitude-input'` | ID input field untuk longitude | +| `searchInputId` | string | `'search-location'` | ID input field untuk search box | +| `searchResultsId` | string | `'search-results'` | ID elemen untuk search results dropdown | +| `clearSearchBtnId` | string | `'clear-search'` | ID button untuk clear search | +| `alamatInputSelector` | string | `null` | CSS selector untuk input alamat (optional) | +| `initialLat` | number | `-6.2088` | Latitude awal map (Jakarta) | +| `initialLng` | number | `106.8456` | Longitude awal map (Jakarta) | +| `initialZoom` | number | `12` | Zoom level awal | +| `maxZoom` | number | `18` | Maximum zoom level | +| `minZoom` | number | `4` | Minimum zoom level | +| `searchDebounceMs` | number | `500` | Debounce delay untuk search (ms) | +| `countryCode` | string | `'id'` | Country code untuk filter pencarian | +| `language` | string | `'id'` | Language untuk API results | + +## ViewData Options untuk Partial View + +| Key | Type | Default | Deskripsi | +|-----|------|---------|-----------| +| `MapId` | string | `'map-picker'` | ID untuk map element | +| `LatInputId` | string | `'latitude-input'` | ID untuk latitude input | +| `LngInputId` | string | `'longitude-input'` | ID untuk longitude input | +| `SearchInputId` | string | `'search-location'` | ID untuk search input | +| `SearchResultsId` | string | `'search-results'` | ID untuk search results | +| `ClearSearchBtnId` | string | `'clear-search'` | ID untuk clear button | +| `Label` | string | `'Pilih Lokasi di Peta'` | Label text | +| `HelpText` | string | Default help text | Help text di bawah map | +| `ShowLabel` | bool | `true` | Tampilkan label | +| `ShowSearch` | bool | `true` | Tampilkan search box | +| `ShowHelpText` | bool | `true` | Tampilkan help text | + +## Public API Methods + +### `getCoordinates()` +Mendapatkan koordinat saat ini. + +```javascript +const coords = leafletMapPickerInstance.getCoordinates(); +console.log(coords); // { lat: -6.2088, lng: 106.8456 } +``` + +### `setCoordinates(lat, lng, updateMarker = true)` +Set koordinat secara programmatic. + +```javascript +leafletMapPickerInstance.setCoordinates(-6.2088, 106.8456, true); +``` + +### `destroy()` +Clean up instance dan event listeners. + +```javascript +leafletMapPickerInstance.destroy(); +``` + +## Contoh Lengkap + +```cshtml +@{ + ViewData["Title"] = "Edit Lokasi"; +} + +@section Styles { + + +} + +
+
+
+ Latitude + +
+ +
+ Longitude + +
+
+ +
+ Alamat + +
+ + @{ + ViewData["MapId"] = "map-picker"; + ViewData["LatInputId"] = "latitude-input"; + ViewData["LngInputId"] = "longitude-input"; + ViewData["SearchInputId"] = "search-location"; + ViewData["SearchResultsId"] = "search-results"; + ViewData["ClearSearchBtnId"] = "clear-search"; + ViewData["Label"] = "Pilih Lokasi di Peta"; + } + @await Html.PartialAsync("_LeafletMapPicker") + + +
+ +@section Scripts { + + + +} +``` + +## Custom Styling + +Anda bisa override CSS classes untuk custom styling: + +```css +/* Custom map height */ +.leaflet-map-picker { + height: 500px; +} + +/* Custom search box styling */ +.leaflet-map-picker-search-input { + border-color: #your-color; +} + +/* Custom search results */ +.leaflet-map-picker-result-item:hover { + background-color: #your-color; +} +``` + +## Browser Support + +- Chrome (latest) +- Firefox (latest) +- Safari (latest) +- Edge (latest) + +## Dependencies + +- Leaflet 1.9.4+ +- OpenStreetMap (Tile Layer) +- Nominatim API (Geocoding) + +## Troubleshooting + +### Map tidak muncul +- Pastikan Leaflet CSS dan JS sudah ter-load +- Periksa console untuk error +- Pastikan element ID sudah benar + +### Search tidak bekerja +- Pastikan ada koneksi internet +- Nominatim API memiliki rate limit +- Periksa browser console untuk error + +### Koordinat tidak update +- Pastikan input ID sudah benar +- Periksa attribute `step="any"` pada input number + +## License + +MIT License + +## Author + +Bank Sampah Development Team diff --git a/wwwroot/js/leaflet-map-picker.js b/wwwroot/js/leaflet-map-picker.js new file mode 100644 index 0000000..629b147 --- /dev/null +++ b/wwwroot/js/leaflet-map-picker.js @@ -0,0 +1,423 @@ +/** + * 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 = ` +
+
+
+ Mencari lokasi... +
+
+ `; + 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 = ` +
+ + Gagal mencari lokasi. Silakan coba lagi. +
+ `; + } + } + + displaySearchResults(results) { + if (!this.searchResults) return; + + if (results.length === 0) { + this.searchResults.innerHTML = ` +
+ + Lokasi tidak ditemukan +
+ `; + this.searchResults.classList.remove('hidden'); + return; + } + + this.searchResults.innerHTML = results.map(result => { + const escapedDisplayName = result.display_name.replace(/'/g, "\\'"); + return ` +
+
+ +
+

${result.display_name}

+

+ ${parseFloat(result.lat).toFixed(6)}, ${parseFloat(result.lon).toFixed(6)} +

+
+
+
+ `; + }).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(` +
+ Lokasi Dipilih
+ ${lat.toFixed(8)}, ${lng.toFixed(8)}
+ Drag marker untuk mengubah lokasi +
+ `).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(` +
+ Lokasi Dipilih
+ ${position.lat.toFixed(8)}, ${position.lng.toFixed(8)}
+ Drag marker untuk mengubah lokasi +
+ `); + + // 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; +}