glam/frontend/src/hooks/useBoundariesAPI.ts
kempersc f82dd57903 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
2025-12-07 19:20:21 +01:00

482 lines
13 KiB
TypeScript

/**
* 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<string, number>;
admin2_by_country: Record<string, number>;
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<string, BoundaryAdmin1[]>;
admin2Cache: Map<string, BoundaryAdmin2[]>;
geojsonCache: Map<string, GeoJSONFeatureCollection>;
stats: BoundaryStats | null;
// Status
isLoading: boolean;
error: Error | null;
// Actions
fetchCountries: () => Promise<BoundaryCountry[]>;
fetchAdmin1: (countryCode: string) => Promise<BoundaryAdmin1[]>;
fetchAdmin2: (countryCode: string, admin1Code?: string) => Promise<BoundaryAdmin2[]>;
fetchAdmin2GeoJSON: (countryCode: string, admin1Code?: string, simplify?: number) => Promise<GeoJSONFeatureCollection>;
lookupPoint: (lat: number, lon: number, countryCode?: string) => Promise<PointLookupResult>;
fetchStats: () => Promise<BoundaryStats>;
// 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<T>(endpoint: string): Promise<T> {
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<BoundaryCountry[]>([]);
const [stats, setStats] = useState<BoundaryStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Caches
const admin1CacheRef = useRef<Map<string, BoundaryAdmin1[]>>(new Map());
const admin2CacheRef = useRef<Map<string, BoundaryAdmin2[]>>(new Map());
const geojsonCacheRef = useRef<Map<string, GeoJSONFeatureCollection>>(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<BoundaryCountry[]> => {
setIsLoading(true);
setError(null);
try {
const data = await apiFetch<BoundaryCountry[]>('/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<BoundaryAdmin1[]> => {
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<BoundaryAdmin1[]>(`/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<BoundaryAdmin2[]> => {
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<BoundaryAdmin2[]>(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<GeoJSONFeatureCollection> => {
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<GeoJSONFeatureCollection>(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<PointLookupResult> => {
setIsLoading(true);
setError(null);
try {
let endpoint = `/boundaries/lookup?lat=${lat}&lon=${lon}`;
if (countryCode) {
endpoint += `&country_code=${countryCode.toUpperCase()}`;
}
return await apiFetch<PointLookupResult>(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<BoundaryStats> => {
setIsLoading(true);
setError(null);
try {
const data = await apiFetch<BoundaryStats>('/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;