glam/frontend/src/hooks/useGeoApiCountries.ts
2025-12-10 13:01:13 +01:00

178 lines
4.8 KiB
TypeScript

/**
* React Hook for fetching country boundaries with institution counts from the Geo API
*
* This hook fetches country polygons for low-zoom map views where individual
* institution points would be too dense to display meaningfully.
*
* API Endpoint:
* - GET /api/geo/countries - Returns country polygons with institution counts
*/
import { useState, useEffect, useCallback } from 'react';
// API base URL
const GEO_API_BASE = '/api/geo';
/**
* Country data from the Geo API
*/
export interface CountryData {
iso_a2: string;
iso_a3: string | null;
name: string;
institution_count: number;
centroid: [number | null, number | null]; // [lon, lat]
area_km2: number | null;
}
/**
* GeoJSON Feature for a country
*/
export interface CountryFeature {
type: 'Feature';
id: string;
geometry: GeoJSON.Geometry;
properties: CountryData;
}
export interface CountriesGeoJSON {
type: 'FeatureCollection';
features: CountryFeature[];
metadata: {
count: number;
total_institutions: number;
countries_with_data: number;
type_filter: string | null;
simplified: boolean;
};
}
export interface UseGeoApiCountriesReturn {
geojson: CountriesGeoJSON | null;
isLoading: boolean;
error: Error | null;
refresh: (typeFilter?: string | string[]) => Promise<void>;
countriesWithData: CountryData[];
totalInstitutions: number;
}
/**
* React hook to fetch country boundaries with institution counts
*
* @param typeFilter - Optional institution type filter (single type or array of types)
* @returns Country GeoJSON with institution counts
*/
export function useGeoApiCountries(typeFilter?: string | string[]): UseGeoApiCountriesReturn {
const [geojson, setGeojson] = useState<CountriesGeoJSON | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const refresh = useCallback(async (filter?: string | string[]) => {
const activeFilter = filter !== undefined ? filter : typeFilter;
setIsLoading(true);
setError(null);
try {
const url = new URL(`${GEO_API_BASE}/countries`, window.location.origin);
url.searchParams.set('simplified', 'true');
url.searchParams.set('with_counts', 'true');
// Support single type or array of types
if (activeFilter) {
if (Array.isArray(activeFilter)) {
// Pass multiple types as comma-separated
url.searchParams.set('type', activeFilter.join(','));
} else {
url.searchParams.set('type', activeFilter);
}
}
console.log(`[GeoAPI Countries] Fetching from ${url.toString()}`);
const response = await fetch(url.toString(), {
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Geo API request failed: ${response.status} ${response.statusText}`);
}
const data: CountriesGeoJSON = await response.json();
console.log(`[GeoAPI Countries] Loaded ${data.features.length} countries, ${data.metadata.total_institutions} institutions`);
setGeojson(data);
} catch (err) {
console.error('[GeoAPI Countries] Failed to load countries:', err);
setError(err instanceof Error ? err : new Error('Failed to load country boundaries'));
} finally {
setIsLoading(false);
}
}, [typeFilter]);
// Initial load
useEffect(() => {
refresh();
}, [refresh]);
// Computed values
const countriesWithData = geojson?.features
.filter(f => f.properties.institution_count > 0)
.map(f => f.properties)
.sort((a, b) => b.institution_count - a.institution_count) || [];
const totalInstitutions = geojson?.metadata.total_institutions || 0;
return {
geojson,
isLoading,
error,
refresh,
countriesWithData,
totalInstitutions,
};
}
/**
* Hook to get country detail with institution breakdown by type
*/
export function useGeoApiCountryDetail(isoA2: string | null) {
const [data, setData] = useState<CountryFeature | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!isoA2) {
setData(null);
return;
}
async function fetchDetail() {
setIsLoading(true);
setError(null);
try {
const url = `${GEO_API_BASE}/countries/${isoA2}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load country detail: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load country detail'));
} finally {
setIsLoading(false);
}
}
fetchDetail();
}, [isoA2]);
return { data, isLoading, error };
}