/** * useBoundariesAPI.ts * * React hook for fetching administrative boundary data from the PostGIS API. * Provides international boundary support (NL, JP, CZ, DE, BE, CH, AT, etc.) * * Replaces static GeoJSON file loading with dynamic API-based fetching. * * API Endpoints used: * - GET /boundaries/countries - List countries with boundary data * - GET /boundaries/countries/{code}/admin1 - List provinces/states * - GET /boundaries/countries/{code}/admin2 - List municipalities * - GET /boundaries/countries/{code}/admin2/geojson - Get GeoJSON FeatureCollection * - GET /boundaries/lookup?lat=&lon= - Point-in-polygon lookup * - GET /boundaries/stats - Statistics */ import { useState, useCallback, useRef, useEffect } from 'react'; // Configuration - can be overridden via environment variables const BOUNDARIES_API_URL = import.meta.env.VITE_POSTGRES_API_URL || '/api/postgres'; // ============================================================================ // Types // ============================================================================ export interface BoundaryCountry { id: number; code: string; name: string; name_local?: string; area_km2?: number; source?: string; centroid?: GeoJSON.Point; } export interface BoundaryAdmin1 { id: number; code: string; name: string; name_local?: string; iso_code?: string; country_code: string; area_km2?: number; source?: string; centroid?: GeoJSON.Point; } export interface BoundaryAdmin2 { id: number; code: string; name: string; name_local?: string; admin1_code: string; admin1_name: string; country_code: string; area_km2?: number; source?: string; centroid?: GeoJSON.Point; } export interface PointLookupResult { country?: { id: number; code: string; name: string; admin_level: 0; country_code: string; source: string; }; admin1?: { id: number; code: string; name: string; admin_level: 1; country_code: string; source: string; }; admin2?: { id: number; code: string; name: string; admin_level: 2; country_code: string; source: string; }; } export interface BoundaryStats { countries: number; admin1_by_country: Record; admin2_by_country: Record; data_sources: string[]; } export interface GeoJSONFeature { type: 'Feature'; properties: { id: number; code: string; name: string; name_local?: string; admin1_code?: string; admin1_name?: string; country_code?: string; area_km2?: number; source?: string; }; geometry: GeoJSON.Geometry; } export interface GeoJSONFeatureCollection { type: 'FeatureCollection'; features: GeoJSONFeature[]; } export interface UseBoundariesAPIReturn { // Data countries: BoundaryCountry[]; admin1Cache: Map; admin2Cache: Map; geojsonCache: Map; stats: BoundaryStats | null; // Status isLoading: boolean; error: Error | null; // Actions fetchCountries: () => Promise; fetchAdmin1: (countryCode: string) => Promise; fetchAdmin2: (countryCode: string, admin1Code?: string) => Promise; fetchAdmin2GeoJSON: (countryCode: string, admin1Code?: string, simplify?: number) => Promise; lookupPoint: (lat: number, lon: number, countryCode?: string) => Promise; fetchStats: () => Promise; // Utilities getAdmin2ByCode: (countryCode: string, admin2Code: string) => BoundaryAdmin2 | undefined; getAdmin2ByName: (countryCode: string, name: string) => BoundaryAdmin2 | undefined; filterFeaturesByCodes: (countryCode: string, admin2Codes: string[]) => GeoJSONFeature[]; // Clear cache clearCache: () => void; } // ============================================================================ // API Fetch Helpers // ============================================================================ async function apiFetch(endpoint: string): Promise { const url = `${BOUNDARIES_API_URL}${endpoint}`; const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json', }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Boundaries API error (${response.status}): ${errorText}`); } return response.json(); } // ============================================================================ // Hook Implementation // ============================================================================ export function useBoundariesAPI(): UseBoundariesAPIReturn { const [countries, setCountries] = useState([]); const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Caches const admin1CacheRef = useRef>(new Map()); const admin2CacheRef = useRef>(new Map()); const geojsonCacheRef = useRef>(new Map()); // Force re-render when caches update const [, forceUpdate] = useState({}); const triggerUpdate = useCallback(() => forceUpdate({}), []); /** * Fetch list of countries with boundary data */ const fetchCountries = useCallback(async (): Promise => { setIsLoading(true); setError(null); try { const data = await apiFetch('/boundaries/countries'); setCountries(data); return data; } catch (err) { const error = err instanceof Error ? err : new Error('Failed to fetch countries'); setError(error); throw error; } finally { setIsLoading(false); } }, []); /** * Fetch admin1 (provinces/states) for a country */ const fetchAdmin1 = useCallback(async (countryCode: string): Promise => { const cacheKey = countryCode.toUpperCase(); // Return cached if available if (admin1CacheRef.current.has(cacheKey)) { return admin1CacheRef.current.get(cacheKey)!; } setIsLoading(true); setError(null); try { const data = await apiFetch(`/boundaries/countries/${cacheKey}/admin1`); admin1CacheRef.current.set(cacheKey, data); triggerUpdate(); return data; } catch (err) { const error = err instanceof Error ? err : new Error(`Failed to fetch admin1 for ${countryCode}`); setError(error); throw error; } finally { setIsLoading(false); } }, [triggerUpdate]); /** * Fetch admin2 (municipalities/counties) for a country */ const fetchAdmin2 = useCallback(async ( countryCode: string, admin1Code?: string ): Promise => { const cacheKey = admin1Code ? `${countryCode.toUpperCase()}:${admin1Code}` : countryCode.toUpperCase(); // Return cached if available if (admin2CacheRef.current.has(cacheKey)) { return admin2CacheRef.current.get(cacheKey)!; } setIsLoading(true); setError(null); try { const endpoint = admin1Code ? `/boundaries/countries/${countryCode.toUpperCase()}/admin2?admin1_code=${admin1Code}` : `/boundaries/countries/${countryCode.toUpperCase()}/admin2`; const data = await apiFetch(endpoint); admin2CacheRef.current.set(cacheKey, data); triggerUpdate(); return data; } catch (err) { const error = err instanceof Error ? err : new Error(`Failed to fetch admin2 for ${countryCode}`); setError(error); throw error; } finally { setIsLoading(false); } }, [triggerUpdate]); /** * Fetch GeoJSON FeatureCollection of admin2 boundaries */ const fetchAdmin2GeoJSON = useCallback(async ( countryCode: string, admin1Code?: string, simplify: number = 0.001 ): Promise => { const cacheKey = admin1Code ? `${countryCode.toUpperCase()}:${admin1Code}:${simplify}` : `${countryCode.toUpperCase()}:${simplify}`; // Return cached if available if (geojsonCacheRef.current.has(cacheKey)) { return geojsonCacheRef.current.get(cacheKey)!; } setIsLoading(true); setError(null); try { let endpoint = `/boundaries/countries/${countryCode.toUpperCase()}/admin2/geojson?simplify=${simplify}`; if (admin1Code) { endpoint += `&admin1_code=${admin1Code}`; } const data = await apiFetch(endpoint); geojsonCacheRef.current.set(cacheKey, data); triggerUpdate(); return data; } catch (err) { const error = err instanceof Error ? err : new Error(`Failed to fetch GeoJSON for ${countryCode}`); setError(error); throw error; } finally { setIsLoading(false); } }, [triggerUpdate]); /** * Point-in-polygon lookup */ const lookupPoint = useCallback(async ( lat: number, lon: number, countryCode?: string ): Promise => { setIsLoading(true); setError(null); try { let endpoint = `/boundaries/lookup?lat=${lat}&lon=${lon}`; if (countryCode) { endpoint += `&country_code=${countryCode.toUpperCase()}`; } return await apiFetch(endpoint); } catch (err) { const error = err instanceof Error ? err : new Error('Failed to lookup point'); setError(error); throw error; } finally { setIsLoading(false); } }, []); /** * Fetch boundary statistics */ const fetchStats = useCallback(async (): Promise => { setIsLoading(true); setError(null); try { const data = await apiFetch('/boundaries/stats'); setStats(data); return data; } catch (err) { const error = err instanceof Error ? err : new Error('Failed to fetch boundary stats'); setError(error); throw error; } finally { setIsLoading(false); } }, []); /** * Get admin2 by code from cache */ const getAdmin2ByCode = useCallback(( countryCode: string, admin2Code: string ): BoundaryAdmin2 | undefined => { const cacheKey = countryCode.toUpperCase(); const cached = admin2CacheRef.current.get(cacheKey); if (!cached) return undefined; // Normalize codes for comparison (some APIs use padded codes) const normalizedCode = admin2Code.padStart(4, '0'); return cached.find(a => a.code === admin2Code || a.code === normalizedCode || a.code.padStart(4, '0') === normalizedCode ); }, []); /** * Get admin2 by name from cache (fuzzy match) */ const getAdmin2ByName = useCallback(( countryCode: string, name: string ): BoundaryAdmin2 | undefined => { const cacheKey = countryCode.toUpperCase(); const cached = admin2CacheRef.current.get(cacheKey); if (!cached) return undefined; const normalizedName = name.toLowerCase().trim(); return cached.find(a => a.name.toLowerCase() === normalizedName || a.name_local?.toLowerCase() === normalizedName || a.name.toLowerCase().includes(normalizedName) || normalizedName.includes(a.name.toLowerCase()) ); }, []); /** * Filter GeoJSON features by admin2 codes * Useful for showing werkgebied (service area) for archives */ const filterFeaturesByCodes = useCallback(( countryCode: string, admin2Codes: string[] ): GeoJSONFeature[] => { // Look for any cached geojson for this country (any simplification level) const prefix = `${countryCode.toUpperCase()}:`; let geojson: GeoJSONFeatureCollection | undefined; for (const [key, value] of geojsonCacheRef.current.entries()) { if (key.startsWith(prefix) || key.startsWith(countryCode.toUpperCase())) { geojson = value; break; } } if (!geojson) { console.warn(`[useBoundariesAPI] No cached GeoJSON for ${countryCode}`); return []; } // Normalize codes for comparison const normalizedCodes = new Set( admin2Codes.map(code => code.padStart(4, '0')) ); return geojson.features.filter(f => { const code = f.properties.code; return code && ( normalizedCodes.has(code) || normalizedCodes.has(code.padStart(4, '0')) ); }); }, []); /** * Clear all caches */ const clearCache = useCallback(() => { admin1CacheRef.current.clear(); admin2CacheRef.current.clear(); geojsonCacheRef.current.clear(); setCountries([]); setStats(null); triggerUpdate(); }, [triggerUpdate]); // Fetch countries on mount useEffect(() => { fetchCountries().catch(err => { console.error('[useBoundariesAPI] Failed to fetch countries on mount:', err); }); }, [fetchCountries]); return { // Data countries, admin1Cache: admin1CacheRef.current, admin2Cache: admin2CacheRef.current, geojsonCache: geojsonCacheRef.current, stats, // Status isLoading, error, // Actions fetchCountries, fetchAdmin1, fetchAdmin2, fetchAdmin2GeoJSON, lookupPoint, fetchStats, // Utilities getAdmin2ByCode, getAdmin2ByName, filterFeaturesByCodes, clearCache, }; } export default useBoundariesAPI;