import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import Map from './components/Map'; import Filters from './components/Filters'; import POIPane from './components/POIPane'; import { PropertiesPane } from './components/PropertiesPane'; import DataSources from './components/DataSources'; import DataSourcesPage from './components/DataSourcesPage'; import HomePage from './components/HomePage'; import type { FeatureMeta, FeatureGroup, FeatureFilters, Bounds, HexagonData, ViewChangeParams, ApiResponse, POI, POIResponse, POICategoriesResponse, POICategoryGroup, ViewState, Property, HexagonPropertiesResponse, } from './types'; const DEBOUNCE_MS = 150; const URL_DEBOUNCE_MS = 300; // Detect if running through VS Code web proxy and construct API base URL function getApiBaseUrl(): string { const { hostname, pathname, href } = window.location; // Check pathname for /proxy/PORT pattern const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/); if (pathMatch) { return `${pathMatch[1]}8001`; } // Check full href in case proxy rewrites pathname const hrefMatch = href.match(/(\/proxy\/)\d+/); if (hrefMatch) { return `${hrefMatch[1]}8001`; } // If not localhost, assume we're behind a proxy and need explicit backend port if (hostname !== 'localhost' && hostname !== '127.0.0.1') { return '/proxy/8001'; } // Local development - webpack proxies /api to :8001 return ''; } const DEFAULT_VIEW: ViewState = { longitude: -1.5, latitude: 53.5, zoom: 6, pitch: 0, }; // --- URL State helpers --- function parseUrlState(): { viewState?: ViewState; filters?: FeatureFilters; poiCategories?: Set; tab?: 'pois' | 'properties'; } { const params = new URLSearchParams(window.location.search); const result: ReturnType = {}; // Parse view: v=lat,lng,zoom const v = params.get('v'); if (v) { const parts = v.split(',').map(Number); if (parts.length === 3 && parts.every((n) => !isNaN(n))) { result.viewState = { latitude: parts[0], longitude: parts[1], zoom: parts[2], pitch: 0, }; } } // Parse filters: f=name:min:max,name:val1|val2 const f = params.get('f'); if (f) { const filters: FeatureFilters = {}; for (const segment of f.split(',')) { const colonIdx = segment.indexOf(':'); if (colonIdx === -1) continue; const name = segment.substring(0, colonIdx); const rest = segment.substring(colonIdx + 1); if (rest.includes(':')) { // Numeric: name:min:max const [minStr, maxStr] = rest.split(':'); const min = Number(minStr); const max = Number(maxStr); if (!isNaN(min) && !isNaN(max)) { filters[name] = [min, max]; } } else if (rest.includes('|')) { // Enum: name:val1|val2 filters[name] = rest.split('|'); } else { // Single enum value filters[name] = [rest]; } } if (Object.keys(filters).length > 0) { result.filters = filters; } } // Parse POI categories: poi=Cafe,Pub,School const poi = params.get('poi'); if (poi) { result.poiCategories = new Set(poi.split(',').filter(Boolean)); } // Parse tab: tab=p or tab=o const tab = params.get('tab'); if (tab === 'p') result.tab = 'properties'; else if (tab === 'o') result.tab = 'pois'; return result; } function stateToParams( viewState: { latitude: number; longitude: number; zoom: number } | null, filters: FeatureFilters, features: FeatureMeta[], selectedPOICategories: Set, rightPaneTab: 'pois' | 'properties' ): URLSearchParams { const params = new URLSearchParams(); // View if (viewState) { params.set( 'v', `${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}` ); } // Filters const filterEntries = Object.entries(filters); if (filterEntries.length > 0) { const filtersStr = filterEntries .map(([name, value]) => { const meta = features.find((f) => f.name === name); if (meta?.type === 'enum') { return `${name}:${(value as string[]).join('|')}`; } const [min, max] = value as [number, number]; return `${name}:${min}:${max}`; }) .join(','); params.set('f', filtersStr); } // POI categories if (selectedPOICategories.size > 0) { params.set('poi', Array.from(selectedPOICategories).join(',')); } // Tab (only if non-default) if (rightPaneTab === 'properties') { params.set('tab', 'p'); } return params; } // --- Header --- type Page = 'home' | 'dashboard' | 'data-sources'; function Header({ activePage, onPageChange, }: { activePage: Page; onPageChange: (page: Page) => void; }) { const [copied, setCopied] = useState(false); const handleShare = useCallback(() => { navigator.clipboard.writeText(window.location.href).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, []); const tabClass = (page: Page) => `px-3 py-1.5 rounded text-sm transition-colors ${ activePage === page ? 'bg-navy-700 font-semibold' : 'text-warm-300 hover:bg-navy-800 hover:text-white' }`; return (
{activePage === 'dashboard' && ( )}
); } // --- App --- export default function App() { // Parse URL state once on mount const urlState = useMemo(() => parseUrlState(), []); const [features, setFeatures] = useState([]); const [filters, setFilters] = useState(urlState.filters || {}); const [activeFeature, setActiveFeature] = useState(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [pinnedFeature, setPinnedFeature] = useState(null); const [rawData, setRawData] = useState([]); const [dragData, setDragData] = useState(null); const [resolution, setResolution] = useState(8); const [bounds, setBounds] = useState(null); const [loading, setLoading] = useState(false); const [zoom, setZoom] = useState(urlState.viewState?.zoom || DEFAULT_VIEW.zoom); const debounceRef = useRef | null>(null); const abortControllerRef = useRef(null); const dragAbortRef = useRef(null); // View state for URL serialization const [currentView, setCurrentView] = useState<{ latitude: number; longitude: number; zoom: number; } | null>( urlState.viewState ? { latitude: urlState.viewState.latitude, longitude: urlState.viewState.longitude, zoom: urlState.viewState.zoom, } : null ); // Initial view state for Map const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []); // POI state const [pois, setPois] = useState([]); const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [selectedPOICategories, setSelectedPOICategories] = useState>( urlState.poiCategories || new Set() ); const poiDebounceRef = useRef | null>(null); const poiAbortControllerRef = useRef(null); // Hexagon properties state const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>( null ); const [properties, setProperties] = useState([]); const [propertiesTotal, setPropertiesTotal] = useState(0); const [propertiesOffset, setPropertiesOffset] = useState(0); const [loadingProperties, setLoadingProperties] = useState(false); const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois'); const [activePage, setActivePage] = useState('home'); // Derive enabled features from filter keys const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); // Derive view feature: active drag takes priority over pinned const viewFeature = activeFeature || pinnedFeature; const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null; // Color range: always the feature's full slider range from metadata const colorRange = useMemo((): [number, number] | null => { if (!viewFeature) return null; const meta = features.find((f) => f.name === viewFeature); if (meta?.min != null && meta?.max != null) return [meta.min, meta.max]; return null; }, [viewFeature, features]); // Filter range: current drag or committed filter values, used for gray-out const filterRange = useMemo((): [number, number] | null => { if (!viewFeature) return null; if (activeFeature && dragValue) return dragValue; const filterVal = filters[viewFeature]; if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number]; return null; }, [viewFeature, activeFeature, dragValue, filters]); // --- URL sync --- const urlDebounceRef = useRef | null>(null); useEffect(() => { if (urlDebounceRef.current) { clearTimeout(urlDebounceRef.current); } urlDebounceRef.current = setTimeout(() => { const params = stateToParams( currentView, filters, features, selectedPOICategories, rightPaneTab ); const search = params.toString(); const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; window.history.replaceState(null, '', newUrl); }, URL_DEBOUNCE_MS); return () => { if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); }; }, [currentView, filters, features, selectedPOICategories, rightPaneTab]); // Fetch feature metadata + POI categories on mount useEffect(() => { fetch(`${getApiBaseUrl()}/api/features`) .then((res) => res.json()) .then((json: { groups: FeatureGroup[] }) => { // Flatten grouped response into a flat feature list with group annotation const flat: FeatureMeta[] = json.groups.flatMap((g) => g.features.map((f) => ({ ...f, group: g.name })) ); setFeatures(flat); }) .catch((err) => console.error('Failed to fetch features:', err)); fetch(`${getApiBaseUrl()}/api/poi-categories`) .then((res) => res.json()) .then((json: POICategoriesResponse) => { setPOICategoryGroups(json.groups); }) .catch((err) => console.error('Failed to fetch POI categories:', err)); }, []); // Build filter query string helper const buildFilterParam = useCallback((): string => { const filterEntries = Object.entries(filters); if (filterEntries.length === 0) return ''; return filterEntries .map(([name, value]) => { const meta = features.find((f) => f.name === name); if (meta?.type === 'enum') { return `${name}:${(value as string[]).join('|')}`; } const [min, max] = value as [number, number]; return `${name}:${min}:${max}`; }) .join(','); }, [filters, features]); // Debounced fetch when resolution/bounds/filters change — always fetch hexagons useEffect(() => { if (!bounds) return; if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(async () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } abortControllerRef.current = new AbortController(); setLoading(true); try { const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; const filtersStr = buildFilterParam(); const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr, }); if (filtersStr) params.set('filters', filtersStr); const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { signal: abortControllerRef.current.signal, }); const json: ApiResponse = await res.json(); setRawData(json.features || []); } catch (err) { if (err instanceof Error && err.name !== 'AbortError') { console.error('Failed to fetch data:', err); } } finally { setLoading(false); } }, DEBOUNCE_MS); return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, [resolution, bounds, filters, buildFilterParam]); // During slider drag, use the expanded dataset (without active feature filter) // so both narrowing and expanding are visible. Otherwise use server-filtered data. const data = dragData ?? rawData; // Fetch POIs when bounds or selected categories change useEffect(() => { if (!bounds || selectedPOICategories.size === 0) { setPois([]); return; } if (poiDebounceRef.current) { clearTimeout(poiDebounceRef.current); } poiDebounceRef.current = setTimeout(async () => { if (poiAbortControllerRef.current) { poiAbortControllerRef.current.abort(); } poiAbortControllerRef.current = new AbortController(); try { const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; const categoriesStr = Array.from(selectedPOICategories).join(','); const params = new URLSearchParams({ categories: categoriesStr, bounds: boundsStr, }); const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, { signal: poiAbortControllerRef.current.signal, }); const json: POIResponse = await res.json(); setPois(json.pois || []); } catch (err) { if (err instanceof Error && err.name !== 'AbortError') { console.error('Failed to fetch POIs:', err); } } }, DEBOUNCE_MS); return () => { if (poiDebounceRef.current) { clearTimeout(poiDebounceRef.current); } }; }, [bounds, selectedPOICategories]); const prevBoundsRef = useRef(''); const handleViewChange = useCallback( ({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude, }: ViewChangeParams) => { // Only update bounds/resolution when quantized values actually change const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`; if (boundsKey !== prevBoundsRef.current) { prevBoundsRef.current = boundsKey; setResolution(newRes); setBounds(newBounds); } setZoom(newZoom); setCurrentView({ latitude, longitude, zoom: newZoom }); }, [] ); const handleAddFilter = useCallback( (name: string) => { const meta = features.find((f) => f.name === name); if (!meta) return; if (meta.type === 'enum' && meta.values) { setFilters((prev) => ({ ...prev, [name]: [...meta.values!] })); } else if (meta.min != null && meta.max != null) { setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] })); } }, [features] ); const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => { setFilters((prev) => ({ ...prev, [name]: value })); }, []); const handleRemoveFilter = useCallback((name: string) => { setFilters((prev) => { const next = { ...prev }; delete next[name]; return next; }); setPinnedFeature((prev) => (prev === name ? null : prev)); }, []); const handleDragStart = useCallback( (name: string) => { const meta = features.find((f) => f.name === name); if (meta?.type === 'enum') return; // No drag interaction for enum features setActiveFeature(name); const fval = filters[name]; setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); // Fetch hexagons without this feature's filter so we can expand the range if (!bounds) return; if (dragAbortRef.current) dragAbortRef.current.abort(); dragAbortRef.current = new AbortController(); const otherFilters = Object.entries(filters).filter(([k]) => k !== name); let filtersStr = ''; if (otherFilters.length > 0) { filtersStr = otherFilters .map(([n, value]) => { const m = features.find((f) => f.name === n); if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`; const [min, max] = value as [number, number]; return `${n}:${min}:${max}`; }) .join(','); } const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr }); if (filtersStr) params.set('filters', filtersStr); fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { signal: dragAbortRef.current.signal, }) .then((res) => res.json()) .then((json: ApiResponse) => setDragData(json.features || [])) .catch((err) => { if (err instanceof Error && err.name !== 'AbortError') { console.error('Failed to fetch drag data:', err); } }); }, [filters, features, bounds, resolution] ); const handleDragChange = useCallback((value: [number, number]) => { setDragValue(value); }, []); const handleDragEnd = useCallback(() => { if (activeFeature && dragValue) { setFilters((prev) => ({ ...prev, [activeFeature]: dragValue })); } setActiveFeature(null); setDragValue(null); setDragData(null); if (dragAbortRef.current) { dragAbortRef.current.abort(); dragAbortRef.current = null; } }, [activeFeature, dragValue]); const handleTogglePin = useCallback((name: string) => { setPinnedFeature((prev) => (prev === name ? null : name)); }, []); const handleCancelPin = useCallback(() => { setPinnedFeature(null); }, []); const fetchHexagonProperties = useCallback( async (h3: string, res: number, offset = 0) => { setLoadingProperties(true); try { const params = new URLSearchParams({ h3, resolution: res.toString(), limit: '100', offset: offset.toString(), }); // Add current filters const filterEntries = Object.entries(filters); if (filterEntries.length > 0) { const filterStr = filterEntries .map(([name, value]) => { const meta = features.find((f) => f.name === name); if (meta?.type === 'enum') { return `${name}:${(value as string[]).join('|')}`; } const [min, max] = value as [number, number]; return `${name}:${min}:${max}`; }) .join(','); params.append('filters', filterStr); } const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`); const data: HexagonPropertiesResponse = await response.json(); if (offset === 0) { setProperties(data.properties); } else { setProperties((prev) => [...prev, ...data.properties]); } setPropertiesTotal(data.total); setPropertiesOffset(offset + data.properties.length); } catch (err) { console.error('Failed to fetch properties:', err); } finally { setLoadingProperties(false); } }, [filters, features] ); const handleHexagonClick = useCallback( (h3: string) => { if (selectedHexagon?.h3 === h3) { // Deselect if clicking same hexagon setSelectedHexagon(null); setProperties([]); } else { setSelectedHexagon({ h3, resolution }); setPropertiesOffset(0); setRightPaneTab('properties'); // Auto-switch to properties tab fetchHexagonProperties(h3, resolution, 0); } }, [selectedHexagon, resolution, fetchHexagonProperties] ); const handleLoadMoreProperties = useCallback(() => { if (selectedHexagon) { fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset); } }, [selectedHexagon, propertiesOffset, fetchHexagonProperties]); const handleCloseProperties = useCallback(() => { setSelectedHexagon(null); setProperties([]); }, []); return (
{activePage === 'home' ? ( setActivePage('dashboard')} /> ) : activePage === 'data-sources' ? ( ) : (
{loading && (
Loading...
)} setActivePage('data-sources')} />
{/* Tab headers */}
{/* Tab content */}
{rightPaneTab === 'pois' ? ( ) : ( )}
)}
); }