/** * location.js * ----------- * Handles all GPS and location logic for the Citizen Pothole Portal. * * Depends on: * - exifr (loaded via CDN ). * * @param {File} file - A File object (from or drag-and-drop). * @returns {Promise<{lat: number, lng: number, source: 'exif'} | null>} * Resolves with a GPS object if coordinates are found, or null otherwise. */ async function extractExifGPS(file) { try { // Ensure the exifr library is available on the global scope if (typeof exifr === 'undefined') { console.warn('[location.js] exifr is not loaded. Cannot extract EXIF GPS.'); return null; } // Parse only the GPS tags for efficiency const gps = await exifr.gps(file); // exifr returns null / undefined when no GPS data is present if (!gps || gps.latitude == null || gps.longitude == null) { console.info('[location.js] No EXIF GPS data found in image.'); return null; } return { lat: gps.latitude, lng: gps.longitude, source: 'exif', }; } catch (err) { console.error('[location.js] extractExifGPS error:', err); return null; } } /* ========================================================================= 2. DEVICE / BROWSER GEOLOCATION ========================================================================= */ /** * getDeviceLocation * ----------------- * Requests the user's current position via the browser Geolocation API. * The user will be prompted for permission if it has not been granted yet. * * @returns {Promise<{lat: number, lng: number, source: 'device', accuracy: number}>} * Resolves with the device position, or rejects with an Error if * permission is denied or the position cannot be determined. */ function getDeviceLocation() { return new Promise((resolve, reject) => { // Check that the Geolocation API is supported if (!navigator.geolocation) { reject(new Error('Geolocation is not supported by this browser.')); return; } const options = { enableHighAccuracy: true, // Request the most accurate position available timeout: 10000, // Wait up to 10 seconds maximumAge: 30000, // Accept a cached position up to 30 seconds old }; navigator.geolocation.getCurrentPosition( (position) => { resolve({ lat: position.coords.latitude, lng: position.coords.longitude, source: 'device', accuracy: position.coords.accuracy, // metres }); }, (error) => { // Map GeolocationPositionError codes to human-readable messages const messages = { 1: 'Location permission denied by the user.', 2: 'Position unavailable – the device could not determine its location.', 3: 'Geolocation request timed out.', }; reject(new Error(messages[error.code] || 'Unknown geolocation error.')); }, options ); }); } /* ========================================================================= 3. REVERSE GEOCODING (Nominatim / OpenStreetMap) ========================================================================= */ /** * reverseGeocode * -------------- * Converts a latitude / longitude pair into a human-readable address using * the free Nominatim API (https://nominatim.openstreetmap.org). * * No API key is required, but Nominatim's usage policy requires: * • A valid User-Agent header (set below). * • A maximum of 1 request per second. * * @param {number} lat - Latitude. * @param {number} lng - Longitude. * @returns {Promise<{address: string, city: string, state: string, zip: string}>} * Resolves with structured address components. */ async function reverseGeocodeDetailed(lat, lng) { const googleMapsApiKey = getGoogleMapsJsApiKey(); if (googleMapsApiKey) { try { return await reverseGeocodeViaGoogle(lat, lng); } catch (err) { console.warn('[location.js] Google reverse geocode failed, falling back to Nominatim:', err); } } const url = `https://nominatim.openstreetmap.org/reverse` + `?format=jsonv2&lat=${lat}&lon=${lng}&addressdetails=1`; try { const response = await fetch(url, { headers: { // Nominatim requires a descriptive User-Agent 'User-Agent': 'CitizenPotholePortal/1.0 (philadelphia-potholes@example.com)', 'Accept-Language': 'en', }, }); if (!response.ok) { throw new Error(`Nominatim HTTP error: ${response.status}`); } const data = await response.json(); const addr = data.address || {}; // Build a single-line address string from the most relevant OSM fields const streetNumber = addr.house_number || ''; const street = addr.road || addr.pedestrian || addr.footway || ''; const fullStreet = [streetNumber, street].filter(Boolean).join(' '); return { // Human-readable single line: "1234 Market St, Philadelphia, PA 19103" address : data.display_name || fullStreet || 'Address unavailable', city : addr.city || addr.town || addr.village || addr.county || '', state : addr.state || '', zip : addr.postcode || '', areaLabel: addr.suburb || addr.neighbourhood || addr.city_district || addr.quarter || addr.city || addr.county || '', provider: 'nominatim', }; } catch (err) { console.error('[location.js] reverseGeocode error:', err); // Return graceful fallback rather than throwing return { address : 'Geocoding unavailable', city : '', state : '', zip : '', areaLabel: '', provider: 'unavailable', }; } } async function reverseGeocode(lat, lng) { const result = await reverseGeocodeDetailed(lat, lng); return { address: result.address, city: result.city, state: result.state, zip: result.zip, }; } /* ========================================================================= 4. LOCATION CROSS-VALIDATION ========================================================================= */ /** * haversineDistance * ----------------- * Calculates the great-circle distance between two GPS coordinates using the * Haversine formula. * * @param {number} lat1 - Latitude of point 1 (degrees). * @param {number} lng1 - Longitude of point 1 (degrees). * @param {number} lat2 - Latitude of point 2 (degrees). * @param {number} lng2 - Longitude of point 2 (degrees). * @returns {number} Distance in metres. */ function haversineDistance(lat1, lng1, lat2, lng2) { const R = 6371000; // Earth's mean radius in metres // Convert degrees to radians const toRad = (deg) => (deg * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; // metres } /** * validateLocations * ----------------- * Cross-validates two GPS positions (e.g. one from EXIF, one from the device) * and returns a verdict on whether they agree. * * Threshold used: positions within 100 m are considered a match. * * @param {{ lat: number, lng: number } | null} exifPos - GPS from image EXIF (may be null). * @param {{ lat: number, lng: number } | null} devicePos - GPS from device (may be null). * @returns {{ * match: boolean, * distanceMeters: number | null, * status: 'verified' | 'mismatch' | 'single' * }} */ function validateLocations(exifPos, devicePos) { // If only one source is available we cannot cross-validate if (!exifPos || !devicePos) { return { match: false, distanceMeters: null, status: 'single', // Only one data source; treated as unverified but acceptable }; } const distanceMeters = haversineDistance( exifPos.lat, exifPos.lng, devicePos.lat, devicePos.lng ); // Positions within 100 metres are considered consistent / verified const MATCH_THRESHOLD_METERS = 100; const isMatch = distanceMeters <= MATCH_THRESHOLD_METERS; return { match: isMatch, distanceMeters: Math.round(distanceMeters), // whole metres for display status: isMatch ? 'verified' : 'mismatch', }; } /* ========================================================================= 5. PHILADELPHIA WARD / PLANNING-DISTRICT DETECTION ========================================================================= */ /** * PHILLY_DISTRICTS * ---------------- * Hardcoded bounding-box definitions for Philadelphia's 10 planning districts. * Each entry is evaluated in order; the first matching region wins. * * Approximate lat/lng boundaries based on the City Planning Commission's * published district boundaries. * * Philadelphia overall bounding box: lat 39.86–40.14, lng -75.28 to -74.96 */ const PHILLY_DISTRICTS = [ { ward : 'Northeast', wardId : 'WARD-NE', district: 'District-06-Northeast', // North of City Ave corridor, east of Roosevelt Blvd match : (lat, lng) => lat > 40.04 && lng > -75.07, }, { ward : 'Northwest', wardId : 'WARD-NW', district: 'District-09-Northwest', // North of City Ave corridor, west of Broad Street area match : (lat, lng) => lat > 40.02 && lng < -75.17, }, { ward : 'North', wardId : 'WARD-NO', district: 'District-07-North', // Upper North Philly corridor between Roosevelt and Broad match : (lat, lng) => lat > 40.02 && lng >= -75.17 && lng <= -75.07, }, { ward : 'Lower North', wardId : 'WARD-LN', district: 'District-08-Lower-North', // Below North, above Center City, west-central match : (lat, lng) => lat >= 39.97 && lat <= 40.02 && lng >= -75.20 && lng <= -75.14, }, { ward : 'West', wardId : 'WARD-WE', district: 'District-04-West', // West of the Schuylkill (west of roughly -75.18) match : (lat, lng) => lat >= 39.94 && lat <= 40.04 && lng < -75.18, }, { ward : 'River Wards', wardId : 'WARD-RW', district: 'District-01-River-Wards', // Fishtown / Kensington / Port Richmond corridor along the Delaware match : (lat, lng) => lat >= 39.95 && lat <= 40.02 && lng > -75.09, }, { ward : 'Central', wardId : 'WARD-CE', district: 'District-05-Central', // Center City and surrounding neighbourhoods match : (lat, lng) => lat >= 39.94 && lat <= 40.02 && lng >= -75.18 && lng <= -75.09, }, { ward : 'South', wardId : 'WARD-SO', district: 'District-02-South', // South Philadelphia proper match : (lat, lng) => lat >= 39.90 && lat <= 39.95 && lng >= -75.20 && lng <= -75.10, }, { ward : 'Southwest', wardId : 'WARD-SW', district: 'District-03-Southwest', // Southwest Philly / Eastwick match : (lat, lng) => lat < 39.94 && lng < -75.15, }, { ward : 'East', wardId : 'WARD-EA', district: 'District-10-East', // Lower Northeast / far-east corridors along the Delaware below Fishtown match : (lat, lng) => lat < 39.95 && lng > -75.10, }, ]; /** Fallback used when coordinates fall outside every defined district. */ const UNKNOWN_DISTRICT = { ward : 'Unknown', wardId : 'WARD-XX', district: 'District-00-Unknown', }; /** * detectWard * ---------- * Determines which Philadelphia planning district a set of GPS coordinates * falls within using simple bounding-box logic. * * @param {number} lat - Latitude. * @param {number} lng - Longitude. * @returns {{ * ward : string, * wardId : string, * district : string * }} * * @example * detectWard(39.9526, -75.1652); * // => { ward: 'Central', wardId: 'WARD-CE', district: 'District-05-Central' } */ function detectWard(lat, lng) { // Sanity-check: confirm coordinates are within Philadelphia's bounding box if (lat < 39.86 || lat > 40.14 || lng < -75.28 || lng > -74.96) { console.warn( `[location.js] detectWard: coordinates (${lat}, ${lng}) are outside Philadelphia.` ); return { ...UNKNOWN_DISTRICT }; } // Evaluate each district's bounding-box predicate in priority order for (const district of PHILLY_DISTRICTS) { if (district.match(lat, lng)) { return { ward : district.ward, wardId : district.wardId, district: district.district, }; } } // Coordinates are within Philadelphia but didn't match any polygon console.warn( `[location.js] detectWard: no district matched for (${lat}, ${lng}). Returning unknown.` ); return { ...UNKNOWN_DISTRICT }; } /* ========================================================================= 6. MODULE EXPORT (works in both browser globals and ES-module contexts) ========================================================================= */ // Make functions available as plain globals in a classic browser