glam/frontend/src/hooks/useGeoApiInstitutions.ts
2025-12-10 18:04:25 +01:00

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 };
}