From f82dd5790361a0faaedaafe4a5b718c297fc9f84 Mon Sep 17 00:00:00 2001 From: kempersc Date: Sun, 7 Dec 2025 19:20:21 +0100 Subject: [PATCH] feat(frontend): add useBoundariesAPI hook for PostGIS boundary fetching New React hook that fetches administrative boundaries from the PostGIS API: - Supports international boundaries (NL, JP, CZ, DE, BE, CH, AT, etc.) - Caches admin1, admin2, and GeoJSON data - Provides point-in-polygon lookup - Includes utility functions for filtering boundaries by code/name - Replaces static GeoJSON file loading pattern --- frontend/src/hooks/useBoundariesAPI.ts | 482 +++++++++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 frontend/src/hooks/useBoundariesAPI.ts diff --git a/frontend/src/hooks/useBoundariesAPI.ts b/frontend/src/hooks/useBoundariesAPI.ts new file mode 100644 index 0000000000..bb3f0ecf50 --- /dev/null +++ b/frontend/src/hooks/useBoundariesAPI.ts @@ -0,0 +1,482 @@ +/** + * 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;