'use strict'; const GIS_STORAGE_KEYS = { geojsonUrl: 'gis_geojson_url', wardProperty: 'gis_ward_prop', districtProperty: 'gis_district_prop', zoneProperty: 'gis_zone_prop', }; const DEFAULT_GIS_GEOJSON_URL = '/api/gis/boundaries'; const GIS_FIELD_CANDIDATES = { ward: ['ward', 'ward_num', 'ward_number', 'wardname', 'ward_name', 'name', 'label'], district: ['district', 'district_name', 'council_district', 'maintenance_district'], zone: ['zone', 'maintenance_zone', 'service_zone', 'route_zone'], }; const __gisGeoJsonCache = new Map(); const PHILLY_BOUNDS = { minLat: 39.86, maxLat: 40.14, minLng: -75.28, maxLng: -74.96, }; const PHILLY_TEN_REGIONS = [ { name: 'Northeast', bounds: [[40.04, -75.07], [40.14, -74.96]], match: (lat, lng) => lat > 40.04 && lng > -75.07 }, { name: 'Northwest', bounds: [[40.02, -75.28], [40.14, -75.17]], match: (lat, lng) => lat > 40.02 && lng < -75.17 }, { name: 'North', bounds: [[40.02, -75.17], [40.14, -75.07]], match: (lat, lng) => lat > 40.02 && lng >= -75.17 && lng <= -75.07 }, { name: 'Lower North', bounds: [[39.97, -75.20], [40.02, -75.14]], match: (lat, lng) => lat >= 39.97 && lat <= 40.02 && lng >= -75.20 && lng <= -75.14 }, { name: 'West', bounds: [[39.94, -75.28], [40.04, -75.18]], match: (lat, lng) => lat >= 39.94 && lat <= 40.04 && lng < -75.18 }, { name: 'River Wards', bounds: [[39.95, -75.09], [40.02, -74.96]], match: (lat, lng) => lat >= 39.95 && lat <= 40.02 && lng > -75.09 }, { name: 'Central', bounds: [[39.94, -75.18], [40.02, -75.09]], match: (lat, lng) => lat >= 39.94 && lat <= 40.02 && lng >= -75.18 && lng <= -75.09 }, { name: 'South', bounds: [[39.90, -75.20], [39.95, -75.10]], match: (lat, lng) => lat >= 39.90 && lat <= 39.95 && lng >= -75.20 && lng <= -75.10 }, { name: 'Southwest', bounds: [[39.86, -75.28], [39.94, -75.15]], match: (lat, lng) => lat < 39.94 && lng < -75.15 }, { name: 'East', bounds: [[39.86, -75.10], [39.95, -74.96]], match: (lat, lng) => lat < 39.95 && lng > -75.10 }, ]; function getGISConfig() { const storedUrl = (localStorage.getItem(GIS_STORAGE_KEYS.geojsonUrl) || '').trim(); return { geojsonUrl: storedUrl || DEFAULT_GIS_GEOJSON_URL, wardProperty: (localStorage.getItem(GIS_STORAGE_KEYS.wardProperty) || 'ward').trim(), districtProperty: (localStorage.getItem(GIS_STORAGE_KEYS.districtProperty) || 'district').trim(), zoneProperty: (localStorage.getItem(GIS_STORAGE_KEYS.zoneProperty) || 'zone').trim(), }; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function fetchWithRetry(url, options = {}, retryOptions = {}) { const { retries = 2, baseDelayMs = 500, retryStatuses = [408, 429, 500, 502, 503, 504], label = 'GIS request', } = retryOptions; let lastError = null; for (let attempt = 0; attempt <= retries; attempt += 1) { try { const response = await fetch(url, options); if (!retryStatuses.includes(response.status) || attempt === retries) { return response; } const retryAfter = response.headers.get('retry-after'); const retryDelayMs = retryAfter ? Number(retryAfter) * 1000 || baseDelayMs * (2 ** attempt) : baseDelayMs * (2 ** attempt); console.warn(`[gis.js] ${label} returned ${response.status}; retrying in ${retryDelayMs}ms`); await sleep(retryDelayMs); } catch (error) { lastError = error; if (attempt === retries) throw error; const retryDelayMs = baseDelayMs * (2 ** attempt); console.warn(`[gis.js] ${label} failed (${error.message}); retrying in ${retryDelayMs}ms`); await sleep(retryDelayMs); } } throw lastError || new Error(`${label} failed.`); } function getGISProperty(properties, key, fallbacks = []) { if (!properties || !key) return ''; if (Object.prototype.hasOwnProperty.call(properties, key)) { return properties[key]; } const loweredKey = String(key).toLowerCase(); const match = Object.keys(properties).find((candidate) => candidate.toLowerCase() === loweredKey); if (match) return properties[match]; for (const fallback of fallbacks) { if (!fallback) continue; if (Object.prototype.hasOwnProperty.call(properties, fallback)) { return properties[fallback]; } const loweredFallback = String(fallback).toLowerCase(); const fallbackMatch = Object.keys(properties).find((candidate) => candidate.toLowerCase() === loweredFallback); if (fallbackMatch) return properties[fallbackMatch]; } return ''; } function getAreaLabelFromAddress(address) { if (!address || typeof address !== 'object') return ''; return ( address.suburb || address.neighbourhood || address.city_district || address.quarter || address.hamlet || address.village || address.town || address.city || address.county || '' ); } function pointInRing(point, ring) { const [x, y] = point; let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const [xi, yi] = ring[i]; const [xj, yj] = ring[j]; const intersects = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi); if (intersects) inside = !inside; } return inside; } function pointInPolygon(point, polygonCoords) { if (!Array.isArray(polygonCoords) || !polygonCoords.length) return false; if (!pointInRing(point, polygonCoords[0])) return false; for (let i = 1; i < polygonCoords.length; i += 1) { if (pointInRing(point, polygonCoords[i])) return false; } return true; } function pointInGeometry(point, geometry) { if (!geometry || !geometry.type || !geometry.coordinates) return false; if (geometry.type === 'Polygon') { return pointInPolygon(point, geometry.coordinates); } if (geometry.type === 'MultiPolygon') { return geometry.coordinates.some((polygon) => pointInPolygon(point, polygon)); } return false; } function extendBounds(bounds, lng, lat) { bounds.minLat = Math.min(bounds.minLat, lat); bounds.maxLat = Math.max(bounds.maxLat, lat); bounds.minLng = Math.min(bounds.minLng, lng); bounds.maxLng = Math.max(bounds.maxLng, lng); } function geometryToBounds(geometry) { if (!geometry || !geometry.type || !geometry.coordinates) return null; const bounds = { minLat: Infinity, maxLat: -Infinity, minLng: Infinity, maxLng: -Infinity, }; const visitRing = (ring) => { ring.forEach(([lng, lat]) => extendBounds(bounds, lng, lat)); }; if (geometry.type === 'Polygon') { geometry.coordinates.forEach(visitRing); } else if (geometry.type === 'MultiPolygon') { geometry.coordinates.forEach((polygon) => polygon.forEach(visitRing)); } else { return null; } if (!Number.isFinite(bounds.minLat) || !Number.isFinite(bounds.minLng)) return null; return [[bounds.minLat, bounds.minLng], [bounds.maxLat, bounds.maxLng]]; } function createGISDisplayLabel(mapping) { if (!mapping) return ''; const parts = [mapping.ward, mapping.district, mapping.zone].filter(Boolean); if (parts.length) return parts.join(' / '); return mapping.area || ''; } function normalizeGISMapping(mapping, fallbackArea = '') { const base = (mapping && typeof mapping === 'object') ? mapping : {}; const normalized = { configured: !!base.configured, matched: !!base.matched, ward: base.ward ? String(base.ward) : '', district: base.district ? String(base.district) : '', zone: base.zone ? String(base.zone) : (base.maintenanceZone ? String(base.maintenanceZone) : ''), area: base.area ? String(base.area) : String(fallbackArea || ''), source: base.source || 'none', bounds: Array.isArray(base.bounds) ? base.bounds : null, featureProperties: base.featureProperties || null, message: base.message || '', }; normalized.label = createGISDisplayLabel(normalized); return normalized; } function isInPhiladelphiaBounds(lat, lng) { return lat >= PHILLY_BOUNDS.minLat && lat <= PHILLY_BOUNDS.maxLat && lng >= PHILLY_BOUNDS.minLng && lng <= PHILLY_BOUNDS.maxLng; } function resolvePhiladelphiaTenRegion(lat, lng) { if (!isInPhiladelphiaBounds(lat, lng)) return null; const region = PHILLY_TEN_REGIONS.find((candidate) => candidate.match(lat, lng)); if (!region) return null; return normalizeGISMapping({ configured: true, matched: true, ward: region.name, district: '', zone: '', area: region.name, source: 'philly-ten-region', bounds: region.bounds, message: 'Matched against the built-in Philadelphia 10-region fallback.', }, region.name); } function isPhiladelphiaTenRegionName(name) { return !!PHILLY_TEN_REGIONS.find((region) => region.name === name); } function resolvePreferredGISMapping(lat, lng, rawMapping, fallbackArea = '') { const normalized = normalizeGISMapping(rawMapping, fallbackArea); const phillyRegion = resolvePhiladelphiaTenRegion(lat, lng); if (!phillyRegion) return normalized; const wardLabel = normalized.ward || ''; const districtLabel = normalized.district || ''; const looksLikeLegacyPhillyCode = /^ward\s*\d+/i.test(wardLabel) || /^district[-\s]/i.test(districtLabel) || /^ward[-\s]/i.test(wardLabel); const isAlreadyTenRegion = isPhiladelphiaTenRegionName(wardLabel); if (!normalized.label || looksLikeLegacyPhillyCode || !isAlreadyTenRegion) { return phillyRegion; } return normalized; } async function fetchGISFeatureCollection(config = getGISConfig()) { const { geojsonUrl } = config; if (!geojsonUrl) return null; if (!__gisGeoJsonCache.has(geojsonUrl)) { const requestPromise = fetchWithRetry(geojsonUrl, {}, { label: 'GIS boundary fetch', }).then(async (response) => { if (!response.ok) { throw new Error(`GIS boundary fetch failed (${response.status})`); } const data = await response.json(); if (!data || !Array.isArray(data.features)) { throw new Error('GIS boundary data must be a GeoJSON FeatureCollection.'); } return data; }).catch((error) => { __gisGeoJsonCache.delete(geojsonUrl); throw error; }); __gisGeoJsonCache.set(geojsonUrl, requestPromise); } return __gisGeoJsonCache.get(geojsonUrl); } async function resolveGISMapping(lat, lng, options = {}) { const fallbackArea = options.fallbackArea || ''; const config = options.config || getGISConfig(); if (!config.geojsonUrl) { const phillyRegion = resolvePhiladelphiaTenRegion(lat, lng); if (phillyRegion) return phillyRegion; return normalizeGISMapping({ configured: false, matched: false, area: fallbackArea, source: fallbackArea ? 'reverse-geocode' : 'none', message: 'Official GIS ward boundaries are not configured.', }, fallbackArea); } try { const featureCollection = await fetchGISFeatureCollection(config); const point = [lng, lat]; for (const feature of featureCollection.features) { if (!pointInGeometry(point, feature.geometry)) continue; const properties = feature.properties || {}; const wardValue = getGISProperty(properties, config.wardProperty, GIS_FIELD_CANDIDATES.ward); const districtValue = getGISProperty(properties, config.districtProperty, GIS_FIELD_CANDIDATES.district); const zoneValue = getGISProperty(properties, config.zoneProperty, GIS_FIELD_CANDIDATES.zone); return normalizeGISMapping({ configured: true, matched: true, ward: wardValue, district: districtValue, zone: zoneValue, area: fallbackArea || wardValue || districtValue || zoneValue, source: config.geojsonUrl === DEFAULT_GIS_GEOJSON_URL ? 'official-geojson' : 'geojson', bounds: geometryToBounds(feature.geometry), featureProperties: properties, message: config.geojsonUrl === DEFAULT_GIS_GEOJSON_URL ? 'Matched against the official GIS boundary layer.' : 'Matched against configured GIS ward boundaries.', }, fallbackArea); } const phillyRegion = resolvePhiladelphiaTenRegion(lat, lng); if (phillyRegion) return phillyRegion; return normalizeGISMapping({ configured: true, matched: false, area: fallbackArea, source: fallbackArea ? 'reverse-geocode' : 'none', message: 'Coordinates did not match any feature in the configured GIS boundary layer.', }, fallbackArea); } catch (error) { console.warn('[gis.js] resolveGISMapping failed:', error); return normalizeGISMapping({ configured: true, matched: false, area: fallbackArea, source: fallbackArea ? 'reverse-geocode' : 'none', message: error.message || 'GIS lookup failed.', }, fallbackArea); } } function getGISBadgeTone(mapping) { if (mapping && mapping.matched) { return { background: '#EFF6FF', color: '#1D4ED8', border: '#BFDBFE', dot: '#2563EB', }; } if (mapping && mapping.area) { return { background: '#ECFDF5', color: '#0F766E', border: '#A7F3D0', dot: '#0D9488', }; } return { background: '#F8FAFC', color: '#64748B', border: '#CBD5E1', dot: '#94A3B8', }; } function getGISStatusText(mapping) { if (!mapping) return 'GIS mapping pending'; if (mapping.matched) return `GIS mapped: ${mapping.label}`; if (mapping.area) return `Area detected: ${mapping.area}`; return 'GIS mapping pending'; } if (typeof window !== 'undefined') { window.DEFAULT_GIS_GEOJSON_URL = DEFAULT_GIS_GEOJSON_URL; window.GIS_STORAGE_KEYS = GIS_STORAGE_KEYS; window.getGISConfig = getGISConfig; window.getAreaLabelFromAddress = getAreaLabelFromAddress; window.fetchGISFeatureCollection = fetchGISFeatureCollection; window.resolveGISMapping = resolveGISMapping; window.resolvePhiladelphiaTenRegion = resolvePhiladelphiaTenRegion; window.resolvePreferredGISMapping = resolvePreferredGISMapping; window.PHILLY_TEN_REGIONS = PHILLY_TEN_REGIONS; window.normalizeGISMapping = normalizeGISMapping; window.createGISDisplayLabel = createGISDisplayLabel; window.getGISBadgeTone = getGISBadgeTone; window.getGISStatusText = getGISStatusText; window.geometryToBounds = geometryToBounds; } if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = { DEFAULT_GIS_GEOJSON_URL, GIS_STORAGE_KEYS, getGISConfig, getAreaLabelFromAddress, fetchGISFeatureCollection, resolveGISMapping, resolvePhiladelphiaTenRegion, resolvePreferredGISMapping, PHILLY_TEN_REGIONS, normalizeGISMapping, createGISDisplayLabel, getGISBadgeTone, getGISStatusText, geometryToBounds, }; }