Spaces:
Sleeping
Sleeping
| /** | |
| * location.js | |
| * ----------- | |
| * Handles all GPS and location logic for the Citizen Pothole Portal. | |
| * | |
| * Depends on: | |
| * - exifr (loaded via CDN <script> tag in the host HTML page) | |
| * - Nominatim reverse-geocoding API (free, no API key required) | |
| * | |
| * Philadelphia bounding box: | |
| * Latitude 39.86 – 40.14 | |
| * Longitude -75.28 – -74.96 | |
| */ | |
| ; | |
| const GOOGLE_MAPS_JS_KEY_STORAGE = 'google_maps_js_api_key'; | |
| let __googleMapsApiPromise = null; | |
| function getGoogleMapsJsApiKey() { | |
| try { | |
| return (localStorage.getItem(GOOGLE_MAPS_JS_KEY_STORAGE) || '').trim(); | |
| } catch { | |
| return ''; | |
| } | |
| } | |
| function getAreaLabelFromGoogleComponents(components = []) { | |
| const preferredTypes = [ | |
| 'sublocality_level_1', | |
| 'sublocality', | |
| 'neighborhood', | |
| 'locality', | |
| 'administrative_area_level_2', | |
| 'administrative_area_level_1', | |
| ]; | |
| for (const type of preferredTypes) { | |
| const match = components.find((component) => Array.isArray(component.types) && component.types.includes(type)); | |
| if (match?.long_name) return match.long_name; | |
| } | |
| return ''; | |
| } | |
| function loadGoogleMapsApi() { | |
| const apiKey = getGoogleMapsJsApiKey(); | |
| if (!apiKey) return Promise.resolve(false); | |
| if (window.google?.maps?.Geocoder) return Promise.resolve(true); | |
| if (__googleMapsApiPromise) return __googleMapsApiPromise; | |
| __googleMapsApiPromise = new Promise((resolve, reject) => { | |
| const existing = document.querySelector('script[data-google-maps-loader="true"]'); | |
| if (existing) { | |
| existing.addEventListener('load', () => resolve(!!window.google?.maps?.Geocoder), { once: true }); | |
| existing.addEventListener('error', () => reject(new Error('Google Maps API failed to load.')), { once: true }); | |
| return; | |
| } | |
| const script = document.createElement('script'); | |
| script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&loading=async`; | |
| script.async = true; | |
| script.defer = true; | |
| script.dataset.googleMapsLoader = 'true'; | |
| script.onload = () => resolve(!!window.google?.maps?.Geocoder); | |
| script.onerror = () => reject(new Error('Google Maps API failed to load.')); | |
| document.head.appendChild(script); | |
| }).catch((error) => { | |
| __googleMapsApiPromise = null; | |
| throw error; | |
| }); | |
| return __googleMapsApiPromise; | |
| } | |
| async function reverseGeocodeViaGoogle(lat, lng) { | |
| const loaded = await loadGoogleMapsApi(); | |
| if (!loaded || !window.google?.maps?.Geocoder) { | |
| throw new Error('Google Maps Geocoder is unavailable.'); | |
| } | |
| const geocoder = new window.google.maps.Geocoder(); | |
| const response = await geocoder.geocode({ | |
| location: { lat, lng }, | |
| language: 'en', | |
| }); | |
| const result = response?.results?.[0]; | |
| if (!result) { | |
| throw new Error('Google Maps returned no reverse geocoding result.'); | |
| } | |
| const components = result.address_components || []; | |
| return { | |
| address: result.formatted_address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`, | |
| city: components.find((item) => item.types?.includes('locality'))?.long_name || '', | |
| state: components.find((item) => item.types?.includes('administrative_area_level_1'))?.short_name || '', | |
| zip: components.find((item) => item.types?.includes('postal_code'))?.long_name || '', | |
| areaLabel: getAreaLabelFromGoogleComponents(components), | |
| provider: 'google-maps', | |
| }; | |
| } | |
| /* ========================================================================= | |
| 1. EXIF GPS EXTRACTION | |
| ========================================================================= */ | |
| /** | |
| * extractExifGPS | |
| * -------------- | |
| * Reads GPS metadata embedded in a photo file using the exifr library. | |
| * exifr must be loaded globally before this function is called | |
| * (e.g. via <script src="https://unpkg.com/exifr/dist/full.esm.js"></script>). | |
| * | |
| * @param {File} file - A File object (from <input type="file"> 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 <script> context | |
| if (typeof window !== 'undefined') { | |
| window.extractExifGPS = extractExifGPS; | |
| window.getDeviceLocation = getDeviceLocation; | |
| window.reverseGeocode = reverseGeocode; | |
| window.validateLocations = validateLocations; | |
| window.detectWard = detectWard; | |
| // Also expose the internal Haversine helper in case other modules need it | |
| window.haversineDistance = haversineDistance; | |
| } | |
| // ES-module export (tree-shaken when bundled; silently ignored by classic <script>) | |
| if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { | |
| module.exports = { | |
| extractExifGPS, | |
| getDeviceLocation, | |
| reverseGeocode, | |
| validateLocations, | |
| detectWard, | |
| haversineDistance, | |
| }; | |
| } | |