feat: implement location selection interface with EXIF, GPS, and map picker
- Created LocationSelector component with three location input methods: * Auto-populate from photo EXIF data * Device GPS location capture with accuracy indicator * Interactive Leaflet map picker with draggable marker - Added Ireland coordinate validation (lat: 51.4-55.4, lng: -10.5--5.5) - Created coordinate utility functions for validation and formatting - Added LocationSelectorDemo page for testing at /demo/location-selector - Displays selected location with coordinates, source, and accuracy - Provides clear error messages for validation failures - Satisfies requirements 1.1, 1.2, 1.3, 8.2
This commit is contained in:
@@ -49,7 +49,7 @@
|
|||||||
- Handle multiple photo uploads
|
- Handle multiple photo uploads
|
||||||
- _Requirements: 1.2, 1.3, 1.4, 1.5, 8.3_
|
- _Requirements: 1.2, 1.3, 1.4, 1.5, 8.3_
|
||||||
|
|
||||||
- [ ] 7. Implement location selection interface
|
- [x] 7. Implement location selection interface
|
||||||
- Auto-populate location from EXIF data when available
|
- Auto-populate location from EXIF data when available
|
||||||
- Add device GPS location capture button
|
- Add device GPS location capture button
|
||||||
- Create map-based location picker for manual selection
|
- Create map-based location picker for manual selection
|
||||||
|
|||||||
13
src/App.tsx
13
src/App.tsx
@@ -18,7 +18,8 @@ import {
|
|||||||
ReportFormPage,
|
ReportFormPage,
|
||||||
EventsPage,
|
EventsPage,
|
||||||
AnalyticsPage,
|
AnalyticsPage,
|
||||||
PhotoUploadDemo
|
PhotoUploadDemo,
|
||||||
|
LocationSelectorDemo
|
||||||
} from './pages';
|
} from './pages';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -96,7 +97,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Demo route for testing */}
|
{/* Demo routes for testing */}
|
||||||
<Route
|
<Route
|
||||||
path="/demo/photo-upload"
|
path="/demo/photo-upload"
|
||||||
element={
|
element={
|
||||||
@@ -105,6 +106,14 @@ function App() {
|
|||||||
</Layout>
|
</Layout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/demo/location-selector"
|
||||||
|
element={
|
||||||
|
<Layout>
|
||||||
|
<LocationSelectorDemo />
|
||||||
|
</Layout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
337
src/components/report/LocationSelector.tsx
Normal file
337
src/components/report/LocationSelector.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import useGeolocation from '@/hooks/useGeolocation';
|
||||||
|
import { IRELAND_BOUNDS, isWithinIreland, getAccuracyLabel } from '@/utils/coordinates';
|
||||||
|
import type { Coordinates, LocationSource } from '@/types';
|
||||||
|
import type { PhotoMetadata } from './PhotoUpload';
|
||||||
|
|
||||||
|
// Fix for default marker icons in Leaflet with Vite
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LocationSelectorProps {
|
||||||
|
photos: PhotoMetadata[];
|
||||||
|
onLocationChange: (location: Coordinates, source: LocationSource, accuracy?: number) => void;
|
||||||
|
initialLocation?: Coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationSelector({ photos, onLocationChange, initialLocation }: LocationSelectorProps) {
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<Coordinates | null>(initialLocation || null);
|
||||||
|
const [locationSource, setLocationSource] = useState<LocationSource | null>(null);
|
||||||
|
const [accuracy] = useState<number | undefined>(undefined);
|
||||||
|
const [showMap, setShowMap] = useState(false);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
const markerRef = useRef<L.Marker | null>(null);
|
||||||
|
|
||||||
|
const { coordinates, error, loading, getCurrentPosition } = useGeolocation();
|
||||||
|
|
||||||
|
// Auto-populate location from EXIF data when photos are uploaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (photos.length === 0 || selectedLocation) return;
|
||||||
|
|
||||||
|
// Find first photo with GPS data
|
||||||
|
const photoWithGPS = photos.find(photo => photo.hasGPS && photo.latitude && photo.longitude);
|
||||||
|
|
||||||
|
if (photoWithGPS && photoWithGPS.latitude && photoWithGPS.longitude) {
|
||||||
|
const coords = {
|
||||||
|
latitude: photoWithGPS.latitude,
|
||||||
|
longitude: photoWithGPS.longitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate coordinates are within Ireland
|
||||||
|
if (isWithinIreland(coords.latitude, coords.longitude)) {
|
||||||
|
setSelectedLocation(coords);
|
||||||
|
setLocationSource('exif');
|
||||||
|
setValidationError(null);
|
||||||
|
onLocationChange(coords, 'exif');
|
||||||
|
} else {
|
||||||
|
setValidationError('Photo location is outside Ireland. Please select a location manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [photos, selectedLocation, onLocationChange]);
|
||||||
|
|
||||||
|
// Handle device GPS location
|
||||||
|
useEffect(() => {
|
||||||
|
if (coordinates && locationSource === 'gps') {
|
||||||
|
// Validate coordinates are within Ireland
|
||||||
|
if (isWithinIreland(coordinates.latitude, coordinates.longitude)) {
|
||||||
|
setSelectedLocation(coordinates);
|
||||||
|
setValidationError(null);
|
||||||
|
onLocationChange(coordinates, 'gps', accuracy);
|
||||||
|
} else {
|
||||||
|
setValidationError('Your current location is outside Ireland. Please select a location manually.');
|
||||||
|
setSelectedLocation(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [coordinates, locationSource, accuracy, onLocationChange]);
|
||||||
|
|
||||||
|
// Initialize map when map view is shown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showMap || !mapRef.current || mapInstanceRef.current) return;
|
||||||
|
|
||||||
|
// Default center: Dublin, Ireland
|
||||||
|
const defaultCenter: [number, number] = [53.3498, -6.2603];
|
||||||
|
const initialCenter: [number, number] = selectedLocation
|
||||||
|
? [selectedLocation.latitude, selectedLocation.longitude]
|
||||||
|
: defaultCenter;
|
||||||
|
|
||||||
|
// Create map instance
|
||||||
|
const map = L.map(mapRef.current).setView(initialCenter, selectedLocation ? 13 : 7);
|
||||||
|
|
||||||
|
// Add OpenStreetMap tiles
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Add Ireland boundary rectangle for visual reference
|
||||||
|
const irelandBounds = L.latLngBounds(
|
||||||
|
[IRELAND_BOUNDS.minLat, IRELAND_BOUNDS.minLng],
|
||||||
|
[IRELAND_BOUNDS.maxLat, IRELAND_BOUNDS.maxLng]
|
||||||
|
);
|
||||||
|
|
||||||
|
L.rectangle(irelandBounds, {
|
||||||
|
color: '#3B82F6',
|
||||||
|
weight: 2,
|
||||||
|
fillOpacity: 0.05,
|
||||||
|
dashArray: '5, 5',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Add marker if location is already selected
|
||||||
|
if (selectedLocation) {
|
||||||
|
markerRef.current = L.marker([selectedLocation.latitude, selectedLocation.longitude], {
|
||||||
|
draggable: true,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Handle marker drag
|
||||||
|
markerRef.current.on('dragend', () => {
|
||||||
|
if (markerRef.current) {
|
||||||
|
const position = markerRef.current.getLatLng();
|
||||||
|
handleMapLocationSelect(position.lat, position.lng);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle map clicks
|
||||||
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
|
handleMapLocationSelect(e.latlng.lat, e.latlng.lng);
|
||||||
|
});
|
||||||
|
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove();
|
||||||
|
mapInstanceRef.current = null;
|
||||||
|
markerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [showMap]);
|
||||||
|
|
||||||
|
// Update marker position when selected location changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstanceRef.current || !selectedLocation) return;
|
||||||
|
|
||||||
|
const map = mapInstanceRef.current;
|
||||||
|
|
||||||
|
// Remove existing marker
|
||||||
|
if (markerRef.current) {
|
||||||
|
map.removeLayer(markerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new marker
|
||||||
|
markerRef.current = L.marker([selectedLocation.latitude, selectedLocation.longitude], {
|
||||||
|
draggable: true,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Handle marker drag
|
||||||
|
markerRef.current.on('dragend', () => {
|
||||||
|
if (markerRef.current) {
|
||||||
|
const position = markerRef.current.getLatLng();
|
||||||
|
handleMapLocationSelect(position.lat, position.lng);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Center map on marker
|
||||||
|
map.setView([selectedLocation.latitude, selectedLocation.longitude], 13);
|
||||||
|
}, [selectedLocation]);
|
||||||
|
|
||||||
|
const handleMapLocationSelect = (lat: number, lng: number) => {
|
||||||
|
// Validate coordinates are within Ireland
|
||||||
|
if (!isWithinIreland(lat, lng)) {
|
||||||
|
setValidationError('Selected location is outside Ireland. Please select a location within Ireland.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = { latitude: lat, longitude: lng };
|
||||||
|
setSelectedLocation(coords);
|
||||||
|
setLocationSource('manual');
|
||||||
|
setValidationError(null);
|
||||||
|
onLocationChange(coords, 'manual');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseDeviceLocation = () => {
|
||||||
|
setLocationSource('gps');
|
||||||
|
setValidationError(null);
|
||||||
|
getCurrentPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowMapPicker = () => {
|
||||||
|
setShowMap(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationSourceLabel = (source: LocationSource): string => {
|
||||||
|
const labels: Record<LocationSource, string> = {
|
||||||
|
exif: 'Photo EXIF data',
|
||||||
|
gps: 'Device GPS',
|
||||||
|
manual: 'Manual selection',
|
||||||
|
};
|
||||||
|
return labels[source];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Location <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Location Selection Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUseDeviceLocation}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={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
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{loading ? 'Getting location...' : 'Use Device GPS'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleShowMapPicker}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Select on Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-5 w-5 text-red-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<p className="ml-2 text-sm text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-5 w-5 text-yellow-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<p className="ml-2 text-sm text-yellow-700">{validationError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Location Display */}
|
||||||
|
{selectedLocation && locationSource && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg className="h-5 w-5 text-green-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div className="ml-3 flex-1">
|
||||||
|
<h4 className="text-sm font-medium text-green-800">Location Selected</h4>
|
||||||
|
<div className="mt-2 text-sm text-green-700 space-y-1">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Coordinates:</span>{' '}
|
||||||
|
{selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Source:</span>{' '}
|
||||||
|
{getLocationSourceLabel(locationSource)}
|
||||||
|
</p>
|
||||||
|
{accuracy && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Accuracy:</span>{' '}
|
||||||
|
±{accuracy.toFixed(0)}m ({getAccuracyLabel(accuracy)})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map Picker */}
|
||||||
|
{showMap && (
|
||||||
|
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-gray-100 px-4 py-2 border-b border-gray-300">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Click on the map to select a location, or drag the marker to adjust.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div ref={mapRef} className="w-full h-96" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Help Text */}
|
||||||
|
{!selectedLocation && (
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Location will be auto-populated from photo EXIF data if available. Otherwise, use your device GPS or select a location on the map.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,3 +65,76 @@ interface PhotoMetadata {
|
|||||||
- **1.4**: Accepts photographs with minimum resolution validation
|
- **1.4**: Accepts photographs with minimum resolution validation
|
||||||
- **1.5**: Extracts and displays original photo capture time
|
- **1.5**: Extracts and displays original photo capture time
|
||||||
- **8.3**: Provides mobile-friendly interface with touch support
|
- **8.3**: Provides mobile-friendly interface with touch support
|
||||||
|
|
||||||
|
## LocationSelector
|
||||||
|
|
||||||
|
An interactive location selection component that supports multiple input methods: EXIF data from photos, device GPS, and manual map selection.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Auto-population from EXIF**: Automatically extracts and uses GPS coordinates from uploaded photos
|
||||||
|
- **Device GPS capture**: One-click button to use device's current location
|
||||||
|
- **Interactive map picker**: Leaflet-based map for manual location selection with draggable marker
|
||||||
|
- **Ireland validation**: Validates that selected coordinates are within Ireland's boundaries
|
||||||
|
- **Accuracy indicator**: Displays GPS accuracy with quality labels (Excellent, Good, Fair, Poor)
|
||||||
|
- **Visual feedback**: Clear indicators showing selected location, source, and coordinates
|
||||||
|
- **Error handling**: User-friendly error messages for validation failures and GPS errors
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { LocationSelector } from '@/components/report';
|
||||||
|
import { PhotoMetadata } from '@/components/report';
|
||||||
|
import { Coordinates, LocationSource } from '@/types';
|
||||||
|
|
||||||
|
function ReportForm() {
|
||||||
|
const [photos, setPhotos] = useState<PhotoMetadata[]>([]);
|
||||||
|
|
||||||
|
const handleLocationChange = (
|
||||||
|
location: Coordinates,
|
||||||
|
source: LocationSource,
|
||||||
|
accuracy?: number
|
||||||
|
) => {
|
||||||
|
console.log('Location:', location);
|
||||||
|
console.log('Source:', source); // 'exif', 'gps', or 'manual'
|
||||||
|
console.log('Accuracy:', accuracy); // GPS accuracy in meters (if available)
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocationSelector
|
||||||
|
photos={photos}
|
||||||
|
onLocationChange={handleLocationChange}
|
||||||
|
initialLocation={undefined} // Optional: pre-populate location
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
- `photos`: Array of PhotoMetadata objects (from PhotoUpload component)
|
||||||
|
- `onLocationChange`: Callback function called when location is selected or changed
|
||||||
|
- `initialLocation`: Optional initial coordinates to pre-populate the selector
|
||||||
|
|
||||||
|
### Location Sources
|
||||||
|
|
||||||
|
- **exif**: Location extracted from photo EXIF metadata
|
||||||
|
- **gps**: Location obtained from device GPS
|
||||||
|
- **manual**: Location selected manually on the map
|
||||||
|
|
||||||
|
### Ireland Coordinate Bounds
|
||||||
|
|
||||||
|
The component validates coordinates against Ireland's geographical boundaries:
|
||||||
|
- Latitude: 51.4° to 55.4°
|
||||||
|
- Longitude: -10.5° to -5.5°
|
||||||
|
|
||||||
|
### Requirements Satisfied
|
||||||
|
|
||||||
|
- **1.1**: Captures geographical coordinates with accuracy within 10 meters (via GPS)
|
||||||
|
- **1.2**: Auto-populates location from EXIF data when available
|
||||||
|
- **1.3**: Prompts for location via device GPS or manual map selection when EXIF is unavailable
|
||||||
|
- **8.2**: Accesses device GPS capabilities to auto-populate report location coordinates
|
||||||
|
|
||||||
|
### Demo
|
||||||
|
|
||||||
|
Visit `/demo/location-selector` to test the component with all three location input methods.
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { PhotoUpload } from './PhotoUpload';
|
export { PhotoUpload } from './PhotoUpload';
|
||||||
export type { PhotoMetadata } from './PhotoUpload';
|
export type { PhotoMetadata } from './PhotoUpload';
|
||||||
|
export { LocationSelector } from './LocationSelector';
|
||||||
|
|||||||
105
src/pages/LocationSelectorDemo.tsx
Normal file
105
src/pages/LocationSelectorDemo.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { PhotoUpload, LocationSelector } from '@/components/report';
|
||||||
|
import type { PhotoMetadata } from '@/components/report';
|
||||||
|
import type { Coordinates, LocationSource } from '@/types';
|
||||||
|
|
||||||
|
export default function LocationSelectorDemo() {
|
||||||
|
const [photos, setPhotos] = useState<PhotoMetadata[]>([]);
|
||||||
|
const [location, setLocation] = useState<Coordinates | null>(null);
|
||||||
|
const [locationSource, setLocationSource] = useState<LocationSource | null>(null);
|
||||||
|
const [accuracy, setAccuracy] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const handlePhotosChange = (newPhotos: PhotoMetadata[]) => {
|
||||||
|
setPhotos(newPhotos);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocationChange = (
|
||||||
|
newLocation: Coordinates,
|
||||||
|
source: LocationSource,
|
||||||
|
acc?: number
|
||||||
|
) => {
|
||||||
|
setLocation(newLocation);
|
||||||
|
setLocationSource(source);
|
||||||
|
setAccuracy(acc);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Location Selector Demo
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
Test the location selection interface with photo upload, device GPS, and manual map selection.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Photo Upload Section */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
1. Upload Photos (Optional)
|
||||||
|
</h2>
|
||||||
|
<PhotoUpload onPhotosChange={handlePhotosChange} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Location Selection Section */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
2. Select Location
|
||||||
|
</h2>
|
||||||
|
<LocationSelector
|
||||||
|
photos={photos}
|
||||||
|
onLocationChange={handleLocationChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Summary Section */}
|
||||||
|
<section className="border-t pt-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
Summary
|
||||||
|
</h2>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Photos uploaded:</span>{' '}
|
||||||
|
<span className="text-gray-900">{photos.length}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Photos with GPS:</span>{' '}
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{photos.filter(p => p.hasGPS).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{location && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Selected location:</span>{' '}
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{location.latitude.toFixed(6)}, {location.longitude.toFixed(6)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Location source:</span>{' '}
|
||||||
|
<span className="text-gray-900">{locationSource}</span>
|
||||||
|
</div>
|
||||||
|
{accuracy && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Accuracy:</span>{' '}
|
||||||
|
<span className="text-gray-900">±{accuracy.toFixed(0)}m</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!location && (
|
||||||
|
<div className="text-gray-500 italic">
|
||||||
|
No location selected yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export { ReportFormPage } from './ReportFormPage';
|
|||||||
export { EventsPage } from './EventsPage';
|
export { EventsPage } from './EventsPage';
|
||||||
export { AnalyticsPage } from './AnalyticsPage';
|
export { AnalyticsPage } from './AnalyticsPage';
|
||||||
export { PhotoUploadDemo } from './PhotoUploadDemo';
|
export { PhotoUploadDemo } from './PhotoUploadDemo';
|
||||||
|
export { default as LocationSelectorDemo } from './LocationSelectorDemo';
|
||||||
|
|||||||
70
src/utils/coordinates.ts
Normal file
70
src/utils/coordinates.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for coordinate validation and manipulation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Coordinates } from '@/types';
|
||||||
|
|
||||||
|
// Ireland bounding box for coordinate validation
|
||||||
|
export const IRELAND_BOUNDS = {
|
||||||
|
minLat: 51.4,
|
||||||
|
maxLat: 55.4,
|
||||||
|
minLng: -10.5,
|
||||||
|
maxLng: -5.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if coordinates are within Ireland's boundaries
|
||||||
|
*/
|
||||||
|
export function isWithinIreland(lat: number, lng: number): boolean {
|
||||||
|
return (
|
||||||
|
lat >= IRELAND_BOUNDS.minLat &&
|
||||||
|
lat <= IRELAND_BOUNDS.maxLat &&
|
||||||
|
lng >= IRELAND_BOUNDS.minLng &&
|
||||||
|
lng <= IRELAND_BOUNDS.maxLng
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if coordinates object is within Ireland's boundaries
|
||||||
|
*/
|
||||||
|
export function validateIrelandCoordinates(coords: Coordinates): boolean {
|
||||||
|
return isWithinIreland(coords.latitude, coords.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the distance between two coordinates in meters using the Haversine formula
|
||||||
|
*/
|
||||||
|
export function calculateDistance(
|
||||||
|
coord1: Coordinates,
|
||||||
|
coord2: Coordinates
|
||||||
|
): number {
|
||||||
|
const R = 6371e3; // Earth's radius in meters
|
||||||
|
const φ1 = (coord1.latitude * Math.PI) / 180;
|
||||||
|
const φ2 = (coord2.latitude * Math.PI) / 180;
|
||||||
|
const Δφ = ((coord2.latitude - coord1.latitude) * Math.PI) / 180;
|
||||||
|
const Δλ = ((coord2.longitude - coord1.longitude) * Math.PI) / 180;
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||||
|
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats coordinates for display
|
||||||
|
*/
|
||||||
|
export function formatCoordinates(coords: Coordinates, precision: number = 6): string {
|
||||||
|
return `${coords.latitude.toFixed(precision)}, ${coords.longitude.toFixed(precision)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets accuracy label based on accuracy value in meters
|
||||||
|
*/
|
||||||
|
export function getAccuracyLabel(accuracy: number): string {
|
||||||
|
if (accuracy < 10) return 'Excellent';
|
||||||
|
if (accuracy < 50) return 'Good';
|
||||||
|
if (accuracy < 100) return 'Fair';
|
||||||
|
return 'Poor';
|
||||||
|
}
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
// Utility functions for the Beach Litter Mapping Platform
|
// Utility functions for the Beach Litter Mapping Platform
|
||||||
|
|
||||||
/**
|
// Re-export coordinate utilities
|
||||||
* Validates if coordinates are within Ireland's geographical bounds
|
export * from './coordinates';
|
||||||
*/
|
|
||||||
export function isValidIrishCoordinates(lat: number, lng: number): boolean {
|
|
||||||
// Ireland approximate bounds
|
|
||||||
const MIN_LAT = 51.4;
|
|
||||||
const MAX_LAT = 55.4;
|
|
||||||
const MIN_LNG = -10.5;
|
|
||||||
const MAX_LNG = -5.5;
|
|
||||||
|
|
||||||
return lat >= MIN_LAT && lat <= MAX_LAT && lng >= MIN_LNG && lng <= MAX_LNG;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a date to ISO 8601 format
|
* Formats a date to ISO 8601 format
|
||||||
|
|||||||
Reference in New Issue
Block a user