PotholeIQ / location.js
Varun10000's picture
PotholeIQ: initial deployable build (Box + Supabase, HF Spaces Docker)
45e997f
Raw
History Blame Contribute Delete
17.5 kB
/**
* 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
*/
'use strict';
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,
};
}