178 lines
4.8 KiB
TypeScript
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 };
|
|
}
|