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:
13
src/App.tsx
13
src/App.tsx
@@ -18,7 +18,8 @@ import {
|
||||
ReportFormPage,
|
||||
EventsPage,
|
||||
AnalyticsPage,
|
||||
PhotoUploadDemo
|
||||
PhotoUploadDemo,
|
||||
LocationSelectorDemo
|
||||
} from './pages';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -96,7 +97,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Demo route for testing */}
|
||||
{/* Demo routes for testing */}
|
||||
<Route
|
||||
path="/demo/photo-upload"
|
||||
element={
|
||||
@@ -105,6 +106,14 @@ function App() {
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/demo/location-selector"
|
||||
element={
|
||||
<Layout>
|
||||
<LocationSelectorDemo />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</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.5**: Extracts and displays original photo capture time
|
||||
- **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 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 { AnalyticsPage } from './AnalyticsPage';
|
||||
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
|
||||
|
||||
/**
|
||||
* Validates if coordinates are within Ireland's geographical bounds
|
||||
*/
|
||||
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;
|
||||
}
|
||||
// Re-export coordinate utilities
|
||||
export * from './coordinates';
|
||||
|
||||
/**
|
||||
* Formats a date to ISO 8601 format
|
||||
|
||||
Reference in New Issue
Block a user