743 lines
22 KiB
TypeScript
743 lines
22 KiB
TypeScript
/**
|
|
* React Hook for fetching institution data from the PostGIS Geo API
|
|
*
|
|
* This hook provides an alternative to useDuckLakeInstitutions, fetching data
|
|
* from the server-side PostGIS database via the Geo API instead of client-side DuckLake.
|
|
*
|
|
* Benefits of Geo API over DuckLake:
|
|
* - Faster initial load (no WASM initialization)
|
|
* - Works on low-memory devices
|
|
* - Server-side filtering with bbox/province/type params
|
|
* - Supports spatial queries (nearby, reverse geocoding)
|
|
*
|
|
* API Endpoints used:
|
|
* - GET /api/geo/institutions - GeoJSON FeatureCollection of all institutions
|
|
* - GET /api/geo/institutions?bbox=... - Filtered by bounding box
|
|
* - GET /api/geo/institutions?type=... - Filtered by institution type
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import type { Institution } from '../components/map/InstitutionInfoPanel';
|
|
|
|
// Re-export Institution type for convenience
|
|
export type { Institution };
|
|
|
|
// Institution type code to color mapping (matches NDEMapPageMapLibre.tsx)
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
'G': '#00bcd4', // Gallery - cyan
|
|
'L': '#2ecc71', // Library - green
|
|
'A': '#3498db', // Archive - blue
|
|
'M': '#e74c3c', // Museum - red
|
|
'O': '#f39c12', // Official - orange
|
|
'R': '#1abc9c', // Research - teal
|
|
'C': '#795548', // Corporation - brown
|
|
'U': '#9e9e9e', // Unknown - gray
|
|
'B': '#4caf50', // Botanical - green
|
|
'E': '#ff9800', // Education - amber
|
|
'S': '#9b59b6', // Society - purple
|
|
'F': '#95a5a6', // Features - gray
|
|
'I': '#673ab7', // Intangible - deep purple
|
|
'X': '#607d8b', // Mixed - blue gray
|
|
'P': '#8bc34a', // Personal - light green
|
|
'H': '#607d8b', // Holy sites - blue gray
|
|
'D': '#34495e', // Digital - dark gray
|
|
'N': '#e91e63', // NGO - pink
|
|
'T': '#ff5722', // Taste/smell - deep orange
|
|
};
|
|
|
|
// Institution type code to name mapping
|
|
const TYPE_NAMES: Record<string, string> = {
|
|
'G': 'Gallery',
|
|
'L': 'Library',
|
|
'A': 'Archive',
|
|
'M': 'Museum',
|
|
'O': 'Official',
|
|
'R': 'Research',
|
|
'C': 'Corporation',
|
|
'U': 'Unknown',
|
|
'B': 'Botanical',
|
|
'E': 'Education',
|
|
'S': 'Society',
|
|
'F': 'Features',
|
|
'I': 'Intangible',
|
|
'X': 'Mixed',
|
|
'P': 'Personal',
|
|
'H': 'Holy sites',
|
|
'D': 'Digital',
|
|
'N': 'NGO',
|
|
'T': 'Taste/smell',
|
|
};
|
|
|
|
// Province code to name mapping
|
|
const PROVINCE_CODE_MAP: Record<string, string> = {
|
|
'DR': 'Drenthe',
|
|
'FL': 'Flevoland',
|
|
'FR': 'Friesland',
|
|
'GE': 'Gelderland',
|
|
'GR': 'Groningen',
|
|
'LI': 'Limburg',
|
|
'NB': 'Noord-Brabant',
|
|
'NH': 'Noord-Holland',
|
|
'OV': 'Overijssel',
|
|
'UT': 'Utrecht',
|
|
'ZE': 'Zeeland',
|
|
'ZH': 'Zuid-Holland',
|
|
};
|
|
|
|
// API base URL - uses relative path so it works in both dev and prod
|
|
const GEO_API_BASE = '/api/geo';
|
|
|
|
// ============================================================================
|
|
// CACHING & PRELOADING
|
|
// ============================================================================
|
|
|
|
/**
|
|
* In-memory cache for institutions data
|
|
* Persists across component re-mounts and page navigations
|
|
*/
|
|
interface InstitutionsCache {
|
|
data: Institution[] | null;
|
|
timestamp: number;
|
|
loading: boolean;
|
|
loadPromise: Promise<Institution[]> | null;
|
|
progress: LoadingProgress;
|
|
error: Error | null;
|
|
subscribers: Set<(progress: LoadingProgress) => void>;
|
|
}
|
|
|
|
const institutionsCache: InstitutionsCache = {
|
|
data: null,
|
|
timestamp: 0,
|
|
loading: false,
|
|
loadPromise: null,
|
|
progress: { percent: 0, phase: 'connecting', message: 'Initializing...' },
|
|
error: null,
|
|
subscribers: new Set(),
|
|
};
|
|
|
|
// Cache TTL: 5 minutes
|
|
const CACHE_TTL = 5 * 60 * 1000;
|
|
|
|
/**
|
|
* Notify all subscribers of progress updates
|
|
*/
|
|
function notifyProgress(progress: LoadingProgress) {
|
|
institutionsCache.progress = progress;
|
|
institutionsCache.subscribers.forEach(cb => cb(progress));
|
|
}
|
|
|
|
/**
|
|
* Fetch institutions with streaming progress tracking
|
|
* Uses ReadableStream to track download progress in real-time
|
|
*/
|
|
async function fetchInstitutionsWithProgress(url: string): Promise<GeoAPIResponse> {
|
|
notifyProgress({ percent: 10, phase: 'connecting', message: 'Connecting to server...' });
|
|
|
|
const response = await fetch(url, {
|
|
headers: { 'Accept': 'application/json' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Geo API request failed: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
// Get content length for progress calculation
|
|
const contentLength = response.headers.get('Content-Length');
|
|
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
|
|
|
notifyProgress({
|
|
percent: 15,
|
|
phase: 'fetching',
|
|
message: totalBytes > 0
|
|
? `Downloading data (0 / ${(totalBytes / 1024 / 1024).toFixed(1)} MB)...`
|
|
: 'Downloading data...'
|
|
});
|
|
|
|
// If no content-length or no body, fall back to simple fetch
|
|
if (!totalBytes || !response.body) {
|
|
notifyProgress({ percent: 30, phase: 'fetching', message: 'Downloading data...' });
|
|
const data = await response.json();
|
|
return data;
|
|
}
|
|
|
|
// Stream the response with progress tracking
|
|
const reader = response.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let receivedBytes = 0;
|
|
let lastProgressUpdate = Date.now();
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
|
|
if (done) break;
|
|
|
|
chunks.push(value);
|
|
receivedBytes += value.length;
|
|
|
|
// Update progress every 100ms to avoid excessive re-renders
|
|
const now = Date.now();
|
|
if (now - lastProgressUpdate > 100) {
|
|
const percentComplete = Math.round((receivedBytes / totalBytes) * 100);
|
|
// Map download progress to 15-60% range
|
|
const displayPercent = 15 + Math.round((percentComplete / 100) * 45);
|
|
|
|
notifyProgress({
|
|
percent: displayPercent,
|
|
phase: 'fetching',
|
|
message: `Downloading data (${(receivedBytes / 1024 / 1024).toFixed(1)} / ${(totalBytes / 1024 / 1024).toFixed(1)} MB)...`,
|
|
});
|
|
lastProgressUpdate = now;
|
|
}
|
|
}
|
|
|
|
notifyProgress({ percent: 60, phase: 'processing', message: 'Parsing response...' });
|
|
|
|
// Combine chunks and decode
|
|
const allChunks = new Uint8Array(receivedBytes);
|
|
let position = 0;
|
|
for (const chunk of chunks) {
|
|
allChunks.set(chunk, position);
|
|
position += chunk.length;
|
|
}
|
|
|
|
const text = new TextDecoder().decode(allChunks);
|
|
const data = JSON.parse(text) as GeoAPIResponse;
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Core loading function - handles the actual data fetching
|
|
* Called by both preload and hook
|
|
*/
|
|
async function loadInstitutions(filters?: GeoAPIFilters): Promise<Institution[]> {
|
|
// Check cache validity
|
|
const now = Date.now();
|
|
if (institutionsCache.data && (now - institutionsCache.timestamp) < CACHE_TTL && !filters) {
|
|
console.log('[GeoAPI] Using cached data:', institutionsCache.data.length, 'institutions');
|
|
notifyProgress({ percent: 100, phase: 'complete', message: `Loaded ${institutionsCache.data.length.toLocaleString()} institutions (cached)` });
|
|
return institutionsCache.data;
|
|
}
|
|
|
|
// If already loading, wait for it
|
|
if (institutionsCache.loading && institutionsCache.loadPromise && !filters) {
|
|
console.log('[GeoAPI] Waiting for existing load...');
|
|
return institutionsCache.loadPromise;
|
|
}
|
|
|
|
// Start loading
|
|
institutionsCache.loading = true;
|
|
institutionsCache.error = null;
|
|
|
|
const loadPromise = (async () => {
|
|
try {
|
|
// Build URL with query params
|
|
const url = new URL(`${GEO_API_BASE}/institutions`, window.location.origin);
|
|
|
|
if (filters?.bbox) {
|
|
url.searchParams.set('bbox', filters.bbox.join(','));
|
|
}
|
|
if (filters?.type) {
|
|
url.searchParams.set('type', filters.type);
|
|
}
|
|
if (filters?.province) {
|
|
url.searchParams.set('province', filters.province);
|
|
}
|
|
|
|
// Fetch with streaming progress
|
|
const data = await fetchInstitutionsWithProgress(url.toString());
|
|
|
|
notifyProgress({
|
|
percent: 65,
|
|
phase: 'processing',
|
|
message: `Processing ${data.features.length.toLocaleString()} institutions...`,
|
|
});
|
|
|
|
// Transform GeoJSON features to Institution objects
|
|
// Process in chunks to show progress
|
|
const chunkSize = 1000;
|
|
const mapped: Institution[] = [];
|
|
|
|
for (let i = 0; i < data.features.length; i += chunkSize) {
|
|
const chunk = data.features.slice(i, i + chunkSize);
|
|
mapped.push(...chunk.map(featureToInstitution));
|
|
|
|
// Update progress
|
|
const processPercent = Math.round((i / data.features.length) * 100);
|
|
const displayPercent = 65 + Math.round((processPercent / 100) * 30);
|
|
|
|
if (i > 0) {
|
|
notifyProgress({
|
|
percent: displayPercent,
|
|
phase: 'processing',
|
|
message: `Processing institutions (${mapped.length.toLocaleString()} / ${data.features.length.toLocaleString()})...`,
|
|
});
|
|
}
|
|
|
|
// Yield to UI thread
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
|
|
notifyProgress({
|
|
percent: 100,
|
|
phase: 'complete',
|
|
message: `Loaded ${mapped.length.toLocaleString()} institutions`,
|
|
});
|
|
|
|
// Update cache (only for unfiltered requests)
|
|
if (!filters) {
|
|
institutionsCache.data = mapped;
|
|
institutionsCache.timestamp = Date.now();
|
|
}
|
|
|
|
console.log(`[GeoAPI] Loaded ${mapped.length} institutions from Geo API`);
|
|
return mapped;
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error('Failed to load institutions');
|
|
console.error('[GeoAPI] Failed to load institutions:', error);
|
|
institutionsCache.error = error;
|
|
throw error;
|
|
} finally {
|
|
institutionsCache.loading = false;
|
|
institutionsCache.loadPromise = null;
|
|
}
|
|
})();
|
|
|
|
if (!filters) {
|
|
institutionsCache.loadPromise = loadPromise;
|
|
}
|
|
|
|
return loadPromise;
|
|
}
|
|
|
|
/**
|
|
* PRELOAD FUNCTION - Call this early to start loading data
|
|
* Can be called from App.tsx or main.tsx to start loading before user navigates
|
|
*/
|
|
export function preloadInstitutions(): void {
|
|
if (institutionsCache.data || institutionsCache.loading) {
|
|
console.log('[GeoAPI Preload] Already loaded or loading, skipping');
|
|
return;
|
|
}
|
|
|
|
console.log('[GeoAPI Preload] Starting preload...');
|
|
loadInstitutions().catch(err => {
|
|
console.warn('[GeoAPI Preload] Failed:', err.message);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if institutions are already loaded/loading
|
|
*/
|
|
export function isInstitutionsReady(): boolean {
|
|
return institutionsCache.data !== null;
|
|
}
|
|
|
|
/**
|
|
* Get current loading progress
|
|
*/
|
|
export function getInstitutionsProgress(): LoadingProgress {
|
|
return institutionsCache.progress;
|
|
}
|
|
|
|
/**
|
|
* GeoJSON Feature from the Geo API
|
|
*/
|
|
interface GeoAPIFeature {
|
|
type: 'Feature';
|
|
geometry: {
|
|
type: 'Point';
|
|
coordinates: [number, number]; // [lon, lat]
|
|
};
|
|
properties: {
|
|
ghcid: string;
|
|
name: string; // API returns 'name' not 'org_name'
|
|
org_name?: string;
|
|
emic_name?: string;
|
|
name_language?: string;
|
|
type: string; // API returns 'type' not 'institution_type'
|
|
type_name?: string; // API also returns type_name
|
|
institution_type?: string; // Keep for backward compatibility
|
|
city?: string;
|
|
province?: string; // API returns 'province' not 'province_code'
|
|
province_iso?: string; // API returns province ISO code
|
|
province_code?: string;
|
|
country_code?: string;
|
|
rating?: number; // API returns 'rating' not 'google_rating'
|
|
total_ratings?: number; // API returns 'total_ratings' not 'google_total_ratings'
|
|
google_rating?: number;
|
|
google_total_ratings?: number;
|
|
wikidata_id?: string;
|
|
website?: string;
|
|
formatted_address?: string;
|
|
isil_code?: string;
|
|
google_place_id?: string;
|
|
// Extended fields (if available)
|
|
phone?: string;
|
|
description?: string;
|
|
opening_hours?: string[];
|
|
photos?: Array<{ url: string; attribution?: string }>;
|
|
reviews?: Array<{ author: string; rating: number; text: string; time: string }>;
|
|
street_view_url?: string;
|
|
business_status?: string;
|
|
museum_register?: boolean;
|
|
founding_year?: number;
|
|
dissolution_year?: number;
|
|
};
|
|
}
|
|
|
|
interface GeoAPIResponse {
|
|
type: 'FeatureCollection';
|
|
features: GeoAPIFeature[];
|
|
}
|
|
|
|
/**
|
|
* Loading progress for UI feedback
|
|
*/
|
|
export interface LoadingProgress {
|
|
percent: number;
|
|
phase: 'connecting' | 'fetching' | 'processing' | 'complete';
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* Filter options for the Geo API query
|
|
*/
|
|
export interface GeoAPIFilters {
|
|
bbox?: [number, number, number, number]; // [minLon, minLat, maxLon, maxLat]
|
|
type?: string; // Institution type code (M, L, A, etc.)
|
|
province?: string; // Province code (NH, ZH, etc.)
|
|
}
|
|
|
|
export interface UseGeoApiInstitutionsReturn {
|
|
institutions: Institution[];
|
|
isLoading: boolean;
|
|
error: Error | null;
|
|
refresh: (filters?: GeoAPIFilters) => Promise<void>;
|
|
isConnected: boolean;
|
|
totalCount: number;
|
|
progress: LoadingProgress;
|
|
}
|
|
|
|
/**
|
|
* Parse province name from GHCID
|
|
* GHCID format: NL-XX-CCC-T-ABBR where XX is province code
|
|
*/
|
|
function parseProvinceFromGhcid(ghcid: string | null | undefined): string {
|
|
if (!ghcid || ghcid.length < 5) return '';
|
|
const code = ghcid.substring(3, 5);
|
|
return PROVINCE_CODE_MAP[code] || '';
|
|
}
|
|
|
|
/**
|
|
* Transform a GeoJSON feature from the Geo API into an Institution object
|
|
*/
|
|
function featureToInstitution(feature: GeoAPIFeature): Institution {
|
|
const props = feature.properties;
|
|
const [lon, lat] = feature.geometry.coordinates;
|
|
|
|
// API returns 'type', fallback to 'institution_type' for backward compatibility
|
|
const typeCode = props.type || props.institution_type || 'U';
|
|
const color = TYPE_COLORS[typeCode] || '#9e9e9e';
|
|
// Use type_name from API if available, otherwise look up from TYPE_NAMES
|
|
const typeName = props.type_name || TYPE_NAMES[typeCode] || 'Unknown';
|
|
|
|
// Use name from API, fallback to emic_name then org_name
|
|
const name = props.name || props.emic_name || props.org_name || 'Unknown';
|
|
|
|
// Get province from province field, province_iso, province_code, or parse from GHCID
|
|
const province = props.province
|
|
|| (props.province_iso ? (PROVINCE_CODE_MAP[props.province_iso] || props.province_iso) : null)
|
|
|| (props.province_code ? (PROVINCE_CODE_MAP[props.province_code] || props.province_code) : null)
|
|
|| parseProvinceFromGhcid(props.ghcid);
|
|
|
|
return {
|
|
lat,
|
|
lon,
|
|
name,
|
|
city: props.city || '',
|
|
province: province || '',
|
|
type: typeCode,
|
|
type_name: typeName,
|
|
color,
|
|
website: props.website || '',
|
|
wikidata_id: props.wikidata_id || '',
|
|
description: props.description || '',
|
|
rating: props.rating || props.google_rating,
|
|
total_ratings: props.total_ratings || props.google_total_ratings,
|
|
phone: props.phone,
|
|
address: props.formatted_address,
|
|
reviews: props.reviews,
|
|
photos: props.photos,
|
|
street_view_url: props.street_view_url,
|
|
business_status: props.business_status,
|
|
google_place_id: props.google_place_id,
|
|
opening_hours: props.opening_hours,
|
|
ghcid: props.ghcid ? {
|
|
current: props.ghcid,
|
|
uuid: '', // Geo API doesn't return UUIDs yet
|
|
numeric: undefined,
|
|
} : undefined,
|
|
isil: props.isil_code ? {
|
|
code: props.isil_code,
|
|
} : undefined,
|
|
museum_register: props.museum_register ? {
|
|
name,
|
|
province: province || '',
|
|
} : undefined,
|
|
emic_name: props.emic_name,
|
|
name_language: props.name_language,
|
|
org_name: props.org_name || props.name,
|
|
founding_year: props.founding_year,
|
|
dissolution_year: props.dissolution_year,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* React hook to fetch institution data from the PostGIS Geo API
|
|
*
|
|
* Uses the shared caching and streaming infrastructure for:
|
|
* - Real-time download progress tracking
|
|
* - Cross-component caching (5-minute TTL)
|
|
* - Preloading support
|
|
*
|
|
* @param filters - Optional filters for bbox, type, province
|
|
* @returns Institution data with loading/error states
|
|
*/
|
|
export function useGeoApiInstitutions(filters?: GeoAPIFilters): UseGeoApiInstitutionsReturn {
|
|
const [institutions, setInstitutions] = useState<Institution[]>(
|
|
// Initialize from cache if available
|
|
institutionsCache.data || []
|
|
);
|
|
const [isLoading, setIsLoading] = useState(!institutionsCache.data);
|
|
const [isConnected, setIsConnected] = useState(!!institutionsCache.data);
|
|
const [error, setError] = useState<Error | null>(institutionsCache.error);
|
|
const [progress, setProgress] = useState<LoadingProgress>(
|
|
institutionsCache.data
|
|
? { percent: 100, phase: 'complete', message: `Loaded ${institutionsCache.data.length.toLocaleString()} institutions (cached)` }
|
|
: institutionsCache.progress
|
|
);
|
|
|
|
// Subscribe to progress updates from the cache
|
|
useEffect(() => {
|
|
const handleProgress = (newProgress: LoadingProgress) => {
|
|
setProgress(newProgress);
|
|
};
|
|
|
|
institutionsCache.subscribers.add(handleProgress);
|
|
|
|
return () => {
|
|
institutionsCache.subscribers.delete(handleProgress);
|
|
};
|
|
}, []);
|
|
|
|
const refresh = useCallback(async (refreshFilters?: GeoAPIFilters) => {
|
|
const activeFilters = refreshFilters || filters;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Use the shared loading function with streaming progress
|
|
const data = await loadInstitutions(activeFilters);
|
|
|
|
setInstitutions(data);
|
|
setIsConnected(true);
|
|
setError(null);
|
|
} catch (err) {
|
|
console.error('[GeoAPI] Failed to load institutions:', err);
|
|
setError(err instanceof Error ? err : new Error('Failed to load institutions from Geo API'));
|
|
setIsConnected(false);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [filters]);
|
|
|
|
// Initial load - use cache if available, otherwise load
|
|
useEffect(() => {
|
|
// If we have cached data, use it immediately
|
|
if (institutionsCache.data && !filters) {
|
|
setInstitutions(institutionsCache.data);
|
|
setIsLoading(false);
|
|
setIsConnected(true);
|
|
setProgress({
|
|
percent: 100,
|
|
phase: 'complete',
|
|
message: `Loaded ${institutionsCache.data.length.toLocaleString()} institutions (cached)`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// If already loading, wait for it
|
|
if (institutionsCache.loading && institutionsCache.loadPromise && !filters) {
|
|
institutionsCache.loadPromise.then(data => {
|
|
setInstitutions(data);
|
|
setIsLoading(false);
|
|
setIsConnected(true);
|
|
}).catch(err => {
|
|
setError(err);
|
|
setIsLoading(false);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Otherwise start fresh load
|
|
refresh();
|
|
}, [refresh, filters]);
|
|
|
|
return {
|
|
institutions,
|
|
isLoading,
|
|
error,
|
|
refresh,
|
|
isConnected,
|
|
totalCount: institutions.length,
|
|
progress,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch nearby institutions from a given point
|
|
*/
|
|
export function useNearbyInstitutions(lon: number, lat: number, limit: number = 10) {
|
|
const [institutions, setInstitutions] = useState<Institution[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
useEffect(() => {
|
|
async function fetchNearby() {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const url = `${GEO_API_BASE}/nearby?lon=${lon}&lat=${lat}&limit=${limit}`;
|
|
const response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Geo API request failed: ${response.status}`);
|
|
}
|
|
|
|
const data: GeoAPIResponse = await response.json();
|
|
const mapped = data.features.map(featureToInstitution);
|
|
setInstitutions(mapped);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('Failed to load nearby institutions'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
if (lon && lat) {
|
|
fetchNearby();
|
|
}
|
|
}, [lon, lat, limit]);
|
|
|
|
return { institutions, isLoading, error };
|
|
}
|
|
|
|
/**
|
|
* Hook to search institutions by name
|
|
*/
|
|
export function useInstitutionSearch(query: string) {
|
|
const [institutions, setInstitutions] = useState<Institution[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
useEffect(() => {
|
|
async function search() {
|
|
if (!query || query.length < 2) {
|
|
setInstitutions([]);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const url = `${GEO_API_BASE}/search?q=${encodeURIComponent(query)}`;
|
|
const response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Geo API request failed: ${response.status}`);
|
|
}
|
|
|
|
const data: GeoAPIResponse = await response.json();
|
|
const mapped = data.features.map(featureToInstitution);
|
|
setInstitutions(mapped);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('Search failed'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
// Debounce search
|
|
const timer = setTimeout(search, 300);
|
|
return () => clearTimeout(timer);
|
|
}, [query]);
|
|
|
|
return { institutions, isLoading, error };
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch GeoJSON for provinces layer
|
|
*/
|
|
export function useProvincesGeoJSON() {
|
|
const [geojson, setGeojson] = useState<GeoJSON.FeatureCollection | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
useEffect(() => {
|
|
async function fetchProvinces() {
|
|
try {
|
|
const response = await fetch(`${GEO_API_BASE}/provinces`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load provinces: ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
setGeojson(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('Failed to load provinces'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
fetchProvinces();
|
|
}, []);
|
|
|
|
return { geojson, isLoading, error };
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch GeoJSON for municipalities layer
|
|
*/
|
|
export function useMunicipalitiesGeoJSON(provinceCode?: string) {
|
|
const [geojson, setGeojson] = useState<GeoJSON.FeatureCollection | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
useEffect(() => {
|
|
async function fetchMunicipalities() {
|
|
try {
|
|
const url = provinceCode
|
|
? `${GEO_API_BASE}/municipalities?province=${provinceCode}`
|
|
: `${GEO_API_BASE}/municipalities`;
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load municipalities: ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
setGeojson(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('Failed to load municipalities'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
fetchMunicipalities();
|
|
}, [provinceCode]);
|
|
|
|
return { geojson, isLoading, error };
|
|
}
|