diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6147eee..f5c98d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,40 +1,15 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { trackPageview } from './usePlausible'; -import Map from './components/map/Map'; -import type { SearchedPostcode } from './components/map/PostcodeSearch'; -import Filters from './components/map/Filters'; -import POIPane from './components/map/POIPane'; -import { PropertiesPane } from './components/map/PropertiesPane'; -import AreaPane from './components/map/AreaPane'; -import DataSources from './components/data-sources/DataSources'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { trackPageview } from './hooks/usePlausible'; +import MapPage from './components/map/MapPage'; import DataSourcesPage from './components/data-sources/DataSourcesPage'; import FAQPage from './components/faq/FAQPage'; import HomePage from './components/home/HomePage'; import Header, { type Page } from './components/ui/Header'; -import { TabButton } from './components/ui/TabButton'; -import type { - FeatureMeta, - FeatureGroup, - FeatureFilters, - Bounds, - HexagonData, - PostcodeFeature, - ViewChangeParams, - ApiResponse, - POI, - POIResponse, - POICategoriesResponse, - POICategoryGroup, - Property, - HexagonPropertiesResponse, - HexagonStatsResponse, - NumericFeatureStats, -} from './types'; -import { fetchWithRetry, buildFilterString, apiUrl, logNonAbortError } from './lib/api'; -import { parseUrlState, DEFAULT_VIEW } from './lib/url-state'; -import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils'; +import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; +import { fetchWithRetry, apiUrl } from './lib/api'; +import { parseUrlState } from './lib/url-state'; +import { INITIAL_VIEW_STATE } from './lib/consts'; import { useTheme } from './hooks/useTheme'; -import { useUrlSync } from './hooks/useUrlSync'; declare global { interface Window { @@ -42,81 +17,22 @@ declare global { } } -const DEBOUNCE_MS = 150; - export default function App() { const urlState = useMemo(() => parseUrlState(), []); + const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []); const isScreenshotMode = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get('screenshot') === '1'; }, []); + // Core data 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 [postcodeData, setPostcodeData] = 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); - - 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 - ); - - const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []); - - 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); - - const [selectedHexagon, setSelectedHexagon] = useState<{ - id: string; - type: 'hexagon' | 'postcode'; - 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' | 'area'>( - urlState.tab || 'pois' - ); - - const [areaStats, setAreaStats] = useState(null); - const [loadingAreaStats, setLoadingAreaStats] = useState(false); - - const [leftPaneWidth, setLeftPaneWidth] = useState(384); // 24rem = 384px - const [rightPaneWidth, setRightPaneWidth] = useState(288); // 18rem = 288px - const leftDraggingRef = useRef(false); - const rightDraggingRef = useRef(false); - - const [hoveredHexagon, setHoveredHexagon] = useState(null); - const [searchedPostcode, setSearchedPostcode] = useState(null); const [initialLoading, setInitialLoading] = useState(true); + // UI state + const [pendingInfoFeature, setPendingInfoFeature] = useState(null); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; if (window.history.state?.page) return window.history.state.page; @@ -126,59 +42,9 @@ export default function App() { : 'home'; }); - const [pendingInfoFeature, setPendingInfoFeature] = useState(null); - - const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { - if (infoFeature) { - window.history.replaceState({ ...window.history.state, infoFeature }, ''); - } - const url = hash - ? `${window.location.pathname}${window.location.search}#${hash}` - : `${window.location.pathname}${window.location.search}`; - window.history.pushState({ page }, '', url); - setActivePage(page); - trackPageview(); - }, []); - - useEffect(() => { - if (!window.history.state?.page) { - window.history.replaceState({ page: activePage }, ''); - } - const handlePopState = (e: PopStateEvent) => { - if (e.state?.page) { - setActivePage(e.state.page); - if (e.state.infoFeature) { - setPendingInfoFeature(e.state.infoFeature); - } - } - }; - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - const { theme, toggleTheme } = useTheme(); - useEffect(() => { - if (isScreenshotMode && !initialLoading && rawData.length > 0) { - window.__og_ready = true; - } - }, [isScreenshotMode, initialLoading, rawData]); - - const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); - - const viewFeature = activeFeature || pinnedFeature; - const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null; - - 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]); - - useUrlSync(currentView, filters, features, selectedPOICategories, rightPaneTab); - + // Load features and POI categories on mount useEffect(() => { const controller = new AbortController(); let featuresLoaded = false; @@ -214,486 +80,58 @@ export default function App() { return () => controller.abort(); }, []); - const buildFilterParam = useCallback( - (): string => buildFilterString(filters, features), - [filters, features] - ); + // Screenshot mode ready signal + useEffect(() => { + if (isScreenshotMode && !initialLoading && features.length > 0) { + window.__og_ready = true; + } + }, [isScreenshotMode, initialLoading, features]); - const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD; + // Navigation + const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { + if (infoFeature) { + window.history.replaceState({ ...window.history.state, infoFeature }, ''); + } + const url = hash + ? `${window.location.pathname}${window.location.search}#${hash}` + : `${window.location.pathname}${window.location.search}`; + window.history.pushState({ page }, '', url); + setActivePage(page); + trackPageview(); + }, []); useEffect(() => { - if (!bounds) return; - - if (debounceRef.current) { - clearTimeout(debounceRef.current); + if (!window.history.state?.page) { + window.history.replaceState({ page: activePage }, ''); } - - 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(); - - if (usePostcodeView) { - // Fetch postcode polygons for high zoom levels - const params = new URLSearchParams({ bounds: boundsStr }); - if (filtersStr) params.set('filters', filtersStr); - if (viewFeature) { - params.set('fields', viewFeature); - } else { - params.set('fields', ''); - } - const res = await fetch(apiUrl('postcodes', params), { - signal: abortControllerRef.current.signal, - }); - const json: { features: PostcodeFeature[] } = await res.json(); - setPostcodeData(json.features || []); - setRawData([]); // Clear hexagon data - } else { - // Fetch hexagons for lower zoom levels - const params = new URLSearchParams({ - resolution: resolution.toString(), - bounds: boundsStr, - }); - if (filtersStr) params.set('filters', filtersStr); - if (viewFeature) { - params.set('fields', viewFeature); - } else { - params.set('fields', ''); - } - const res = await fetch(apiUrl('hexagons', params), { - signal: abortControllerRef.current.signal, - }); - const json: ApiResponse = await res.json(); - setRawData(json.features || []); - setPostcodeData([]); // Clear postcode data + const handlePopState = (e: PopStateEvent) => { + if (e.state?.page) { + setActivePage(e.state.page); + if (e.state.infoFeature) { + setPendingInfoFeature(e.state.infoFeature); } - } catch (err) { - logNonAbortError('Failed to fetch data', err); - } finally { - setLoading(false); - } - }, DEBOUNCE_MS); - - return () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); } }; - }, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]); - - const data = dragData ?? rawData; - - // Compute actual min/max from visible data for the viewed feature - // Uses postcodeData when in postcode view, otherwise hexagon/drag data - const dataRange = useMemo((): [number, number] | null => { - if (!viewFeature) return null; - const meta = features.find((f) => f.name === viewFeature); - if (!meta || meta.type === 'enum') return null; - - // When actively dragging, only use dragData (not rawData which has old filters) - // If dragData hasn't loaded yet, return null to trigger fallback - if (activeFeature && !dragData) return null; - - // Only use min_ values since that's what hexagon coloring uses - let min = Infinity; - let max = -Infinity; - - if (usePostcodeView) { - if (postcodeData.length === 0) return null; - for (const feat of postcodeData) { - const val = feat.properties[`min_${viewFeature}`]; - if (typeof val === 'number' && !isNaN(val)) { - min = Math.min(min, val); - max = Math.max(max, val); - } - } - } else { - if (data.length === 0) return null; - for (const item of data) { - const val = item[`min_${viewFeature}`]; - if (typeof val === 'number' && !isNaN(val)) { - min = Math.min(min, val); - max = Math.max(max, val); - } - } - } - - if (min === Infinity || max === -Infinity) return null; - return [min, max]; - }, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]); - - // Color range for the legend and hex coloring - uses actual data range when available - const colorRange = useMemo((): [number, number] | null => { - if (!viewFeature) return null; - const meta = features.find((f) => f.name === viewFeature); - if (!meta) return null; - // For enum features: use [0, numValues-1] - if (meta.type === 'enum' && meta.values && meta.values.length > 0) { - return [0, meta.values.length - 1]; - } - // Use actual data range when available (shows actual min/max on the map) - if (dataRange) return dataRange; - // During drag when data hasn't loaded yet, use dragValue as preview - if (activeFeature && dragValue) return dragValue; - // Fallback to full feature range - if (meta.min != null && meta.max != null) return [meta.min, meta.max]; - return null; - }, [viewFeature, features, dataRange, activeFeature, dragValue]); - - 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(apiUrl('pois', params), { - signal: poiAbortControllerRef.current.signal, - }); - const json: POIResponse = await res.json(); - setPois(json.pois || []); - } catch (err) { - logNonAbortError('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) => { - 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; - setActiveFeature(name); - const fval = filters[name]; - setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); - - 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); - params.set('fields', name); - - fetch(apiUrl('hexagons', params), { - signal: dragAbortRef.current.signal, - }) - .then((res) => res.json()) - .then((json: ApiResponse) => setDragData(json.features || [])) - .catch((err) => logNonAbortError('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 fetchHexagonStats = useCallback( - async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => { - const params = new URLSearchParams({ - h3, - resolution: res.toString(), - }); - const filterStr = buildFilterString(filters, features); - if (filterStr) params.append('filters', filterStr); - if (fields) { - params.set('fields', fields.join(',')); - } - const response = await fetch(apiUrl('hexagon-stats', params), { signal }); - return (await response.json()) as HexagonStatsResponse; - }, - [filters, features] - ); - - /** Build stats from already-loaded PostcodeFeature (min/max per feature). */ - const buildPostcodeStats = useCallback( - (postcode: string): HexagonStatsResponse | null => { - const feat = postcodeData.find((f) => f.properties.postcode === postcode); - if (!feat) return null; - const props = feat.properties; - - const numeric_features: NumericFeatureStats[] = []; - for (const f of features) { - if (f.type !== 'numeric') continue; - const minVal = props[`min_${f.name}`]; - const maxVal = props[`max_${f.name}`]; - if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue; - numeric_features.push({ - name: f.name, - count: props.count, - min: minVal, - max: maxVal, - mean: (minVal + maxVal) / 2, - }); - } - - return { count: props.count, numeric_features, enum_features: [] }; - }, - [postcodeData, features] - ); - - 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(), - }); - - const filterStr = buildFilterString(filters, features); - if (filterStr) params.append('filters', filterStr); - - const response = await fetch(apiUrl('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( - (id: string, isPostcode = false) => { - if (selectedHexagon?.id === id) { - setSelectedHexagon(null); - setProperties([]); - setAreaStats(null); - } else { - const type = isPostcode ? 'postcode' : 'hexagon'; - setSelectedHexagon({ id, type, resolution }); - setProperties([]); - setPropertiesTotal(0); - setPropertiesOffset(0); - setRightPaneTab('area'); - - if (isPostcode) { - setAreaStats(buildPostcodeStats(id)); - setLoadingAreaStats(false); - } else { - setLoadingAreaStats(true); - fetchHexagonStats(id, resolution) - .then((stats) => setAreaStats(stats)) - .catch((error) => logNonAbortError('Failed to fetch area stats', error)) - .finally(() => setLoadingAreaStats(false)); - } - } - }, - [selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats] - ); - - const handleHexagonHover = useCallback((h3: string | null) => { - setHoveredHexagon(h3); - }, []); - - const handleViewPropertiesFromArea = useCallback(() => { - if (selectedHexagon && selectedHexagon.type === 'hexagon') { - setRightPaneTab('properties'); - setPropertiesOffset(0); - fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); - } - }, [selectedHexagon, fetchHexagonProperties]); - - const handlePropertiesTabClick = useCallback(() => { - setRightPaneTab('properties'); - if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) { - setPropertiesOffset(0); - fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); - } - }, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]); - - const handleLoadMoreProperties = useCallback(() => { - if (selectedHexagon && selectedHexagon.type === 'hexagon') { - fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset); - } - }, [selectedHexagon, propertiesOffset, fetchHexagonProperties]); - - const handleCloseProperties = useCallback(() => { - setSelectedHexagon(null); - setProperties([]); - setAreaStats(null); - }, []); - - // Left pane resize handlers - const handleLeftSeparatorPointerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - leftDraggingRef.current = true; - }, []); - - const handleLeftSeparatorPointerMove = useCallback((e: React.PointerEvent) => { - if (!leftDraggingRef.current) return; - const newWidth = Math.min(600, Math.max(200, e.clientX)); - setLeftPaneWidth(newWidth); - }, []); - - const handleLeftSeparatorPointerUp = useCallback(() => { - leftDraggingRef.current = false; - }, []); - - // Right pane resize handlers - const handleRightSeparatorPointerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - rightDraggingRef.current = true; - }, []); - - const handleRightSeparatorPointerMove = useCallback((e: React.PointerEvent) => { - if (!rightDraggingRef.current) return; - const newWidth = Math.min(500, Math.max(200, window.innerWidth - e.clientX)); - setRightPaneWidth(newWidth); - }, []); - - const handleRightSeparatorPointerUp = useCallback(() => { - rightDraggingRef.current = false; - }, []); + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); // eslint-disable-line react-hooks/exhaustive-deps if (isScreenshotMode) { return ( -
- {}} - onHexagonHover={() => {}} - initialViewState={initialViewState} - theme={theme} - screenshotMode - /> -
+ {}} + onNavigateTo={() => {}} + screenshotMode + /> ); } @@ -712,189 +150,19 @@ export default function App() { ) : activePage === 'faq' ? ( ) : ( -
- {initialLoading && ( -
-
- - - - -

- Connecting to server... -

-
-
- )} -
-
- { - navigateTo('data-sources', slug, featureName); - }} - openInfoFeature={pendingInfoFeature} - onClearOpenInfoFeature={() => setPendingInfoFeature(null)} - /> -
-
-
-
-
-
- - {loading && ( -
- Loading... -
- )} - navigateTo('data-sources')} /> -
-
-
-
-
-
-
- setRightPaneTab('area')} - /> - - setRightPaneTab('pois')} - /> -
- -
- {rightPaneTab === 'area' ? ( - f.properties.postcode === selectedHexagon.id) || null - : null - } - onViewProperties={handleViewPropertiesFromArea} - onClose={handleCloseProperties} - hexagonLocation={(() => { - const hexId = selectedHexagon?.id; - const hex = hexId ? data.find((d) => d.h3 === hexId) : null; - if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') - return null; - return { - lat: hex.lat as number, - lon: hex.lon as number, - resolution, - }; - })()} - filters={filters} - onNavigateToSource={(slug, featureName) => { - navigateTo('data-sources', slug, featureName); - }} - /> - ) : rightPaneTab === 'properties' ? ( - navigateTo('data-sources', slug)} - /> - ) : ( - navigateTo('data-sources', slug)} - /> - )} -
-
-
-
+ setPendingInfoFeature(null)} + onNavigateTo={navigateTo} + /> )}
); diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx new file mode 100644 index 0000000..479f279 --- /dev/null +++ b/frontend/src/components/map/MapPage.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect, useMemo } from 'react'; +import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types'; +import type { SearchedPostcode } from './PostcodeSearch'; +import type { Page } from '../ui/Header'; +import Map from './Map'; +import Filters from './Filters'; +import POIPane from './POIPane'; +import { PropertiesPane } from './PropertiesPane'; +import AreaPane from './AreaPane'; +import DataSources from '../data-sources/DataSources'; +import { TabButton } from '../ui/TabButton'; +import { useMapData } from '../../hooks/useMapData'; +import { usePOIData } from '../../hooks/usePOIData'; +import { useFilters } from '../../hooks/useFilters'; +import { useHexagonSelection } from '../../hooks/useHexagonSelection'; +import { usePaneResize } from '../../hooks/usePaneResize'; + +interface MapPageProps { + features: FeatureMeta[]; + poiCategoryGroups: POICategoryGroup[]; + initialFilters: FeatureFilters; + initialViewState: ViewState; + initialPOICategories: Set; + initialTab: 'pois' | 'properties' | 'area'; + initialLoading: boolean; + theme: 'light' | 'dark'; + pendingInfoFeature: string | null; + onClearPendingInfoFeature: () => void; + onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; + screenshotMode?: boolean; +} + +export default function MapPage({ + features, + poiCategoryGroups, + initialFilters, + initialViewState, + initialPOICategories, + initialTab, + initialLoading, + theme, + pendingInfoFeature, + onClearPendingInfoFeature, + onNavigateTo, + screenshotMode, +}: MapPageProps) { + if (screenshotMode) { + return ( +
+ {}} + viewFeature={null} + colorRange={null} + filterRange={null} + viewSource={null} + onCancelPin={() => {}} + features={features} + selectedHexagonId={null} + hoveredHexagonId={null} + onHexagonClick={() => {}} + onHexagonHover={() => {}} + initialViewState={initialViewState} + theme={theme} + screenshotMode + /> +
+ ); + } + + const [searchedPostcode, setSearchedPostcode] = useState(null); + const [selectedPOICategories, setSelectedPOICategories] = useState>(initialPOICategories); + + const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left'); + const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right'); + + // Initialize filters first + const { + filters, + activeFeature, + dragValue, + dragData, + pinnedFeature, + enabledFeatures, + viewFeature, + viewSource, + filterRange, + handleAddFilter, + handleFilterChange, + handleRemoveFilter, + handleDragStart, + handleDragChange, + handleDragEnd, + handleTogglePin, + handleCancelPin, + updateBoundsInfo, + } = useFilters({ + initialFilters, + features, + }); + + // Map data hook + const mapData = useMapData({ + filters, + features, + viewFeature, + activeFeature, + dragValue, + dragData, + }); + + // Keep filter bounds in sync with map data + useEffect(() => { + updateBoundsInfo(mapData.bounds, mapData.resolution); + }, [mapData.bounds, mapData.resolution, updateBoundsInfo]); + + // Hexagon selection hook + const selection = useHexagonSelection({ + filters, + features, + postcodeData: mapData.postcodeData, + resolution: mapData.resolution, + }); + + // POI data + const pois = usePOIData(mapData.bounds, selectedPOICategories); + + // Set initial view and tab from URL state + useEffect(() => { + mapData.setInitialView(initialViewState); + selection.setRightPaneTab(initialTab); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Compute hexagon location for external links + const hexagonLocation = useMemo(() => { + const hexId = selection.selectedHexagon?.id; + const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null; + if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; + return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; + }, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]); + + return ( +
+ {initialLoading && ( +
+
+ + + + +

Connecting to server...

+
+
+ )} + + {/* Left Pane */} +
+
+ onNavigateTo('data-sources', slug, featureName)} + openInfoFeature={pendingInfoFeature} + onClearOpenInfoFeature={onClearPendingInfoFeature} + /> +
+
+
+
+
+ + {/* Map */} +
+ + {mapData.loading && ( +
+ Loading... +
+ )} + onNavigateTo('data-sources')} /> +
+ + {/* Right Pane */} +
+
+
+
+
+
+ selection.setRightPaneTab('area')} /> + + selection.setRightPaneTab('pois')} /> +
+ +
+ {selection.rightPaneTab === 'area' ? ( + f.properties.postcode === selection.selectedHexagon?.id) || null + : null + } + onViewProperties={selection.handleViewPropertiesFromArea} + onClose={selection.handleCloseSelection} + hexagonLocation={hexagonLocation} + filters={filters} + onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)} + /> + ) : selection.rightPaneTab === 'properties' ? ( + onNavigateTo('data-sources', slug)} + /> + ) : ( + onNavigateTo('data-sources', slug)} + /> + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/TabButton.tsx b/frontend/src/components/ui/TabButton.tsx index b15f9c9..39c19a1 100644 --- a/frontend/src/components/ui/TabButton.tsx +++ b/frontend/src/components/ui/TabButton.tsx @@ -1,6 +1,5 @@ interface TabButtonProps { label: string; - count?: number; isActive: boolean; onClick: () => void; } diff --git a/frontend/src/components/ui/icons/LocationIcon.tsx b/frontend/src/components/ui/icons/LocationIcon.tsx deleted file mode 100644 index f3f3b63..0000000 --- a/frontend/src/components/ui/icons/LocationIcon.tsx +++ /dev/null @@ -1,26 +0,0 @@ -interface IconProps { - className?: string; -} - -export function LocationIcon({ className = 'w-5 h-5' }: IconProps) { - return ( - - - - - ); -} diff --git a/frontend/src/components/ui/icons/index.ts b/frontend/src/components/ui/icons/index.ts index 99671d3..1c38057 100644 --- a/frontend/src/components/ui/icons/index.ts +++ b/frontend/src/components/ui/icons/index.ts @@ -5,4 +5,3 @@ export { PlusIcon } from './PlusIcon'; export { ChevronIcon } from './ChevronIcon'; export { FilterIcon } from './FilterIcon'; export { LightbulbIcon } from './LightbulbIcon'; -export { LocationIcon } from './LocationIcon'; diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts new file mode 100644 index 0000000..349cca6 --- /dev/null +++ b/frontend/src/hooks/useFilters.ts @@ -0,0 +1,151 @@ +import { useState, useCallback, useRef, useMemo } from 'react'; +import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types'; +import { apiUrl, logNonAbortError } from '../lib/api'; + +interface UseFiltersOptions { + initialFilters: FeatureFilters; + features: FeatureMeta[]; +} + +export function useFilters({ initialFilters, features }: UseFiltersOptions) { + // Use refs for bounds/resolution so handleDragStart always has latest values + const boundsRef = useRef(null); + const resolutionRef = useRef(8); + const [filters, setFilters] = useState(initialFilters); + const [activeFeature, setActiveFeature] = useState(null); + const [dragValue, setDragValue] = useState<[number, number] | null>(null); + const [pinnedFeature, setPinnedFeature] = useState(null); + const [dragData, setDragData] = useState(null); + const dragAbortRef = useRef(null); + + const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); + + const viewFeature = activeFeature || pinnedFeature; + const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null; + + 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]); + + 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; + setActiveFeature(name); + const fval = filters[name]; + setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); + + const currentBounds = boundsRef.current; + if (!currentBounds) 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 = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`; + const params = new URLSearchParams({ resolution: resolutionRef.current.toString(), bounds: boundsStr }); + if (filtersStr) params.set('filters', filtersStr); + params.set('fields', name); + + fetch(apiUrl('hexagons', params), { + signal: dragAbortRef.current.signal, + }) + .then((res) => res.json()) + .then((json: ApiResponse) => setDragData(json.features || [])) + .catch((err) => logNonAbortError('Failed to fetch drag data', err)); + }, + [filters, features] + ); + + 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 updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => { + boundsRef.current = newBounds; + resolutionRef.current = newResolution; + }, []); + + return { + filters, + activeFeature, + dragValue, + dragData, + pinnedFeature, + enabledFeatures, + viewFeature, + viewSource, + filterRange, + handleAddFilter, + handleFilterChange, + handleRemoveFilter, + handleDragStart, + handleDragChange, + handleDragEnd, + handleTogglePin, + handleCancelPin, + updateBoundsInfo, + }; +} diff --git a/frontend/src/hooks/useHexagonSelection.ts b/frontend/src/hooks/useHexagonSelection.ts new file mode 100644 index 0000000..dbd8cec --- /dev/null +++ b/frontend/src/hooks/useHexagonSelection.ts @@ -0,0 +1,196 @@ +import { useState, useCallback } from 'react'; +import type { + FeatureMeta, + FeatureFilters, + PostcodeFeature, + Property, + HexagonPropertiesResponse, + HexagonStatsResponse, + NumericFeatureStats, +} from '../types'; +import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api'; + +interface SelectedHexagon { + id: string; + type: 'hexagon' | 'postcode'; + resolution: number; +} + +interface UseHexagonSelectionOptions { + filters: FeatureFilters; + features: FeatureMeta[]; + postcodeData: PostcodeFeature[]; + resolution: number; +} + +export function useHexagonSelection({ + filters, + features, + postcodeData, + resolution, +}: UseHexagonSelectionOptions) { + const [selectedHexagon, setSelectedHexagon] = useState(null); + const [properties, setProperties] = useState([]); + const [propertiesTotal, setPropertiesTotal] = useState(0); + const [propertiesOffset, setPropertiesOffset] = useState(0); + const [loadingProperties, setLoadingProperties] = useState(false); + const [areaStats, setAreaStats] = useState(null); + const [loadingAreaStats, setLoadingAreaStats] = useState(false); + const [hoveredHexagon, setHoveredHexagon] = useState(null); + const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>('pois'); + + const fetchHexagonStats = useCallback( + async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => { + const params = new URLSearchParams({ + h3, + resolution: res.toString(), + }); + const filterStr = buildFilterString(filters, features); + if (filterStr) params.append('filters', filterStr); + if (fields) { + params.set('fields', fields.join(',')); + } + const response = await fetch(apiUrl('hexagon-stats', params), { signal }); + return (await response.json()) as HexagonStatsResponse; + }, + [filters, features] + ); + + const buildPostcodeStats = useCallback( + (postcode: string): HexagonStatsResponse | null => { + const feat = postcodeData.find((f) => f.properties.postcode === postcode); + if (!feat) return null; + const props = feat.properties; + + const numeric_features: NumericFeatureStats[] = []; + for (const f of features) { + if (f.type !== 'numeric') continue; + const minVal = props[`min_${f.name}`]; + const maxVal = props[`max_${f.name}`]; + if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue; + numeric_features.push({ + name: f.name, + count: props.count, + min: minVal, + max: maxVal, + mean: (minVal + maxVal) / 2, + }); + } + + return { count: props.count, numeric_features, enum_features: [] }; + }, + [postcodeData, features] + ); + + 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(), + }); + + const filterStr = buildFilterString(filters, features); + if (filterStr) params.append('filters', filterStr); + + const response = await fetch(apiUrl('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( + (id: string, isPostcode = false) => { + if (selectedHexagon?.id === id) { + setSelectedHexagon(null); + setProperties([]); + setAreaStats(null); + } else { + const type = isPostcode ? 'postcode' : 'hexagon'; + setSelectedHexagon({ id, type, resolution }); + setProperties([]); + setPropertiesTotal(0); + setPropertiesOffset(0); + setRightPaneTab('area'); + + if (isPostcode) { + setAreaStats(buildPostcodeStats(id)); + setLoadingAreaStats(false); + } else { + setLoadingAreaStats(true); + fetchHexagonStats(id, resolution) + .then((stats) => setAreaStats(stats)) + .catch((error) => logNonAbortError('Failed to fetch area stats', error)) + .finally(() => setLoadingAreaStats(false)); + } + } + }, + [selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats] + ); + + const handleHexagonHover = useCallback((h3: string | null) => { + setHoveredHexagon(h3); + }, []); + + const handleViewPropertiesFromArea = useCallback(() => { + if (selectedHexagon && selectedHexagon.type === 'hexagon') { + setRightPaneTab('properties'); + setPropertiesOffset(0); + fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); + } + }, [selectedHexagon, fetchHexagonProperties]); + + const handlePropertiesTabClick = useCallback(() => { + setRightPaneTab('properties'); + if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) { + setPropertiesOffset(0); + fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); + } + }, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]); + + const handleLoadMoreProperties = useCallback(() => { + if (selectedHexagon && selectedHexagon.type === 'hexagon') { + fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset); + } + }, [selectedHexagon, propertiesOffset, fetchHexagonProperties]); + + const handleCloseSelection = useCallback(() => { + setSelectedHexagon(null); + setProperties([]); + setAreaStats(null); + }, []); + + return { + selectedHexagon, + properties, + propertiesTotal, + loadingProperties, + areaStats, + loadingAreaStats, + hoveredHexagon, + rightPaneTab, + setRightPaneTab, + handleHexagonClick, + handleHexagonHover, + handleViewPropertiesFromArea, + handlePropertiesTabClick, + handleLoadMoreProperties, + handleCloseSelection, + }; +} diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts new file mode 100644 index 0000000..14945a2 --- /dev/null +++ b/frontend/src/hooks/useMapData.ts @@ -0,0 +1,197 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import type { + FeatureMeta, + FeatureFilters, + Bounds, + HexagonData, + PostcodeFeature, + ViewChangeParams, + ApiResponse, +} from '../types'; +import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api'; +import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils'; + +const DEBOUNCE_MS = 150; + +interface UseMapDataOptions { + filters: FeatureFilters; + features: FeatureMeta[]; + viewFeature: string | null; + activeFeature: string | null; + dragValue: [number, number] | null; + dragData: HexagonData[] | null; +} + +export function useMapData({ + filters, + features, + viewFeature, + activeFeature, + dragValue, + dragData, +}: UseMapDataOptions) { + const [rawData, setRawData] = useState([]); + const [postcodeData, setPostcodeData] = useState([]); + const [resolution, setResolution] = useState(8); + const [bounds, setBounds] = useState(null); + const [loading, setLoading] = useState(false); + const [zoom, setZoom] = useState(10); + const [currentView, setCurrentView] = useState<{ + latitude: number; + longitude: number; + zoom: number; + } | null>(null); + + const debounceRef = useRef | null>(null); + const abortControllerRef = useRef(null); + const prevBoundsRef = useRef(''); + + const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD; + + const buildFilterParam = useCallback( + (): string => buildFilterString(filters, features), + [filters, features] + ); + + // Fetch hexagons or postcodes when bounds/filters change + 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(); + + if (usePostcodeView) { + const params = new URLSearchParams({ bounds: boundsStr }); + if (filtersStr) params.set('filters', filtersStr); + params.set('fields', viewFeature || ''); + const res = await fetch(apiUrl('postcodes', params), { + signal: abortControllerRef.current.signal, + }); + const json: { features: PostcodeFeature[] } = await res.json(); + setPostcodeData(json.features || []); + setRawData([]); + } else { + const params = new URLSearchParams({ + resolution: resolution.toString(), + bounds: boundsStr, + }); + if (filtersStr) params.set('filters', filtersStr); + params.set('fields', viewFeature || ''); + const res = await fetch(apiUrl('hexagons', params), { + signal: abortControllerRef.current.signal, + }); + const json: ApiResponse = await res.json(); + setRawData(json.features || []); + setPostcodeData([]); + } + } catch (err) { + logNonAbortError('Failed to fetch data', err); + } finally { + setLoading(false); + } + }, DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]); + + const data = dragData ?? rawData; + + // Compute actual min/max from visible data for the viewed feature + const dataRange = useMemo((): [number, number] | null => { + if (!viewFeature) return null; + const meta = features.find((f) => f.name === viewFeature); + if (!meta || meta.type === 'enum') return null; + + if (activeFeature && !dragData) return null; + + let min = Infinity; + let max = -Infinity; + + if (usePostcodeView) { + if (postcodeData.length === 0) return null; + for (const feat of postcodeData) { + const val = feat.properties[`min_${viewFeature}`]; + if (typeof val === 'number' && !isNaN(val)) { + min = Math.min(min, val); + max = Math.max(max, val); + } + } + } else { + if (data.length === 0) return null; + for (const item of data) { + const val = item[`min_${viewFeature}`]; + if (typeof val === 'number' && !isNaN(val)) { + min = Math.min(min, val); + max = Math.max(max, val); + } + } + } + + if (min === Infinity || max === -Infinity) return null; + return [min, max]; + }, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]); + + // Color range for the legend and hex coloring + const colorRange = useMemo((): [number, number] | null => { + if (!viewFeature) return null; + const meta = features.find((f) => f.name === viewFeature); + if (!meta) return null; + if (meta.type === 'enum' && meta.values && meta.values.length > 0) { + return [0, meta.values.length - 1]; + } + if (dataRange) return dataRange; + if (activeFeature && dragValue) return dragValue; + if (meta.min != null && meta.max != null) return [meta.min, meta.max]; + return null; + }, [viewFeature, features, dataRange, activeFeature, dragValue]); + + const handleViewChange = useCallback( + ({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => { + 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 setInitialView = useCallback((view: { latitude: number; longitude: number; zoom: number }) => { + setCurrentView(view); + setZoom(view.zoom); + }, []); + + return { + data, + rawData, + postcodeData, + resolution, + bounds, + loading, + zoom, + currentView, + usePostcodeView, + colorRange, + handleViewChange, + setInitialView, + }; +} diff --git a/frontend/src/hooks/usePOIData.ts b/frontend/src/hooks/usePOIData.ts new file mode 100644 index 0000000..5dcedfd --- /dev/null +++ b/frontend/src/hooks/usePOIData.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useRef } from 'react'; +import type { Bounds, POI, POIResponse } from '../types'; +import { apiUrl, logNonAbortError } from '../lib/api'; + +const DEBOUNCE_MS = 150; + +export function usePOIData(bounds: Bounds | null, selectedCategories: Set) { + const [pois, setPois] = useState([]); + const debounceRef = useRef | null>(null); + const abortControllerRef = useRef(null); + + useEffect(() => { + if (!bounds || selectedCategories.size === 0) { + setPois([]); + return; + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(async () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + try { + const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; + const categoriesStr = Array.from(selectedCategories).join(','); + const params = new URLSearchParams({ + categories: categoriesStr, + bounds: boundsStr, + }); + const res = await fetch(apiUrl('pois', params), { + signal: abortControllerRef.current.signal, + }); + const json: POIResponse = await res.json(); + setPois(json.pois || []); + } catch (err) { + logNonAbortError('Failed to fetch POIs', err); + } + }, DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [bounds, selectedCategories]); + + return pois; +} diff --git a/frontend/src/hooks/usePaneResize.ts b/frontend/src/hooks/usePaneResize.ts new file mode 100644 index 0000000..abd04e7 --- /dev/null +++ b/frontend/src/hooks/usePaneResize.ts @@ -0,0 +1,48 @@ +import { useState, useCallback, useRef } from 'react'; + +interface PaneResizeHandlers { + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: () => void; +} + +export function usePaneResize( + initialWidth: number, + minWidth: number, + maxWidth: number, + side: 'left' | 'right' +): [number, PaneResizeHandlers] { + const [width, setWidth] = useState(initialWidth); + const draggingRef = useRef(false); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + draggingRef.current = true; + }, []); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!draggingRef.current) return; + const newWidth = + side === 'left' + ? Math.min(maxWidth, Math.max(minWidth, e.clientX)) + : Math.min(maxWidth, Math.max(minWidth, window.innerWidth - e.clientX)); + setWidth(newWidth); + }, + [side, minWidth, maxWidth] + ); + + const handlePointerUp = useCallback(() => { + draggingRef.current = false; + }, []); + + return [ + width, + { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + }, + ]; +} diff --git a/frontend/src/usePlausible.ts b/frontend/src/hooks/usePlausible.ts similarity index 100% rename from frontend/src/usePlausible.ts rename to frontend/src/hooks/usePlausible.ts diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 6979fd9..3d60bd7 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,7 +1,7 @@ import { createRoot, hydrateRoot } from 'react-dom/client'; import App from './App'; import './index.css'; -import { initPlausible } from './usePlausible'; +import { initPlausible } from './hooks/usePlausible'; initPlausible(); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9ce3231..f6326a9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,7 +4,7 @@ const INITIAL_RETRY_MS = 1000; const MAX_RETRY_MS = 10000; // Error handling utilities -export function isAbortError(error: unknown): boolean { +function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError'; } diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index 6e5fd89..2b327b6 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -1,13 +1,6 @@ import type { ViewState } from '../types'; -// ============================================================================= -// Map Bounds & Zoom -// ============================================================================= - -/** Geographic bounds constraining map panning [west, south, east, north] */ export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57]; - -/** Minimum zoom level (can't zoom out further) */ export const MAP_MIN_ZOOM = 5.5; /** Maximum zoom level for tile fetching (map extrapolates beyond this) */ @@ -21,12 +14,6 @@ export const INITIAL_VIEW_STATE: ViewState = { pitch: 0, }; -// ============================================================================= -// Zoom Thresholds -// ============================================================================= - -/** Zoom level at which we switch from H3 hexagons to postcode polygons */ -export const POSTCODE_ZOOM_THRESHOLD = 15; /** * Zoom to H3 resolution mapping thresholds. @@ -40,6 +27,8 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [ { maxZoom: 13, resolution: 9 }, { maxZoom: Infinity, resolution: 10 }, ] as const; +export const POSTCODE_ZOOM_THRESHOLD = 15; + // ============================================================================= // Color Gradients diff --git a/frontend/src/lib/features.ts b/frontend/src/lib/features.ts index 2193a91..11c0020 100644 --- a/frontend/src/lib/features.ts +++ b/frontend/src/lib/features.ts @@ -1,9 +1,4 @@ -import type { FeatureMeta } from '../types'; - -export interface FeatureGroup { - name: string; - features: FeatureMeta[]; -} +import type { FeatureMeta, FeatureGroup } from '../types'; export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[] { const groups: FeatureGroup[] = []; diff --git a/frontend/src/lib/map-utils.ts b/frontend/src/lib/map-utils.ts index 2450f24..d8853c6 100644 --- a/frontend/src/lib/map-utils.ts +++ b/frontend/src/lib/map-utils.ts @@ -35,13 +35,15 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification { } as StyleSpecification; } -export function normalizedToColor(t: number): [number, number, number] { - if (t <= 0) return FEATURE_GRADIENT[0].color; - if (t >= 1) return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color; +type GradientStop = { t: number; color: [number, number, number] }; - for (let i = 0; i < FEATURE_GRADIENT.length - 1; i++) { - const lo = FEATURE_GRADIENT[i]; - const hi = FEATURE_GRADIENT[i + 1]; +function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] { + if (t <= 0) return gradient[0].color; + if (t >= 1) return gradient[gradient.length - 1].color; + + for (let i = 0; i < gradient.length - 1; i++) { + const lo = gradient[i]; + const hi = gradient[i + 1]; if (t >= lo.t && t <= hi.t) { const frac = (t - lo.t) / (hi.t - lo.t); return [ @@ -51,26 +53,15 @@ export function normalizedToColor(t: number): [number, number, number] { ]; } } - return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color; + return gradient[gradient.length - 1].color; +} + +export function normalizedToColor(t: number): [number, number, number] { + return interpolateGradient(t, FEATURE_GRADIENT); } export function countToColor(t: number): [number, number, number] { - if (t <= 0) return DENSITY_GRADIENT[0].color; - if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color; - - for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) { - const lo = DENSITY_GRADIENT[i]; - const hi = DENSITY_GRADIENT[i + 1]; - if (t >= lo.t && t <= hi.t) { - const frac = (t - lo.t) / (hi.t - lo.t); - return [ - Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac), - Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac), - Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac), - ]; - } - } - return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color; + return interpolateGradient(t, DENSITY_GRADIENT); } export function zoomToResolution(zoom: number): number { diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts index f87496f..38e5c8a 100644 --- a/frontend/src/lib/url-state.ts +++ b/frontend/src/lib/url-state.ts @@ -1,12 +1,5 @@ import type { FeatureMeta, FeatureFilters, ViewState } from '../types'; -export const DEFAULT_VIEW: ViewState = { - longitude: -1.5, - latitude: 53.5, - zoom: 6, - pitch: 0, -}; - export function parseUrlState(): { viewState?: ViewState; filters?: FeatureFilters;