feat: leaflet
parent
80c4c8e758
commit
29f1c5fb66
|
|
@ -1,7 +1,12 @@
|
|||
@{
|
||||
@{
|
||||
ViewData["Title"] = "Profil Bank Sampah";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="~/css/leaflet-map-picker.css" />
|
||||
}
|
||||
|
||||
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:gap-0">
|
||||
<div class="prose">
|
||||
<span class="text-xl font-semibold text-black">
|
||||
|
|
@ -274,7 +279,7 @@
|
|||
<legend class="fieldset-legend">
|
||||
Latitude
|
||||
</legend>
|
||||
<input type="number" class="input" placeholder="Latitude" value="324242" />
|
||||
<input type="number" step="any" class="input" id="latitude-input" name="latitude" placeholder="Latitude" value="-6.2088" />
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
|
|
@ -282,10 +287,24 @@
|
|||
<legend class="fieldset-legend">
|
||||
Longitude
|
||||
</legend>
|
||||
<input type="number" class="input" placeholder="Longitude" value="-371872" />
|
||||
<input type="number" step="any" class="input" id="longitude-input" name="longitude" placeholder="Longitude" value="106.8456" />
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Picker -->
|
||||
<div class="mt-6">
|
||||
@{
|
||||
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")
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="grid grid-cols-1 space-y-2 md:grid-cols-4 md:space-x-6">
|
||||
<div class="flex flex-col">
|
||||
|
|
@ -320,4 +339,24 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="~/js/leaflet-map-picker.js"></script>
|
||||
<script>
|
||||
// Initialize the reusable Leaflet Map Picker component
|
||||
window.leafletMapPickerInstance = 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
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
|
@ -2,6 +2,17 @@
|
|||
ViewData["Title"] = "Profil Bank Sampah";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
#map-display {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:gap-0">
|
||||
<div class="prose">
|
||||
<span class="text-xl font-semibold text-black">
|
||||
|
|
@ -149,12 +160,55 @@
|
|||
<div class="grid grid-cols-1 space-y-2 md:grid-cols-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-gray-500">Latitude</span>
|
||||
<span class="text-sm">324242</span>
|
||||
<span class="text-sm" id="latitude-value">-6.2088</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-gray-500">Longitude</span>
|
||||
<span class="text-sm">-371872</span>
|
||||
<span class="text-sm" id="longitude-value">106.8456</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Map Display -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">
|
||||
<i class="ph ph-map-pin me-2"></i>
|
||||
Lokasi di Peta
|
||||
</h3>
|
||||
<div id="map-display" class="rounded-lg border border-gray-300"></div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<i class="ph ph-info me-1"></i>
|
||||
Peta menampilkan lokasi Bank Sampah
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
// Get coordinates from data
|
||||
const lat = parseFloat(document.getElementById('latitude-value').textContent);
|
||||
const lng = parseFloat(document.getElementById('longitude-value').textContent);
|
||||
|
||||
// Initialize map
|
||||
const mapDisplay = L.map('map-display').setView([lat, lng], 15);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 18,
|
||||
minZoom: 4
|
||||
}).addTo(mapDisplay);
|
||||
|
||||
// Add marker (non-draggable for view mode)
|
||||
const marker = L.marker([lat, lng]).addTo(mapDisplay);
|
||||
marker.bindPopup(`
|
||||
<div style="text-align: center;">
|
||||
<strong>Lokasi Bank Sampah</strong><br>
|
||||
<small>${lat.toFixed(8)}, ${lng.toFixed(8)}</small>
|
||||
</div>
|
||||
`).openPopup();
|
||||
</script>
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
<div class="leaflet-map-picker-container">
|
||||
@if (showLabel)
|
||||
{
|
||||
<label class="text-sm font-semibold text-gray-700 mb-2 block">
|
||||
<i class="ph ph-map-pin me-2"></i>
|
||||
@label
|
||||
</label>
|
||||
}
|
||||
|
||||
@if (showSearch)
|
||||
{
|
||||
<!-- Search Box -->
|
||||
<div class="leaflet-map-picker-search">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="@searchInputId"
|
||||
class="input leaflet-map-picker-search-input"
|
||||
placeholder="Cari lokasi (nama tempat, alamat, kota)..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<i class="ph ph-magnifying-glass leaflet-map-picker-search-icon"></i>
|
||||
<button
|
||||
type="button"
|
||||
id="@clearSearchBtnId"
|
||||
class="leaflet-map-picker-clear-btn hidden"
|
||||
>
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search Results Dropdown -->
|
||||
<div id="@searchResultsId" class="leaflet-map-picker-results hidden"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div id="@mapId" class="leaflet-map-picker"></div>
|
||||
|
||||
@if (showHelpText)
|
||||
{
|
||||
<p class="leaflet-map-picker-help-text">
|
||||
<i class="ph ph-info me-1"></i>
|
||||
@helpText
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="~/css/leaflet-map-picker.css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="~/js/leaflet-map-picker.js"></script>
|
||||
}
|
||||
```
|
||||
|
||||
## Penggunaan
|
||||
|
||||
### Metode 1: Menggunakan Partial View (Recommended)
|
||||
|
||||
#### Langkah 1: Tambahkan Input Fields untuk Latitude dan Longitude
|
||||
|
||||
```cshtml
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Latitude</legend>
|
||||
<input type="number" step="any" class="input" id="latitude-input" name="latitude" placeholder="Latitude" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Longitude</legend>
|
||||
<input type="number" step="any" class="input" id="longitude-input" name="longitude" placeholder="Longitude" />
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
#### Langkah 2: Include Partial View
|
||||
|
||||
```cshtml
|
||||
<div class="mt-6">
|
||||
@{
|
||||
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")
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Langkah 3: Initialize JavaScript Component
|
||||
|
||||
```cshtml
|
||||
@section Scripts {
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="~/js/leaflet-map-picker.js"></script>
|
||||
<script>
|
||||
window.leafletMapPickerInstance = 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"]', // Optional
|
||||
initialLat: -6.2088,
|
||||
initialLng: 106.8456,
|
||||
initialZoom: 12
|
||||
});
|
||||
</script>
|
||||
}
|
||||
```
|
||||
|
||||
### Metode 2: HTML Manual
|
||||
|
||||
```html
|
||||
<div class="leaflet-map-picker-container">
|
||||
<label class="text-sm font-semibold text-gray-700 mb-2 block">
|
||||
<i class="ph ph-map-pin me-2"></i>
|
||||
Pilih Lokasi di Peta
|
||||
</label>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="leaflet-map-picker-search">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="search-location"
|
||||
class="input leaflet-map-picker-search-input"
|
||||
placeholder="Cari lokasi (nama tempat, alamat, kota)..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<i class="ph ph-magnifying-glass leaflet-map-picker-search-icon"></i>
|
||||
<button
|
||||
type="button"
|
||||
id="clear-search"
|
||||
class="leaflet-map-picker-clear-btn hidden"
|
||||
>
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="search-results" class="leaflet-map-picker-results hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="map-picker" class="leaflet-map-picker"></div>
|
||||
<p class="leaflet-map-picker-help-text">
|
||||
<i class="ph ph-info me-1"></i>
|
||||
Cari lokasi menggunakan search box di atas, atau klik pada peta untuk menentukan lokasi secara manual
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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 {
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="~/css/leaflet-map-picker.css" />
|
||||
}
|
||||
|
||||
<form method="post">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Latitude</legend>
|
||||
<input type="number" step="any" class="input" id="latitude-input" name="latitude" value="-6.2088" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Longitude</legend>
|
||||
<input type="number" step="any" class="input" id="longitude-input" name="longitude" value="106.8456" />
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Alamat</legend>
|
||||
<textarea class="textarea" name="alamat_lengkap" placeholder="Alamat lengkap"></textarea>
|
||||
</fieldset>
|
||||
|
||||
@{
|
||||
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")
|
||||
|
||||
<button type="submit" class="btn btn-primary">Simpan</button>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="~/js/leaflet-map-picker.js"></script>
|
||||
<script>
|
||||
window.leafletMapPickerInstance = 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
|
||||
});
|
||||
</script>
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
|
@ -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 = `
|
||||
<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;
|
||||
}
|
||||
Loading…
Reference in New Issue