Implement LocationDisplayComponent and LocationMapComponent for interactive map features; add event handling and state management

This commit is contained in:
pacnpal
2025-02-23 21:17:27 -05:00
parent af4271b0a4
commit 27e584f427
12 changed files with 2255 additions and 184 deletions

View File

@@ -0,0 +1,190 @@
@push('styles')
<style>
/* Ensure map container and its elements stay below other UI elements */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
z-index: 1 !important;
}
.leaflet-control {
z-index: 2 !important;
}
/* Custom marker cluster styling */
.marker-cluster {
background-color: rgba(255, 255, 255, 0.8);
border-radius: 50%;
text-align: center;
color: #333;
border: 2px solid rgba(0, 120, 255, 0.5);
font-weight: 600;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
padding-top: 5px;
background-color: rgba(0, 120, 255, 0.6);
border-radius: 50%;
}
/* Custom info window styling */
.location-info-window {
padding: 10px;
background: white;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
max-width: 300px;
}
.location-info-window h3 {
margin-top: 0;
margin-bottom: 8px;
font-weight: 600;
}
</style>
@endpush
<div class="location-display-component">
<div wire:ignore class="relative mb-4" style="z-index: 1;">
<div id="locationMap" class="h-[400px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
@if($showInfoWindow && $activeMarker)
<div class="location-info-window absolute top-4 right-4 z-10">
<div class="flex justify-between items-start">
<h3 class="text-lg font-semibold">{{ $activeMarker['name'] ?? 'Location' }}</h3>
<button wire:click="closeInfoWindow" class="text-gray-500 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="mt-2">
@if(isset($activeMarker['address']))
<p class="text-sm text-gray-600">
{{ $activeMarker['address']['street'] ?? '' }}
{{ $activeMarker['address']['city'] ?? '' }}
{{ $activeMarker['address']['state'] ?? '' }}
{{ $activeMarker['address']['country'] ?? '' }}
</p>
@endif
@if(isset($activeMarker['description']))
<p class="mt-2 text-sm">{{ $activeMarker['description'] }}</p>
@endif
</div>
</div>
@endif
</div>
</div>
@push('scripts')
<script>
document.addEventListener('livewire:initialized', function () {
const map = L.map('locationMap');
let markerClusterGroup;
let markers = {};
// Initialize map with OSM tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Initialize marker cluster group
markerClusterGroup = L.markerClusterGroup({
maxClusterRadius: @entangle('clusterRadius'),
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
removeOutsideVisibleBounds: true,
iconCreateFunction: function(cluster) {
const count = cluster.getChildCount();
return L.divIcon({
html: `<div><span>${count}</span></div>`,
className: 'marker-cluster',
iconSize: L.point(40, 40)
});
}
});
map.addLayer(markerClusterGroup);
// Listen for marker updates from Livewire
@this.on('updateMarkers', (markerData) => {
// Clear existing markers
markerClusterGroup.clearLayers();
markers = {};
// Add new markers
markerData.forEach(marker => {
const leafletMarker = L.marker([marker.lat, marker.lng], {
icon: getMarkerIcon(marker.category)
});
leafletMarker.on('click', () => {
@this.markerClicked(marker.id);
});
markers[marker.id] = leafletMarker;
markerClusterGroup.addLayer(leafletMarker);
});
// Fit bounds if available
if (@this.bounds) {
map.fitBounds([
[@this.bounds.south, @this.bounds.west],
[@this.bounds.north, @this.bounds.east]
]);
}
});
// Handle cluster clicks
markerClusterGroup.on('clusterclick', (e) => {
const clusterMarkers = e.layer.getAllChildMarkers().map(marker => {
const markerId = Object.keys(markers).find(key => markers[key] === marker);
return { id: markerId, lat: marker.getLatLng().lat, lng: marker.getLatLng().lng };
});
@this.clusterClicked(clusterMarkers);
});
// Handle map bounds changes
map.on('moveend', () => {
const bounds = map.getBounds();
@this.boundsChanged({
north: bounds.getNorth(),
south: bounds.getSouth(),
east: bounds.getEast(),
west: bounds.getWest()
});
});
// Initialize markers
@this.getMarkers().then(markerData => {
@this.emit('updateMarkers', markerData);
});
// Helper function to get marker icon based on category
function getMarkerIcon(category) {
// Customize icons based on category
return L.divIcon({
className: `marker-icon marker-${category || 'default'}`,
html: `<div class="w-8 h-8 rounded-full bg-blue-500 border-2 border-white shadow-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"/>
</svg>
</div>`,
iconSize: [32, 32],
iconAnchor: [16, 32]
});
}
});
</script>
@endpush

View File

@@ -0,0 +1,207 @@
<div
wire:ignore
x-data="mapComponent(@js($mapConfig))"
x-init="initializeMap()"
class="relative w-full h-[400px] rounded-lg overflow-hidden"
>
{{-- Map Container --}}
<div id="map" class="absolute inset-0 w-full h-full"></div>
{{-- Loading Overlay --}}
<div
x-show="loading"
class="absolute inset-0 bg-white/60 flex items-center justify-center"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
{{-- Map Controls --}}
@if($showControls)
<div class="absolute top-2 right-2 flex flex-col gap-2 z-[1000]">
{{-- Zoom Controls --}}
<div class="flex flex-col bg-white rounded-lg shadow-lg">
<button
@click="zoomIn"
class="p-2 hover:bg-gray-100 rounded-t-lg border-b"
title="Zoom in"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6"/>
</svg>
</button>
<button
@click="zoomOut"
class="p-2 hover:bg-gray-100 rounded-b-lg"
title="Zoom out"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4"/>
</svg>
</button>
</div>
{{-- Center Control --}}
<button
@click="centerOnMarkers"
class="p-2 bg-white rounded-lg shadow-lg hover:bg-gray-100"
title="Center map on markers"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
</div>
@endif
{{-- Selected Location Info --}}
@if($selectedLocation)
<div
x-show="selectedLocation"
class="absolute bottom-4 left-4 right-4 bg-white rounded-lg shadow-lg p-4 z-[1000] max-w-sm"
>
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold text-gray-900" x-text="selectedLocation.name || 'Selected Location'"></h3>
<p class="text-sm text-gray-600 mt-1" x-text="selectedLocation.address || formatCoordinates(selectedLocation)"></p>
</div>
<button
@click="clearSelection"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
@endif
</div>
@push('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('mapComponent', (config) => ({
map: null,
markers: [],
markerLayer: null,
loading: true,
selectedLocation: config.selectedLocation,
async initializeMap() {
// Wait for Leaflet to be loaded
if (typeof L === 'undefined') {
await this.loadLeaflet();
}
// Initialize the map
this.map = L.map('map', {
center: [config.center.lat || 0, config.center.lng || 0],
zoom: config.zoom || 13,
zoomControl: false,
dragging: config.interactive,
touchZoom: config.interactive,
doubleClickZoom: config.interactive,
scrollWheelZoom: config.interactive,
boxZoom: config.interactive,
tap: config.interactive
});
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18
}).addTo(this.map);
// Initialize marker layer
this.markerLayer = L.layerGroup().addTo(this.map);
// Add markers
this.updateMarkers(config.markers);
// Set up event listeners
this.map.on('moveend', () => this.handleMapMoved());
this.map.on('zoomend', () => this.handleZoomChanged());
this.loading = false;
},
async loadLeaflet() {
// Load Leaflet CSS
if (!document.querySelector('link[href*="leaflet.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
document.head.appendChild(link);
}
// Load Leaflet JS
if (typeof L === 'undefined') {
const script = document.createElement('script');
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
document.head.appendChild(script);
await new Promise(resolve => script.onload = resolve);
}
},
updateMarkers(markers) {
this.markerLayer.clearLayers();
this.markers = markers.map(marker => {
const leafletMarker = L.marker([marker.latitude, marker.longitude], {
title: marker.name || 'Location'
});
leafletMarker.on('click', () => {
this.$wire.handleMarkerClicked(marker);
});
this.markerLayer.addLayer(leafletMarker);
return leafletMarker;
});
if (markers.length > 0) {
this.centerOnMarkers();
}
},
handleMapMoved() {
const center = this.map.getCenter();
this.$wire.handleMapMoved(center.lat, center.lng);
},
handleZoomChanged() {
this.$wire.handleZoomChanged(this.map.getZoom());
},
zoomIn() {
this.map.zoomIn();
},
zoomOut() {
this.map.zoomOut();
},
centerOnMarkers() {
if (this.markers.length === 0) return;
if (this.markers.length === 1) {
this.map.setView(this.markers[0].getLatLng(), this.map.getZoom());
} else {
const group = L.featureGroup(this.markers);
this.map.fitBounds(group.getBounds().pad(0.1));
}
},
clearSelection() {
this.selectedLocation = null;
this.$wire.handleLocationSelected(null);
},
formatCoordinates(location) {
if (!location?.latitude || !location?.longitude) return '';
return `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
}
}));
});
</script>
@endpush

View File

@@ -0,0 +1,206 @@
<div>
<div class="space-y-4">
<!-- Mode Switcher -->
<div class="flex space-x-2 mb-4">
<button
wire:click="switchMode('search')"
class="px-4 py-2 text-sm rounded-lg {{ $mode === 'search' ? 'bg-blue-600 text-white' : 'bg-gray-100 hover:bg-gray-200' }}"
>
<span class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Search
</span>
</button>
<button
wire:click="switchMode('coordinates')"
class="px-4 py-2 text-sm rounded-lg {{ $mode === 'coordinates' ? 'bg-blue-600 text-white' : 'bg-gray-100 hover:bg-gray-200' }}"
>
<span class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Coordinates
</span>
</button>
<button
wire:click="detectCurrentLocation"
class="px-4 py-2 text-sm rounded-lg {{ $mode === 'current' ? 'bg-blue-600 text-white' : 'bg-gray-100 hover:bg-gray-200' }}"
>
<span class="flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Current Location
</span>
</button>
</div>
<!-- Search Mode -->
@if ($mode === 'search')
<div class="relative">
<div class="relative">
<input
type="text"
wire:model.live="searchQuery"
placeholder="Search for a location..."
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
@if ($isSearching)
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<svg class="w-5 h-5 text-gray-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
@endif
</div>
@if ($showSearchResults && count($searchResults))
<div class="absolute z-10 w-full mt-1 bg-white rounded-lg shadow-lg">
<ul class="py-1">
@foreach ($searchResults as $result)
<li>
<button
wire:click="selectLocation({{ json_encode($result) }})"
class="w-full px-4 py-2 text-left hover:bg-gray-100"
>
{{ $result['display_name'] }}
</button>
</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif
<!-- Coordinate Mode -->
@if ($mode === 'coordinates')
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Latitude</label>
<input
type="number"
step="any"
wire:model="latitude"
class="w-full px-4 py-2 mt-1 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter latitude..."
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Longitude</label>
<input
type="number"
step="any"
wire:model="longitude"
class="w-full px-4 py-2 mt-1 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter longitude..."
>
</div>
</div>
@endif
<!-- Current Location Mode -->
@if ($mode === 'current')
<div class="p-4 text-center">
@if ($isLoadingLocation)
<div class="flex items-center justify-center space-x-2">
<svg class="w-5 h-5 text-blue-600 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Detecting your location...</span>
</div>
@else
<p class="text-gray-600">Click the Current Location button above to detect your location</p>
@endif
</div>
@endif
<!-- Map Component -->
@if ($latitude && $longitude)
<div class="mt-4">
<livewire:location.location-map-component
:latitude="$latitude"
:longitude="$longitude"
:interactive="true"
/>
</div>
@endif
<!-- Error Messages -->
@if ($validationError)
<div class="p-4 mt-4 text-sm text-red-700 bg-red-100 rounded-lg">
{{ $validationError }}
</div>
@endif
<!-- Selected Location Display -->
@if ($isValidLocation)
<div class="p-4 mt-4 bg-green-50 rounded-lg">
<h4 class="font-medium text-green-800">Selected Location</h4>
@if ($formattedAddress)
<p class="mt-1 text-sm text-green-600">{{ $formattedAddress }}</p>
@endif
<p class="mt-1 text-sm text-green-600">
Coordinates: {{ number_format($latitude, 6) }}, {{ number_format($longitude, 6) }}
</p>
</div>
@endif
<!-- Clear Selection Button -->
@if ($isValidLocation)
<div class="flex justify-end mt-4">
<button
wire:click="clearSelection"
class="px-4 py-2 text-sm text-red-600 bg-red-100 rounded-lg hover:bg-red-200"
>
Clear Selection
</button>
</div>
@endif
</div>
<!-- JavaScript for Geolocation -->
<script>
document.addEventListener('livewire:initialized', () => {
@this.on('requestCurrentLocation', () => {
if (!navigator.geolocation) {
@this.dispatch('validationFailed', {
message: 'Geolocation is not supported by your browser'
});
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
@this.setCoordinates(
position.coords.latitude,
position.coords.longitude
);
},
(error) => {
let errorMessage = 'Failed to get your location';
switch (error.code) {
case error.PERMISSION_DENIED:
errorMessage = 'Location permission denied';
break;
case error.POSITION_UNAVAILABLE:
errorMessage = 'Location information unavailable';
break;
case error.TIMEOUT:
errorMessage = 'Location request timed out';
break;
}
@this.dispatch('validationFailed', {
message: errorMessage
});
}
);
});
});
</script>
</div>