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:
2025-11-16 16:15:42 +00:00
parent ebdfc797ac
commit e18997ed95
9 changed files with 601 additions and 15 deletions

View File

@@ -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

View File

@@ -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>

View 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: '&copy; <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>
);
}

View File

@@ -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.

View File

@@ -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';

View 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>
);
}

View File

@@ -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
View 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';
}

View File

@@ -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