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
482 lines
13 KiB
TypeScript
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;
|