From 34a4d0ba86c559b18ef81456db80c9b098e51aba Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 4 Feb 2026 22:27:56 +0000 Subject: [PATCH] Refactor UI --- frontend/package-lock.json | 9 + frontend/package.json | 3 +- frontend/src/App.tsx | 535 +++++++++--------- frontend/src/components/AreaPane.tsx | 173 +++--- frontend/src/components/FeatureIcons.tsx | 47 ++ frontend/src/components/FeatureInfoPopup.tsx | 37 ++ frontend/src/components/Filters.tsx | 323 +++++------ frontend/src/components/HoverCard.tsx | 94 +++ frontend/src/components/InfoPopup.tsx | 19 +- frontend/src/components/Map.tsx | 255 +++++++-- frontend/src/components/POIPane.tsx | 75 +-- frontend/src/components/PostcodeSearch.tsx | 29 +- frontend/src/components/PropertiesPane.tsx | 189 ++----- frontend/src/components/ui/CheckboxList.tsx | 89 +++ frontend/src/components/ui/EmptyState.tsx | 31 + frontend/src/components/ui/IconButton.tsx | 22 + frontend/src/components/ui/Icons.tsx | 92 +++ frontend/src/components/ui/PaneHeader.tsx | 35 ++ frontend/src/components/ui/SearchInput.tsx | 23 + .../src/components/ui/SelectionButtons.tsx | 24 + frontend/src/components/ui/TabButton.tsx | 22 + frontend/src/hooks/useDebouncedFetch.ts | 81 +++ frontend/src/hooks/useInfoPopup.ts | 27 + frontend/src/hooks/useSearch.ts | 55 ++ frontend/src/index.css | 6 + frontend/src/lib/api.ts | 19 + frontend/src/lib/consts.ts | 76 +++ frontend/src/lib/features.ts | 36 ++ frontend/src/lib/format.ts | 24 + frontend/src/lib/map-utils.ts | 76 +-- frontend/src/lib/property-fields.ts | 38 ++ frontend/src/types.ts | 7 + 32 files changed, 1726 insertions(+), 845 deletions(-) create mode 100644 frontend/src/components/FeatureIcons.tsx create mode 100644 frontend/src/components/FeatureInfoPopup.tsx create mode 100644 frontend/src/components/HoverCard.tsx create mode 100644 frontend/src/components/ui/CheckboxList.tsx create mode 100644 frontend/src/components/ui/EmptyState.tsx create mode 100644 frontend/src/components/ui/IconButton.tsx create mode 100644 frontend/src/components/ui/Icons.tsx create mode 100644 frontend/src/components/ui/PaneHeader.tsx create mode 100644 frontend/src/components/ui/SearchInput.tsx create mode 100644 frontend/src/components/ui/SelectionButtons.tsx create mode 100644 frontend/src/components/ui/TabButton.tsx create mode 100644 frontend/src/hooks/useDebouncedFetch.ts create mode 100644 frontend/src/hooks/useInfoPopup.ts create mode 100644 frontend/src/hooks/useSearch.ts create mode 100644 frontend/src/lib/consts.ts create mode 100644 frontend/src/lib/features.ts create mode 100644 frontend/src/lib/property-fields.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 97cf65a..f2d19e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@deck.gl/layers": "^9.0.0", "@deck.gl/mapbox": "^9.2.6", "@deck.gl/react": "^9.0.0", + "@protomaps/basemaps": "^5.7.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.0", "class-variance-authority": "^0.7.0", @@ -1714,6 +1715,14 @@ "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==", "license": "MIT" }, + "node_modules/@protomaps/basemaps": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.0.tgz", + "integrity": "sha512-vIInnzVSxHuOcvj1BFGkCjlFxG/9a1GV23t98kGEVcPUM7aEqTnf6loUHTRJYX5eCz+WCO16N0aibr1SLg830Q==", + "bin": { + "generate_style": "src/cli.ts" + } + }, "node_modules/@puppeteer/browsers": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1ecfc7d..78b43ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "property-map-frontend", "version": "1.0.0", "scripts": { - "dev": "webpack serve --mode development --port 3030", + "dev": "webpack serve --mode development --port 3000", "build": "webpack --mode production && node scripts/prerender.mjs", "build:no-prerender": "webpack --mode production", "prerender": "node scripts/prerender.mjs", @@ -18,6 +18,7 @@ "@deck.gl/layers": "^9.0.0", "@deck.gl/mapbox": "^9.2.6", "@deck.gl/react": "^9.0.0", + "@protomaps/basemaps": "^5.7.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.0", "class-variance-authority": "^0.7.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3157f1e..7413aa8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { trackPageview } from './usePlausible'; import Map from './components/Map'; +import type { SearchedPostcode } from './components/PostcodeSearch'; import Filters from './components/Filters'; import POIPane from './components/POIPane'; import { PropertiesPane } from './components/PropertiesPane'; @@ -10,12 +11,15 @@ import DataSourcesPage from './components/DataSourcesPage'; import FAQPage from './components/FAQPage'; import HomePage from './components/HomePage'; import Header, { type Page } from './components/Header'; +import { ChevronIcon } from './components/ui/Icons'; +import { TabButton } from './components/ui/TabButton'; import type { FeatureMeta, FeatureGroup, FeatureFilters, Bounds, HexagonData, + PostcodeData, ViewChangeParams, ApiResponse, POI, @@ -26,8 +30,9 @@ import type { HexagonPropertiesResponse, HexagonStatsResponse, } from './types'; -import { fetchWithRetry, getApiBaseUrl, buildFilterString } from './lib/api'; +import { fetchWithRetry, getApiBaseUrl, buildFilterString, apiUrl, logNonAbortError } from './lib/api'; import { parseUrlState, DEFAULT_VIEW } from './lib/url-state'; +import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils'; import { useTheme } from './hooks/useTheme'; import { useUrlSync } from './hooks/useUrlSync'; @@ -53,6 +58,7 @@ export default function App() { 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); @@ -86,9 +92,11 @@ export default function App() { const poiDebounceRef = useRef | null>(null); const poiAbortControllerRef = useRef(null); - const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>( - 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); @@ -100,14 +108,11 @@ export default function App() { const [areaStats, setAreaStats] = useState(null); const [loadingAreaStats, setLoadingAreaStats] = useState(false); + const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false); + const [rightPaneCollapsed, setRightPaneCollapsed] = useState(false); + const [hoveredHexagon, setHoveredHexagon] = useState(null); - const [hoveredAreaStats, setHoveredAreaStats] = useState(null); - const [hoveredProperties, setHoveredProperties] = useState(null); - const [hoveredPropertiesTotal, setHoveredPropertiesTotal] = useState(0); - const [loadingHoveredAreaStats, setLoadingHoveredAreaStats] = useState(false); - const [hoverMode, setHoverMode] = useState(true); - const hoverAbortRef = useRef(null); - const hoverDebounceRef = useRef | null>(null); + const [searchedPostcode, setSearchedPostcode] = useState(null); const [initialLoading, setInitialLoading] = useState(true); const [activePage, setActivePage] = useState(() => { @@ -162,20 +167,6 @@ export default function App() { const viewFeature = activeFeature || pinnedFeature; const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null; - 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 (activeFeature === viewFeature && dragValue) return dragValue; - const filterVal = filters[viewFeature]; - if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number]; - if (meta.min != null && meta.max != null) return [meta.min, meta.max]; - return null; - }, [viewFeature, features, activeFeature, dragValue, filters]); - const filterRange = useMemo((): [number, number] | null => { if (!viewFeature) return null; if (activeFeature && dragValue) return dragValue; @@ -196,7 +187,7 @@ export default function App() { }; fetchWithRetry<{ groups: FeatureGroup[] }>( - `${getApiBaseUrl()}/api/features`, + apiUrl('features'), (json) => { const flat: FeatureMeta[] = json.groups.flatMap((g) => g.features.map((f) => ({ ...f, group: g.name })) @@ -209,7 +200,7 @@ export default function App() { ); fetchWithRetry( - `${getApiBaseUrl()}/api/poi-categories`, + apiUrl('poi-categories'), (json) => { setPOICategoryGroups(json.groups); poisLoaded = true; @@ -226,6 +217,8 @@ export default function App() { [filters, features] ); + const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD; + useEffect(() => { if (!bounds) return; @@ -244,25 +237,42 @@ export default function App() { 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); - if (viewFeature) { - params.set('fields', viewFeature); + 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: PostcodeData[] } = await res.json(); + setPostcodeData(json.features || []); + setRawData([]); // Clear hexagon data } else { - params.set('fields', ''); + // 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 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); - } + logNonAbortError('Failed to fetch data', err); } finally { setLoading(false); } @@ -273,10 +283,57 @@ export default function App() { clearTimeout(debounceRef.current); } }; - }, [resolution, bounds, filters, buildFilterParam, viewFeature]); + }, [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; + + // Choose the appropriate data source based on zoom level + const sourceData = usePostcodeView ? postcodeData : data; + if (sourceData.length === 0) return null; + + // Only use min_ values since that's what hexagon coloring uses + let min = Infinity; + let max = -Infinity; + for (const item of sourceData) { + 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([]); @@ -300,15 +357,13 @@ export default function App() { categories: categoriesStr, bounds: boundsStr, }); - const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, { + const res = await fetch(apiUrl('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); - } + logNonAbortError('Failed to fetch POIs', err); } }, DEBOUNCE_MS); @@ -396,16 +451,12 @@ export default function App() { if (filtersStr) params.set('filters', filtersStr); params.set('fields', name); - fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { + fetch(apiUrl('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); - } - }); + .catch((err) => logNonAbortError('Failed to fetch drag data', err)); }, [filters, features, bounds, resolution] ); @@ -446,7 +497,7 @@ export default function App() { if (fields) { params.set('fields', fields.join(',')); } - const response = await fetch(`${getApiBaseUrl()}/api/hexagon-stats?${params}`, { signal }); + const response = await fetch(apiUrl('hexagon-stats', params), { signal }); return (await response.json()) as HexagonStatsResponse; }, [filters, features] @@ -466,7 +517,7 @@ export default function App() { const filterStr = buildFilterString(filters, features); if (filterStr) params.append('filters', filterStr); - const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`); + const response = await fetch(apiUrl('hexagon-properties', params)); const data: HexagonPropertiesResponse = await response.json(); if (offset === 0) { @@ -486,99 +537,48 @@ export default function App() { ); const handleHexagonClick = useCallback( - (h3: string) => { - if (selectedHexagon?.h3 === h3) { + (id: string, isPostcode = false) => { + if (selectedHexagon?.id === id) { setSelectedHexagon(null); setProperties([]); setAreaStats(null); } else { - setSelectedHexagon({ h3, resolution }); + const type = isPostcode ? 'postcode' : 'hexagon'; + setSelectedHexagon({ id, type, resolution }); setPropertiesOffset(0); setRightPaneTab('area'); - setLoadingAreaStats(true); - fetchHexagonStats(h3, resolution) - .then((stats) => setAreaStats(stats)) - .catch((error) => { - if (error instanceof Error && error.name !== 'AbortError') { - console.error('Failed to fetch area stats:', error); - } - }) - .finally(() => setLoadingAreaStats(false)); + + if (isPostcode) { + // For postcodes, we don't have a stats API yet, so skip + setAreaStats(null); + 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] ); - const handleHexagonHover = useCallback( - (h3: string | null) => { - setHoveredHexagon(h3); - if (!hoverMode || !h3 || h3 === selectedHexagon?.h3) { - if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current); - if (hoverAbortRef.current) hoverAbortRef.current.abort(); - setHoveredAreaStats(null); - setHoveredProperties(null); - setHoveredPropertiesTotal(0); - return; - } - - if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current); - hoverDebounceRef.current = setTimeout(async () => { - if (hoverAbortRef.current) hoverAbortRef.current.abort(); - hoverAbortRef.current = new AbortController(); - const signal = hoverAbortRef.current.signal; - - try { - if (rightPaneTab === 'area') { - setLoadingHoveredAreaStats(true); - const hoverFields = Object.keys(filters); - const stats = await fetchHexagonStats( - h3, - resolution, - signal, - hoverFields.length > 0 ? hoverFields : undefined - ); - if (!signal.aborted) setHoveredAreaStats(stats); - } else if (rightPaneTab === 'properties') { - const params = new URLSearchParams({ - h3, - resolution: resolution.toString(), - limit: '3', - offset: '0', - }); - const filterStr = buildFilterString(filters, features); - if (filterStr) params.append('filters', filterStr); - const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, { - signal, - }); - const data: HexagonPropertiesResponse = await response.json(); - if (!signal.aborted) { - setHoveredProperties(data.properties); - setHoveredPropertiesTotal(data.total); - } - } - } catch (error) { - if (error instanceof Error && error.name !== 'AbortError') { - console.error('Failed to fetch hover data:', error); - } - } finally { - if (!signal.aborted) setLoadingHoveredAreaStats(false); - } - }, DEBOUNCE_MS); - }, - [hoverMode, selectedHexagon, rightPaneTab, resolution, filters, features, fetchHexagonStats] - ); + const handleHexagonHover = useCallback((h3: string | null) => { + setHoveredHexagon(h3); + }, []); const handleViewPropertiesFromArea = useCallback(() => { - if (selectedHexagon) { + if (selectedHexagon && selectedHexagon.type === 'hexagon') { setRightPaneTab('properties'); setPropertiesOffset(0); - fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0); + fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); } }, [selectedHexagon, fetchHexagonProperties]); const handleLoadMoreProperties = useCallback(() => { - if (selectedHexagon) { - fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset); + if (selectedHexagon && selectedHexagon.type === 'hexagon') { + fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset); } }, [selectedHexagon, propertiesOffset, fetchHexagonProperties]); @@ -593,6 +593,8 @@ export default function App() {
)} - { - navigateTo('data-sources', slug, featureName); - }} - openInfoFeature={pendingInfoFeature} - onClearOpenInfoFeature={() => setPendingInfoFeature(null)} - /> +
+ {leftPaneCollapsed ? ( + + ) : ( +
+ { + navigateTo('data-sources', slug, featureName); + }} + openInfoFeature={pendingInfoFeature} + onClearOpenInfoFeature={() => setPendingInfoFeature(null)} + onCollapse={() => setLeftPaneCollapsed(true)} + /> +
+ )} +
{loading && (
@@ -704,115 +731,107 @@ export default function App() { )} navigateTo('data-sources')} />
-
-
+
+ {rightPaneCollapsed ? ( - - -
+ ) : ( + <> +
+ + setRightPaneTab('area')} + /> + 0 ? propertiesTotal : undefined} + isActive={rightPaneTab === 'properties'} + onClick={() => setRightPaneTab('properties')} + /> + 0 ? pois.length : undefined} + isActive={rightPaneTab === 'pois'} + onClick={() => setRightPaneTab('pois')} + /> +
-
- {rightPaneTab === 'area' ? ( - { - const hexId = - hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 - ? hoveredHexagon - : selectedHexagon?.h3; - 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} - /> - ) : rightPaneTab === 'properties' ? ( - navigateTo('data-sources', slug)} - isHoveredPreview={ - !!( - hoverMode && - hoveredProperties && - hoveredHexagon && - hoveredHexagon !== selectedHexagon?.h3 - ) - } - hoverMode={hoverMode} - onHoverModeChange={setHoverMode} - /> - ) : ( - navigateTo('data-sources', slug)} - /> - )} -
+
+ {rightPaneTab === 'area' ? ( + d.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)} + /> + )} +
+ + )}
)} diff --git a/frontend/src/components/AreaPane.tsx b/frontend/src/components/AreaPane.tsx index 88ba313..44ed8d0 100644 --- a/frontend/src/components/AreaPane.tsx +++ b/frontend/src/components/AreaPane.tsx @@ -1,37 +1,28 @@ -import { useMemo } from 'react'; -import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types'; +import { useMemo, useState } from 'react'; +import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData } from '../types'; import type { HexagonLocation } from '../lib/external-search'; -import { formatValue } from '../lib/format'; +import { formatValue, calculateHistogramMean } from '../lib/format'; +import { groupFeaturesByCategory } from '../lib/features'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import EnumBarChart from './EnumBarChart'; import ExternalSearchLinks from './ExternalSearchLinks'; +import { InfoIcon, CloseIcon } from './ui/Icons'; +import { IconButton } from './ui/IconButton'; +import { FeatureInfoPopup } from './FeatureInfoPopup'; +import { PaneEmptyState } from './ui/EmptyState'; interface AreaPaneProps { stats: HexagonStatsResponse | null; globalFeatures: FeatureMeta[]; loading: boolean; hexagonId: string | null; - isHoveredPreview: boolean; - hoverMode: boolean; - onHoverModeChange: (enabled: boolean) => void; + isPostcode?: boolean; + postcodeData?: PostcodeData | null; onViewProperties: () => void; onClose: () => void; hexagonLocation: HexagonLocation | null; filters: FeatureFilters; -} - -function groupFeatures(globalFeatures: FeatureMeta[]): { name: string; features: FeatureMeta[] }[] { - const groups: { name: string; features: FeatureMeta[] }[] = []; - const seen = new Set(); - for (const feature of globalFeatures) { - const groupName = feature.group || 'Other'; - if (!seen.has(groupName)) { - seen.add(groupName); - groups.push({ name: groupName, features: [] }); - } - groups.find((group) => group.name === groupName)!.features.push(feature); - } - return groups; + onNavigateToSource?: (slug: string, featureName: string) => void; } export default function AreaPane({ @@ -39,15 +30,18 @@ export default function AreaPane({ globalFeatures, loading, hexagonId, - isHoveredPreview, - hoverMode, - onHoverModeChange, + isPostcode = false, + postcodeData, onViewProperties, onClose, hexagonLocation, filters, + onNavigateToSource, }: AreaPaneProps) { - const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]); + // For postcodes, use local data for count + const propertyCount = isPostcode && postcodeData ? postcodeData.count : stats?.count; + const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]); + const [infoFeature, setInfoFeature] = useState(null); const numericByName = useMemo(() => { if (!stats) return new Map(); @@ -65,78 +59,31 @@ export default function AreaPane({ ); if (!hexagonId) { - return ( -
- Click a hexagon to view area statistics -
- ); + return ; } return (
-
-

Area Statistics

- {isHoveredPreview && ( - - Preview - +
+

+ {isPostcode ? hexagonId : 'Area Statistics'} +

+ {isPostcode && ( + Postcode )}
-
- - -
+ + +
- {stats && ( + {propertyCount != null && (

- {stats.count.toLocaleString()} properties + {propertyCount.toLocaleString()} properties

)} - {stats && ( + {!isPostcode && stats && ( + )} +
{formatValue(numericStats.mean)} @@ -231,9 +179,20 @@ export default function AreaPane({ key={feature.name} className="bg-warm-50 dark:bg-warm-800 rounded p-2" > - - {feature.name} - +
+ + {feature.name} + + {feature.detail && ( + + )} +
); @@ -248,6 +207,14 @@ export default function AreaPane({
) : null}
+ + {infoFeature && ( + setInfoFeature(null)} + onNavigateToSource={onNavigateToSource} + /> + )} ); } diff --git a/frontend/src/components/FeatureIcons.tsx b/frontend/src/components/FeatureIcons.tsx new file mode 100644 index 0000000..28ede75 --- /dev/null +++ b/frontend/src/components/FeatureIcons.tsx @@ -0,0 +1,47 @@ +import type { FeatureMeta } from '../types'; +import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from './ui/Icons'; +import { IconButton } from './ui/IconButton'; + +// Re-export icons for backwards compatibility +export { EyeIcon, InfoIcon, CloseIcon as RemoveIcon } from './ui/Icons'; + +interface FeatureActionsProps { + feature: FeatureMeta; + isPinned: boolean; + onTogglePin: (name: string) => void; + onShowInfo?: (feature: FeatureMeta) => void; + onRemove?: (name: string) => void; + onAdd?: (name: string) => void; +} + +export function FeatureActions({ + feature, + isPinned, + onTogglePin, + onShowInfo, + onRemove, + onAdd, +}: FeatureActionsProps) { + return ( +
+ {feature.detail && onShowInfo && ( + onShowInfo(feature)} title="Feature info"> + + + )} + onTogglePin(feature.name)} title={isPinned ? 'Unpin color view' : 'Color map by this feature'} active={isPinned}> + + + {onAdd && ( + onAdd(feature.name)} title="Add filter"> + + + )} + {onRemove && ( + onRemove(feature.name)} title="Remove filter"> + + + )} +
+ ); +} diff --git a/frontend/src/components/FeatureInfoPopup.tsx b/frontend/src/components/FeatureInfoPopup.tsx new file mode 100644 index 0000000..a710e34 --- /dev/null +++ b/frontend/src/components/FeatureInfoPopup.tsx @@ -0,0 +1,37 @@ +import type { FeatureMeta } from '../types'; +import InfoPopup from './InfoPopup'; + +interface FeatureInfoPopupProps { + feature: FeatureMeta; + onClose: () => void; + onNavigateToSource?: (slug: string, featureName: string) => void; +} + +export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: FeatureInfoPopupProps) { + return ( + { + onNavigateToSource(feature.source!, feature.name); + onClose(); + }, + } + : undefined + } + > + {feature.description && ( +

{feature.description}

+ )} + {feature.detail && ( +

+ {feature.detail} +

+ )} +
+ ); +} diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx index 33176ea..547d265 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/Filters.tsx @@ -1,9 +1,17 @@ import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { Slider } from './ui/slider'; import { Label } from './ui/label'; +import { SearchInput } from './ui/SearchInput'; +import { SelectionButtons } from './ui/SelectionButtons'; +import { ChevronIcon, FilterIcon, LightbulbIcon } from './ui/Icons'; +import { IconButton } from './ui/IconButton'; +import { EmptyState } from './ui/EmptyState'; import type { FeatureMeta, FeatureFilters } from '../types'; import { formatFilterValue } from '../lib/format'; +import { groupFeaturesByCategory } from '../lib/features'; import InfoPopup from './InfoPopup'; +import { FeatureInfoPopup } from './FeatureInfoPopup'; +import { FeatureActions } from './FeatureIcons'; interface FiltersProps { features: FeatureMeta[]; @@ -18,27 +26,15 @@ interface FiltersProps { onDragChange: (value: [number, number]) => void; onDragEnd: () => void; zoom: number; + itemCount: number; + usePostcodeView: boolean; pinnedFeature: string | null; onTogglePin: (name: string) => void; onCancelPin: () => void; onNavigateToSource?: (slug: string, featureName: string) => void; openInfoFeature?: string | null; onClearOpenInfoFeature?: () => void; -} - -function EyeIcon({ filled, className }: { filled: boolean; className?: string }) { - return ( - - - - - ); + onCollapse?: () => void; } function FeatureBrowser({ @@ -77,32 +73,12 @@ function FeatureBrowser({ return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower)); }, [availableFeatures, search]); - const grouped = useMemo(() => { - const groups: { name: string; features: FeatureMeta[] }[] = []; - const seen = new Map(); - for (const f of filtered) { - const g = f.group || 'Other'; - let arr = seen.get(g); - if (!arr) { - arr = []; - seen.set(g, arr); - groups.push({ name: g, features: arr }); - } - arr.push(f); - } - return groups; - }, [filtered]); + const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]); return ( <>
- setSearch(e.target.value)} - className="w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400" - /> +
{grouped.map((group) => ( @@ -125,86 +101,31 @@ function FeatureBrowser({ )}
-
- {f.detail && ( - - )} - - -
+ ); })} ))} {grouped.length === 0 && ( -
- {search ? 'No matching features' : 'All features are active'} -
+ )} {infoFeature && ( - setInfoFeature(null)} - sourceLink={ - infoFeature.source && onNavigateToSource - ? { - label: 'View data source', - onClick: () => { - onNavigateToSource(infoFeature.source!, infoFeature.name); - setInfoFeature(null); - }, - } - : undefined - } - > - {infoFeature.description && ( -

- {infoFeature.description} -

- )} - {infoFeature.detail && ( -

- {infoFeature.detail} -

- )} -
+ onNavigateToSource={onNavigateToSource} + /> )} ); @@ -223,12 +144,15 @@ export default memo(function Filters({ onDragChange, onDragEnd, zoom, + itemCount, + usePostcodeView, pinnedFeature, onTogglePin, onCancelPin: _onCancelPin, onNavigateToSource, openInfoFeature, onClearOpenInfoFeature, + onCollapse, }: FiltersProps) { const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); @@ -236,6 +160,8 @@ export default memo(function Filters({ const containerRef = useRef(null); const [splitFraction, setSplitFraction] = useState(0.65); const draggingRef = useRef(false); + const [showPhilosophy, setShowPhilosophy] = useState(false); + const [activeInfoFeature, setActiveInfoFeature] = useState(null); const handleSeparatorPointerDown = useCallback((e: React.PointerEvent) => { e.preventDefault(); @@ -258,8 +184,22 @@ export default memo(function Filters({ return (
+
+ {onCollapse && ( + + + + )} + +
@@ -272,32 +212,19 @@ export default memo(function Filters({ )}
- Zoom {zoom.toFixed(1)} + + {itemCount.toLocaleString()} {usePostcodeView ? 'postcodes' : 'hexagons'} · z + {zoom.toFixed(1)} +
{enabledFeatureList.length === 0 && ( -
- - - - - No active filters - - - Browse features below and click + to add a filter - -
+ } + title="No active filters" + description="Browse features below and click + to add a filter" + /> )} {enabledFeatureList.map((feature) => { @@ -311,41 +238,19 @@ export default memo(function Filters({ >
-
- - -
-
-
- - +
+ onFilterChange(feature.name, [...allValues])} + onSelectNone={() => onFilterChange(feature.name, [])} + className="mb-1" + />
{allValues.map((val) => (
+ + {showPhilosophy && ( + setShowPhilosophy(false)}> +
+
+

+ Be intentional, not reactive +

+

+ Your future home isn't a box of cereal you grab because it's on sale. Don't let a + seemingly good deal turn into lifelong regret. Instead of waiting for listings to + appear, define what you actually want and go find it. +

+
+ +
+

+ See the full picture +

+

+ Current listings show only a fraction of the market. There are too few to give you a + complete picture, yet too many to evaluate one by one. We aggregate millions of + historical sales so you can understand what's truly available in any area. +

+
+ +
+

+ Your priorities, your filters +

+

+ We all care about different things. Some want peace and quiet; others want to be + near the action. Use our filters to define exactly what matters to you and discover + postcodes that match. +

+
+ +
+

+ Find the right place, not just the right listing +

+

+ The best areas to live don't always have properties listed right now. We help you + identify where you should be looking, so when something does come up, you're ready. +

+
+ +
+

+ Know what's possible +

+

+ We'd rather tell you upfront if your expectations are unrealistic than have you + spend months searching for something that doesn't exist. +

+
+
+
+ )} + + {activeInfoFeature && ( + setActiveInfoFeature(null)} + onNavigateToSource={onNavigateToSource} + /> + )}
); }); diff --git a/frontend/src/components/HoverCard.tsx b/frontend/src/components/HoverCard.tsx new file mode 100644 index 0000000..08a1c40 --- /dev/null +++ b/frontend/src/components/HoverCard.tsx @@ -0,0 +1,94 @@ +import { memo } from 'react'; +import type { HexagonData, PostcodeData, FeatureFilters } from '../types'; +import { formatValue } from '../lib/format'; + +interface HoverCardProps { + x: number; + y: number; + id: string; + isPostcode: boolean; + data: HexagonData | PostcodeData | null; + filters: FeatureFilters; +} + +export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) { + const activeFilterNames = Object.keys(filters); + + // Get key stats to show from local data (min_ values) + const getDisplayStats = () => { + if (!data) return []; + + const results: { name: string; value: string }[] = []; + + // Show stats for active filters (up to 4) + for (const name of activeFilterNames.slice(0, 4)) { + const minVal = data[`min_${name}`]; + if (minVal != null && typeof minVal === 'number') { + results.push({ name, value: formatValue(minVal) }); + } + } + + return results; + }; + + const displayStats = getDisplayStats(); + const count = data?.count; + + return ( +
+ {/* Arrow */} +
+ +
+ {/* Header */} +
+ + {isPostcode ? id : 'Area'} + +
+ + {/* Property count */} + {count != null && ( +
+ {count.toLocaleString()} {count === 1 ? 'property' : 'properties'} +
+ )} + + {/* Quick stats */} + {displayStats.length > 0 && ( +
+ {displayStats.map((stat) => ( +
+ {stat.name} + + {stat.value} + +
+ ))} +
+ )} + + {/* Hint */} + {data && ( +
+ Click for details +
+ )} +
+
+ ); +}); diff --git a/frontend/src/components/InfoPopup.tsx b/frontend/src/components/InfoPopup.tsx index 7be3f2b..7fc4e16 100644 --- a/frontend/src/components/InfoPopup.tsx +++ b/frontend/src/components/InfoPopup.tsx @@ -1,5 +1,7 @@ import { useRef, useCallback, type ReactNode } from 'react'; import { useClickOutside } from '../hooks/useClickOutside'; +import { CloseIcon } from './ui/Icons'; +import { IconButton } from './ui/IconButton'; interface InfoPopupProps { title: string; @@ -25,20 +27,9 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info >

{title}

- + + +
{children} {sourceLink && ( diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 1ecda34..cd01526 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -3,10 +3,18 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; -import { IconLayer } from '@deck.gl/layers'; +import { IconLayer, PolygonLayer } from '@deck.gl/layers'; import type { PickingInfo } from '@deck.gl/core'; import 'maplibre-gl/dist/maplibre-gl.css'; -import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types'; +import type { + HexagonData, + PostcodeData, + ViewState, + ViewChangeParams, + Bounds, + POI, + FeatureMeta, +} from '../types'; import { GRADIENT, normalizedToColor, @@ -14,11 +22,14 @@ import { zoomToResolution, getBoundsFromViewState, emojiToTwemojiUrl, - MAP_STYLE_LIGHT, - MAP_STYLE_DARK, + getMapStyle, + POSTCODE_ZOOM_THRESHOLD, } from '../lib/map-utils'; -import PostcodeSearch from './PostcodeSearch'; +import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../lib/consts'; +import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch'; import MapLegend from './MapLegend'; +import HoverCard from './HoverCard'; +import type { FeatureFilters } from '../types'; /** Convert POI id (e.g. "n12345") to OpenStreetMap URL */ function osmIdToUrl(id: string): string | null { @@ -30,6 +41,8 @@ function osmIdToUrl(id: string): string | null { interface MapProps { data: HexagonData[]; + postcodeData: PostcodeData[]; + usePostcodeView: boolean; pois: POI[]; onViewChange: (params: ViewChangeParams) => void; viewFeature: string | null; @@ -40,19 +53,16 @@ interface MapProps { features: FeatureMeta[]; selectedHexagonId: string | null; hoveredHexagonId: string | null; - onHexagonClick: (h3: string) => void; - onHexagonHover: (h3: string | null) => void; + onHexagonClick: (id: string, isPostcode?: boolean) => void; + onHexagonHover: (h3: string | null, x?: number, y?: number) => void; initialViewState?: ViewState; theme?: 'light' | 'dark'; screenshotMode?: boolean; + filters?: FeatureFilters; + searchedPostcode?: SearchedPostcode | null; + onPostcodeSearched?: (postcode: SearchedPostcode | null) => void; } -const INITIAL_VIEW: ViewState = { - longitude: -1.5, - latitude: 53.5, - zoom: 6, - pitch: 0, -}; interface Dimensions { width: number; @@ -81,6 +91,8 @@ function DeckOverlay({ export default memo(function Map({ data, + postcodeData, + usePostcodeView, pois, onViewChange, viewFeature, @@ -96,10 +108,14 @@ export default memo(function Map({ initialViewState, theme = 'light', screenshotMode = false, + filters = {}, + searchedPostcode, + onPostcodeSearched, }: MapProps) { const containerRef = useRef(null); - const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW); + const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW_STATE); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); useEffect(() => { const container = containerRef.current; @@ -119,17 +135,11 @@ export default memo(function Map({ useEffect(() => { if (dimensions.width === 0 || dimensions.height === 0) return; - const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); + // Send exact viewport bounds - server will filter to only return + // hexagons/postcodes that intersect this precise AABB + const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); const resolution = zoomToResolution(viewState.zoom); - const QUANT = 0.01; - const bounds: Bounds = { - south: Math.floor(raw.south / QUANT) * QUANT, - west: Math.floor(raw.west / QUANT) * QUANT, - north: Math.ceil(raw.north / QUANT) * QUANT, - east: Math.ceil(raw.east / QUANT) * QUANT, - }; - onViewChange({ resolution, bounds, @@ -153,30 +163,17 @@ export default memo(function Map({ const handleMapLoad = useCallback( (evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { const map = evt.target; - if (themeRef.current === 'light') { - for (const layer of map.getStyle().layers || []) { - if (layer.type !== 'symbol') continue; - map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)'); - map.setPaintProperty(layer.id, 'text-halo-width', 2); - map.setPaintProperty(layer.id, 'text-color', '#222'); - } - for (const layer of map.getStyle().layers || []) { - if (layer.id === 'water' || layer.id.startsWith('water')) { - map.setPaintProperty(layer.id, 'fill-color', '#6baed6'); - } - } - } + // Hide buildings to reduce visual clutter over hexagons try { - map.setLayoutProperty('building', 'visibility', 'none'); - map.setLayoutProperty('building-top', 'visibility', 'none'); + map.setLayoutProperty('buildings', 'visibility', 'none'); } catch { - // layers may not exist in dark style + // layer may not exist } }, [] ); - const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT; + const mapStyle = useMemo(() => getMapStyle(theme), [theme]); const [popupInfo, setPopupInfo] = useState<{ x: number; @@ -244,9 +241,11 @@ export default memo(function Map({ const onHexagonHoverRef = useRef(onHexagonHover); onHexagonHoverRef.current = onHexagonHover; const handleHexagonHover = useCallback((info: PickingInfo) => { - if (info.object && 'h3' in info.object) { - onHexagonHoverRef.current(info.object.h3); + if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) { + setHoverPosition({ x: info.x, y: info.y }); + onHexagonHoverRef.current(info.object.h3, info.x, info.y); } else { + setHoverPosition(null); onHexagonHoverRef.current(null); } }, []); @@ -257,7 +256,54 @@ export default memo(function Map({ handlePoiHoverRef.current(info); }, []); + // Compute count range for postcodes (similar to hexagons) + const postcodeCountRange = useMemo(() => { + if (postcodeData.length === 0) return { min: 0, max: 1 }; + let min = Infinity; + let max = -Infinity; + for (const d of postcodeData) { + const c = d.count as number; + if (c < min) min = c; + if (c > max) max = c; + } + if (min === max) return { min, max: min + 1 }; + return { min, max }; + }, [postcodeData]); + + const postcodeCountRangeRef = useRef(postcodeCountRange); + postcodeCountRangeRef.current = postcodeCountRange; + + // Track selected/hovered postcode for styling + const [selectedPostcode, setSelectedPostcode] = useState(null); + const [hoveredPostcode, setHoveredPostcode] = useState(null); + const selectedPostcodeRef = useRef(selectedPostcode); + selectedPostcodeRef.current = selectedPostcode; + const hoveredPostcodeRef = useRef(hoveredPostcode); + hoveredPostcodeRef.current = hoveredPostcode; + + const handlePostcodeClick = useCallback((info: PickingInfo) => { + if (info.object && 'postcode' in info.object) { + const pc = info.object.postcode; + setSelectedPostcode((prev) => (prev === pc ? null : pc)); + // Also trigger the hexagon click handler with the postcode as identifier + onHexagonClickRef.current(pc, true); + } + }, []); + + const handlePostcodeHoverCallback = useCallback((info: PickingInfo) => { + if (info.object && 'postcode' in info.object && info.x !== undefined && info.y !== undefined) { + setHoveredPostcode(info.object.postcode); + setHoverPosition({ x: info.x, y: info.y }); + onHexagonHoverRef.current(info.object.postcode, info.x, info.y); + } else { + setHoveredPostcode(null); + setHoverPosition(null); + onHexagonHoverRef.current(null); + } + }, []); + const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`; + const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`; const hexLayer = useMemo( () => @@ -321,11 +367,76 @@ export default memo(function Map({ onClick: handleHexagonClick, onHover: handleHexagonHover, // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps - beforeId: 'waterway_label', + beforeId: 'water_waterway_label', }), [data, colorTrigger, handleHexagonClick, handleHexagonHover] ); + const postcodeLayer = useMemo( + () => + new PolygonLayer({ + id: 'postcode-polygons', + data: postcodeData, + getPolygon: (d) => d.vertices, + getFillColor: (d) => { + const vf = viewFeatureRef.current; + const clr = colorRangeRef.current; + const fr = filterRangeRef.current; + const cfm = colorFeatureMetaRef.current; + if (vf && clr && cfm) { + const val = d[`min_${vf}`]; + if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; + if (fr) { + const minVal = d[`min_${vf}`] as number; + const maxVal = d[`max_${vf}`] as number; + if (maxVal < fr[0] || minVal > fr[1]) { + return [180, 180, 180, 60] as [number, number, number, number]; + } + } + const range = clr[1] - clr[0]; + if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number]; + const t = ((val as number) - clr[0]) / range; + const rgb = normalizedToColor(Math.max(0, Math.min(1, t))); + return [...rgb, 200] as [number, number, number, number]; + } + const cr = postcodeCountRangeRef.current; + const c = d.count as number; + const t = (c - cr.min) / (cr.max - cr.min); + return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [ + number, + number, + number, + number, + ]; + }, + getLineColor: (d) => { + if (d.postcode === selectedPostcodeRef.current) + return [255, 255, 255, 255] as [number, number, number, number]; + if (d.postcode === hoveredPostcodeRef.current) + return [29, 228, 195, 200] as [number, number, number, number]; + return [100, 100, 100, 150] as [number, number, number, number]; + }, + getLineWidth: (d) => { + if (d.postcode === selectedPostcodeRef.current) return 3; + if (d.postcode === hoveredPostcodeRef.current) return 2; + return 1; + }, + lineWidthUnits: 'pixels', + updateTriggers: { + getFillColor: [postcodeColorTrigger], + getLineColor: [postcodeColorTrigger], + getLineWidth: [postcodeColorTrigger], + }, + extruded: false, + pickable: true, + onClick: handlePostcodeClick, + onHover: handlePostcodeHoverCallback, + // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps + beforeId: 'water_waterway_label', + }), + [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback] + ); + const poiLayer = useMemo( () => new IconLayer({ @@ -346,7 +457,43 @@ export default memo(function Map({ [pois, stablePoiHover] ); - const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]); + // Check if the searched postcode has data (passes current filters) + const searchedPostcodeHasData = useMemo(() => { + if (!searchedPostcode) return false; + return postcodeData.some((d) => d.postcode === searchedPostcode.postcode); + }, [searchedPostcode, postcodeData]); + + // Highlight layer for searched postcode + const searchedPostcodeHighlightLayer = useMemo(() => { + if (!searchedPostcode) return null; + const hasData = searchedPostcodeHasData; + // Use different layers for dashed vs solid lines + return new PolygonLayer<{ vertices: [number, number][] }>({ + id: 'searched-postcode-highlight', + data: [{ vertices: searchedPostcode.vertices }], + getPolygon: (d) => d.vertices, + // Transparent fill - just show outline + getFillColor: hasData + ? [29, 228, 195, 40] // teal tint when has data + : [255, 180, 0, 30], // orange tint when filtered out + getLineColor: hasData + ? [29, 228, 195, 255] // solid teal when has data + : [255, 180, 0, 200], // orange when filtered out (no matching properties) + getLineWidth: hasData ? 4 : 3, + lineWidthUnits: 'pixels', + stroked: true, + filled: true, + pickable: false, + }); + }, [searchedPostcode, searchedPostcodeHasData]); + + const layers = useMemo(() => { + const baseLayers = usePostcodeView ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]; + if (searchedPostcodeHighlightLayer) { + return [...baseLayers, searchedPostcodeHighlightLayer]; + } + return baseLayers; + }, [usePostcodeView, hexLayer, postcodeLayer, poiLayer, searchedPostcodeHighlightLayer]); return (
@@ -362,8 +509,8 @@ export default memo(function Map({ touchPitch={false} keyboard={true} pitchWithRotate={false} - minZoom={5} - maxBounds={[-12, 49, 4, 62]} + minZoom={MAP_MIN_ZOOM} + maxBounds={MAP_BOUNDS} > @@ -378,7 +525,7 @@ export default memo(function Map({
) : ( <> - + {viewSource === 'eye' && viewFeature && (
@@ -434,6 +581,20 @@ export default memo(function Map({ )}
)} + {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( + d.postcode === hoveredHexagonId) || null + : data.find((d) => d.h3 === hoveredHexagonId) || null + } + filters={filters} + /> + )} )}
diff --git a/frontend/src/components/POIPane.tsx b/frontend/src/components/POIPane.tsx index 584a74d..a72091a 100644 --- a/frontend/src/components/POIPane.tsx +++ b/frontend/src/components/POIPane.tsx @@ -2,6 +2,10 @@ import { useState, useRef, useCallback } from 'react'; import type { POICategoryGroup } from '../types'; import { useClickOutside } from '../hooks/useClickOutside'; import InfoPopup from './InfoPopup'; +import { SearchInput } from './ui/SearchInput'; +import { SelectionButtons } from './ui/SelectionButtons'; +import { InfoIcon, ChevronIcon } from './ui/Icons'; +import { IconButton } from './ui/IconButton'; interface POIPaneProps { groups: POICategoryGroup[]; @@ -93,22 +97,9 @@ export default function POIPane({

Points of Interest

- + setShowInfo(true)} title="Data source info"> + +
{showInfo && ( @@ -148,40 +139,22 @@ export default function POIPane({ ? 'All categories' : `${selectedCount} selected`} - - - + {dropdownOpen && (
-
- - | - +
+
- setSearchTerm(e.target.value)} - className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-navy-700 rounded bg-white dark:bg-navy-950 dark:text-warm-200 dark:placeholder-warm-500" + onChange={setSearchTerm} + placeholder="Search categories..." />
@@ -198,21 +171,9 @@ export default function POIPane({