/**
* 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