From 5f311233e4b086feac3db25305a6db5a43b8e8c5 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 1 Feb 2026 11:07:58 +0000 Subject: [PATCH] Update UI --- frontend/src/App.tsx | 446 +++++++++++++------- frontend/src/components/DataSourcesPage.tsx | 23 +- frontend/src/components/Filters.tsx | 114 +++-- frontend/src/components/HomePage.tsx | 44 +- frontend/src/components/Map.tsx | 242 +++++------ frontend/src/components/POIPane.tsx | 135 +++++- frontend/src/components/PropertiesPane.tsx | 32 +- frontend/src/index.css | 14 + frontend/src/index.html | 2 +- frontend/src/types.ts | 19 +- 10 files changed, 663 insertions(+), 408 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 62f1a8c..ce7963c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,11 @@ import Filters from './components/Filters'; import POIPane from './components/POIPane'; import { PropertiesPane } from './components/PropertiesPane'; import DataSources from './components/DataSources'; +import DataSourcesPage from './components/DataSourcesPage'; +import HomePage from './components/HomePage'; import type { FeatureMeta, + FeatureGroup, FeatureFilters, Bounds, HexagonData, @@ -14,6 +17,7 @@ import type { POI, POIResponse, POICategoriesResponse, + POICategoryGroup, ViewState, Property, HexagonPropertiesResponse, @@ -171,7 +175,15 @@ function stateToParams( // --- Header --- -function Header() { +type Page = 'home' | 'dashboard' | 'data-sources'; + +function Header({ + activePage, + onPageChange, +}: { + activePage: Page; + onPageChange: (page: Page) => void; +}) { const [copied, setCopied] = useState(false); const handleShare = useCallback(() => { @@ -181,60 +193,85 @@ function Header() { }); }, []); + const tabClass = (page: Page) => + `px-3 py-1.5 rounded text-sm transition-colors ${ + activePage === page + ? 'bg-navy-700 font-semibold' + : 'text-warm-300 hover:bg-navy-800 hover:text-white' + }`; + return ( -
-
- +
+ +
- + {activePage === 'dashboard' && ( + + )}
); } @@ -249,13 +286,16 @@ export default function App() { const [filters, setFilters] = useState(urlState.filters || {}); const [activeFeature, setActiveFeature] = useState(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null); + const [pinnedFeature, setPinnedFeature] = useState(null); const [rawData, setRawData] = useState([]); + const [dragData, setDragData] = useState(null); const [resolution, setResolution] = useState(8); const [bounds, setBounds] = useState(null); const [loading, setLoading] = useState(false); const [zoom, setZoom] = useState(urlState.viewState?.zoom || DEFAULT_VIEW.zoom); const debounceRef = useRef | null>(null); const abortControllerRef = useRef(null); + const dragAbortRef = useRef(null); // View state for URL serialization const [currentView, setCurrentView] = useState<{ @@ -277,7 +317,7 @@ export default function App() { // POI state const [pois, setPois] = useState([]); - const [poiCategories, setPOICategories] = useState([]); + const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [selectedPOICategories, setSelectedPOICategories] = useState>( urlState.poiCategories || new Set() ); @@ -293,10 +333,31 @@ export default function App() { const [propertiesOffset, setPropertiesOffset] = useState(0); const [loadingProperties, setLoadingProperties] = useState(false); const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois'); + const [activePage, setActivePage] = useState('home'); // Derive enabled features from filter keys const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); + // Derive view feature: active drag takes priority over pinned + const viewFeature = activeFeature || pinnedFeature; + const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null; + // Color range: always the feature's full slider range from metadata + const colorRange = useMemo((): [number, number] | null => { + if (!viewFeature) return null; + const meta = features.find((f) => f.name === viewFeature); + if (meta?.min != null && meta?.max != null) return [meta.min, meta.max]; + return null; + }, [viewFeature, features]); + + // Filter range: current drag or committed filter values, used for gray-out + const filterRange = useMemo((): [number, number] | null => { + if (!viewFeature) return null; + if (activeFeature && dragValue) return dragValue; + const filterVal = filters[viewFeature]; + if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number]; + return null; + }, [viewFeature, activeFeature, dragValue, filters]); + // --- URL sync --- const urlDebounceRef = useRef | null>(null); @@ -326,20 +387,40 @@ export default function App() { useEffect(() => { fetch(`${getApiBaseUrl()}/api/features`) .then((res) => res.json()) - .then((json: { features: FeatureMeta[] }) => { - setFeatures(json.features); + .then((json: { groups: FeatureGroup[] }) => { + // Flatten grouped response into a flat feature list with group annotation + const flat: FeatureMeta[] = json.groups.flatMap((g) => + g.features.map((f) => ({ ...f, group: g.name })) + ); + setFeatures(flat); }) .catch((err) => console.error('Failed to fetch features:', err)); fetch(`${getApiBaseUrl()}/api/poi-categories`) .then((res) => res.json()) .then((json: POICategoriesResponse) => { - setPOICategories(json.categories); + setPOICategoryGroups(json.groups); }) .catch((err) => console.error('Failed to fetch POI categories:', err)); }, []); - // Debounced fetch when resolution/bounds/filters change + // Build filter query string helper + const buildFilterParam = useCallback((): string => { + const filterEntries = Object.entries(filters); + if (filterEntries.length === 0) return ''; + return filterEntries + .map(([name, value]) => { + const meta = features.find((f) => f.name === name); + if (meta?.type === 'enum') { + return `${name}:${(value as string[]).join('|')}`; + } + const [min, max] = value as [number, number]; + return `${name}:${min}:${max}`; + }) + .join(','); + }, [filters, features]); + + // Debounced fetch when resolution/bounds/filters change — always fetch hexagons useEffect(() => { if (!bounds) return; @@ -356,25 +437,13 @@ export default function App() { setLoading(true); try { const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; + const filtersStr = buildFilterParam(); + const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr, }); - // Build filters param: numeric=name:min:max, enum=name:val1|val2 - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filtersStr = filterEntries - .map(([name, value]) => { - const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.set('filters', filtersStr); - } + if (filtersStr) params.set('filters', filtersStr); const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { signal: abortControllerRef.current.signal, }); @@ -394,10 +463,11 @@ export default function App() { clearTimeout(debounceRef.current); } }; - }, [resolution, bounds, filters]); + }, [resolution, bounds, filters, buildFilterParam]); - // Data passed directly to Map — visual filtering is done via color/opacity in the layer - const data = rawData; + // During slider drag, use the expanded dataset (without active feature filter) + // so both narrowing and expanding are visible. Otherwise use server-filtered data. + const data = dragData ?? rawData; // Fetch POIs when bounds or selected categories change useEffect(() => { @@ -444,7 +514,13 @@ export default function App() { const prevBoundsRef = useRef(''); const handleViewChange = useCallback( - ({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => { + ({ + resolution: newRes, + bounds: newBounds, + zoom: newZoom, + latitude, + longitude, + }: ViewChangeParams) => { // Only update bounds/resolution when quantized values actually change const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`; if (boundsKey !== prevBoundsRef.current) { @@ -471,12 +547,9 @@ export default function App() { [features] ); - const handleFilterChange = useCallback( - (name: string, value: [number, number] | string[]) => { - setFilters((prev) => ({ ...prev, [name]: value })); - }, - [] - ); + const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => { + setFilters((prev) => ({ ...prev, [name]: value })); + }, []); const handleRemoveFilter = useCallback((name: string) => { setFilters((prev) => { @@ -484,6 +557,7 @@ export default function App() { delete next[name]; return next; }); + setPinnedFeature((prev) => (prev === name ? null : prev)); }, []); const handleDragStart = useCallback( @@ -493,8 +567,41 @@ export default function App() { setActiveFeature(name); const fval = filters[name]; setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); + + // Fetch hexagons without this feature's filter so we can expand the range + if (!bounds) return; + if (dragAbortRef.current) dragAbortRef.current.abort(); + dragAbortRef.current = new AbortController(); + + const otherFilters = Object.entries(filters).filter(([k]) => k !== name); + let filtersStr = ''; + if (otherFilters.length > 0) { + filtersStr = otherFilters + .map(([n, value]) => { + const m = features.find((f) => f.name === n); + if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`; + const [min, max] = value as [number, number]; + return `${n}:${min}:${max}`; + }) + .join(','); + } + + const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; + const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr }); + if (filtersStr) params.set('filters', filtersStr); + + fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { + signal: dragAbortRef.current.signal, + }) + .then((res) => res.json()) + .then((json: ApiResponse) => setDragData(json.features || [])) + .catch((err) => { + if (err instanceof Error && err.name !== 'AbortError') { + console.error('Failed to fetch drag data:', err); + } + }); }, - [filters, features] + [filters, features, bounds, resolution] ); const handleDragChange = useCallback((value: [number, number]) => { @@ -507,8 +614,21 @@ export default function App() { } setActiveFeature(null); setDragValue(null); + setDragData(null); + if (dragAbortRef.current) { + dragAbortRef.current.abort(); + dragAbortRef.current = null; + } }, [activeFeature, dragValue]); + const handleTogglePin = useCallback((name: string) => { + setPinnedFeature((prev) => (prev === name ? null : name)); + }, []); + + const handleCancelPin = useCallback(() => { + setPinnedFeature(null); + }, []); + const fetchHexagonProperties = useCallback( async (h3: string, res: number, offset = 0) => { setLoadingProperties(true); @@ -584,84 +704,100 @@ export default function App() { return (
-
-
- -
- + {activePage === 'home' ? ( + setActivePage('dashboard')} /> + ) : activePage === 'data-sources' ? ( + + ) : ( +
+ - {loading && ( -
Loading...
- )} - -
-
- {/* Tab headers */} -
- - -
- - {/* Tab content */} -
- {rightPaneTab === 'pois' ? ( - - ) : ( - +
+ + {loading && ( +
+ Loading... +
)} + setActivePage('data-sources')} /> +
+
+ {/* Tab headers */} +
+ + +
+ + {/* Tab content */} +
+ {rightPaneTab === 'pois' ? ( + + ) : ( + + )} +
-
+ )}
); } diff --git a/frontend/src/components/DataSourcesPage.tsx b/frontend/src/components/DataSourcesPage.tsx index 1515412..67a533a 100644 --- a/frontend/src/components/DataSourcesPage.tsx +++ b/frontend/src/components/DataSourcesPage.tsx @@ -44,7 +44,7 @@ const DATA_SOURCES = [ { name: 'TfL Journey Times', origin: 'Transport for London', - use: 'Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King\'s Cross, etc.) via public transport and cycling.', + use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.", url: 'https://api-portal.tfl.gov.uk/', license: 'Powered by TfL Open Data', }, @@ -92,15 +92,12 @@ export default function DataSourcesPage() {

Data Sources

- This application combines {DATA_SOURCES.length} open datasets covering property prices, energy - performance, transport, demographics, crime, environment, and more. + This application combines {DATA_SOURCES.length} open datasets covering property prices, + energy performance, transport, demographics, crime, environment, and more.

{DATA_SOURCES.map((source) => ( -
+

{source.name}

@@ -125,11 +122,11 @@ export default function DataSourcesPage() {
); }); diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index 7c381e9..05f0289 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -182,28 +182,29 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v const ctaRef = useFadeInRef(); return ( -
+
{/* Hero */}
-
+

Find where to live, not just what's for sale

- Every neighbourhood
- in England & Wales.
+ Every neighbourhood +
+ in England & Wales. +
One map. Your rules.

- Set the commute, budget, school rating, noise level, and crime - threshold you'll accept. Narrowit shows you every area that - qualifies — instantly. + Set the commute, budget, school rating, noise level, and crime threshold you'll + accept. Narrowit shows you every area that qualifies — instantly.

@@ -281,16 +281,14 @@ export default memo(function Map({ pois, onViewChange, viewFeature, - viewRange, + colorRange, + filterRange, viewSource, onCancelPin, features, selectedHexagonId, onHexagonClick, initialViewState, - postcodeData, - selectedPostcode, - onPostcodeClick, }: MapProps) { const containerRef = useRef(null); const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW); @@ -328,7 +326,13 @@ export default memo(function Map({ east: Math.ceil(raw.east / QUANT) * QUANT, }; - onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude }); + onViewChange({ + resolution, + bounds, + zoom: viewState.zoom, + latitude: viewState.latitude, + longitude: viewState.longitude, + }); }, [viewState, dimensions, onViewChange]); const handleMove = useCallback((evt: { viewState: ViewState }) => { @@ -349,6 +353,12 @@ export default memo(function Map({ map.setPaintProperty(layer.id, 'text-halo-width', 2); map.setPaintProperty(layer.id, 'text-color', '#222'); } + // Make water more prominent + for (const layer of map.getStyle().layers || []) { + if (layer.id === 'water' || layer.id.startsWith('water')) { + map.setPaintProperty(layer.id, 'fill-color', '#6baed6'); + } + } map.setLayoutProperty('building', 'visibility', 'none'); map.setLayoutProperty('building-top', 'visibility', 'none'); }, @@ -399,8 +409,10 @@ export default memo(function Map({ // Use refs for values that change during drag so layers aren't recreated const viewFeatureRef = useRef(viewFeature); viewFeatureRef.current = viewFeature; - const viewRangeRef = useRef(viewRange); - viewRangeRef.current = viewRange; + const colorRangeRef = useRef(colorRange); + colorRangeRef.current = colorRange; + const filterRangeRef = useRef(filterRange); + filterRangeRef.current = filterRange; const colorFeatureMetaRef = useRef(colorFeatureMeta); colorFeatureMetaRef.current = colorFeatureMeta; const countRangeRef = useRef(countRange); @@ -408,32 +420,14 @@ export default memo(function Map({ const selectedHexagonIdRef = useRef(selectedHexagonId); selectedHexagonIdRef.current = selectedHexagonId; - // Postcode refs - const selectedPostcodeRef = useRef(selectedPostcode); - selectedPostcodeRef.current = selectedPostcode; - // Stable click handler using ref const onHexagonClickRef = useRef(onHexagonClick); onHexagonClickRef.current = onHexagonClick; - const handleHexagonClick = useCallback( - (info: PickingInfo) => { - if (info.object && 'h3' in info.object) { - onHexagonClickRef.current(info.object.h3); - } - }, - [] - ); - - const onPostcodeClickRef = useRef(onPostcodeClick); - onPostcodeClickRef.current = onPostcodeClick; - const handlePostcodeClick = useCallback( - (info: PickingInfo) => { - if (info.object && 'postcode' in info.object) { - onPostcodeClickRef.current(info.object.postcode); - } - }, - [] - ); + const handleHexagonClick = useCallback((info: PickingInfo) => { + if (info.object && 'h3' in info.object) { + onHexagonClickRef.current(info.object.h3); + } + }, []); // Stable hover handler using ref const handlePoiHoverRef = useRef(handlePoiHover); @@ -443,7 +437,7 @@ export default memo(function Map({ }, []); // Derive a trigger value from color-affecting state — avoids useEffect+setState double-render - const colorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`; + const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`; // Hexagon layer — only recreated when data or color trigger changes const hexLayer = useMemo( @@ -454,29 +448,36 @@ export default memo(function Map({ getHexagon: (d) => d.h3, getFillColor: (d) => { const vf = viewFeatureRef.current; - const vr = viewRangeRef.current; + const clr = colorRangeRef.current; + const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; - if (vf && vr && cfm) { + if (vf && clr && cfm) { const val = d[`min_${vf}`]; if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; - const min = vr[0]; - const max = vr[1]; - const minVal = d[`min_${vf}`] as number; - const maxVal = d[`max_${vf}`] as number; - // Gray out hexagons outside range - if (maxVal < min || minVal > max) { - return [180, 180, 180, 60] as [number, number, number, number]; + // Gray out hexagons outside filter range + 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 = max - min; + // Color using full slider range + const range = clr[1] - clr[0]; if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number]; - const t = ((val as number) - min) / range; + 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 = countRangeRef.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]; + return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [ + number, + number, + number, + number, + ]; }, getLineColor: (d) => (d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [ @@ -498,84 +499,11 @@ export default memo(function Map({ highPrecision: true, onClick: handleHexagonClick, // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps - beforeId: "waterway_label", + beforeId: 'waterway_label', }), [data, colorTrigger, handleHexagonClick] ); - // Postcode count range - 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; - - // Postcode color trigger - const postcodeColorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}`; - - // Postcode polygon layer - const postcodeLayer = useMemo( - () => - new PolygonLayer({ - id: 'postcode-polygons', - data: postcodeData, - getPolygon: (d) => d.polygon, - getFillColor: (d) => { - const vf = viewFeatureRef.current; - const vr = viewRangeRef.current; - const cfm = colorFeatureMetaRef.current; - if (vf && vr && cfm) { - const val = d[`min_${vf}`]; - if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; - const min = vr[0]; - const max = vr[1]; - const minVal = d[`min_${vf}`] as number; - const maxVal = d[`max_${vf}`] as number; - if (maxVal < min || minVal > max) { - return [180, 180, 180, 60] as [number, number, number, number]; - } - const range = max - min; - if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number]; - const t = ((val as number) - min) / 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) => - (d.postcode === selectedPostcodeRef.current - ? [255, 255, 255, 255] - : [160, 160, 160, 200]) as [number, number, number, number], - getLineWidth: (d) => (d.postcode === selectedPostcodeRef.current ? 2 : 1), - lineWidthUnits: 'pixels' as const, - stroked: true, - filled: true, - pickable: true, - updateTriggers: { - getFillColor: [postcodeColorTrigger], - getLineColor: [postcodeColorTrigger], - getLineWidth: [postcodeColorTrigger], - }, - onClick: handlePostcodeClick, - // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps - beforeId: "waterway_label", - }), - [postcodeData, postcodeColorTrigger, handlePostcodeClick] - ); - // POI layer — independent, only recreated when POI data changes const poiLayer = useMemo( () => @@ -597,9 +525,41 @@ export default memo(function Map({ [pois, stablePoiHover] ); + // Postcode labels on high-res hexagons (resolution 11+, zoom >= 13) + const postcodeData = useMemo( + () => data.filter((d) => d.postcode && d.lat != null && d.lon != null), + [data] + ); + + const showPostcodes = viewState.zoom >= 13; + const postcodeLayer = useMemo( + () => + showPostcodes + ? new TextLayer({ + id: 'postcode-labels', + data: postcodeData, + getPosition: (d) => [d.lon as number, d.lat as number], + getText: (d) => d.postcode as string, + getSize: 11, + getColor: [30, 30, 30, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + fontFamily: 'Inter, system-ui, sans-serif', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [255, 255, 255, 200], + billboard: false, + sizeUnits: 'pixels', + sizeMinPixels: 10, + sizeMaxPixels: 14, + }) + : null, + [postcodeData, showPostcodes] + ); + const layers = useMemo( - () => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]), - [postcodeData.length, postcodeLayer, hexLayer, poiLayer] + () => [hexLayer, poiLayer, ...(postcodeLayer ? [postcodeLayer] : [])], + [hexLayer, poiLayer, postcodeLayer] ); // Tooltip uses refs to avoid being a layer dependency @@ -611,15 +571,9 @@ export default memo(function Map({ ({ object }: { object?: any }) => { if (!object) return null; - // Handle both hexagon and postcode objects - const isPostcode = 'postcode' in object; - const isHexagon = 'h3' in object; - if (!isPostcode && !isHexagon) return null; + if (!('h3' in object)) return null; const lines: string[] = []; - if (isPostcode) { - lines.push(`${object.postcode}`); - } lines.push(`
${(object.count as number).toLocaleString()} properties
`); for (const f of featuresRef.current) { @@ -664,10 +618,10 @@ export default memo(function Map({ - {viewFeature && viewRange && colorFeatureMeta && ( + {viewFeature && colorRange && colorFeatureMeta && ( diff --git a/frontend/src/components/POIPane.tsx b/frontend/src/components/POIPane.tsx index 43490f0..2e41a6b 100644 --- a/frontend/src/components/POIPane.tsx +++ b/frontend/src/components/POIPane.tsx @@ -1,21 +1,22 @@ -import { useState, useRef, useEffect } from 'react'; -import { Label } from './ui/label'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import type { POICategoryGroup } from '../types'; interface POIPaneProps { - categories: string[]; + groups: POICategoryGroup[]; selectedCategories: Set; onCategoriesChange: (categories: Set) => void; poiCount: number; } export default function POIPane({ - categories, + groups, selectedCategories, onCategoriesChange, poiCount, }: POIPaneProps) { const [dropdownOpen, setDropdownOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const dropdownRef = useRef(null); // Close dropdown when clicking outside @@ -29,6 +30,8 @@ export default function POIPane({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + const allCategories = groups.flatMap((g) => g.categories); + const toggleCategory = (category: string) => { const newSet = new Set(selectedCategories); if (newSet.has(category)) { @@ -40,17 +43,55 @@ export default function POIPane({ }; const selectAll = () => { - onCategoriesChange(new Set(categories)); + onCategoriesChange(new Set(allCategories)); }; const selectNone = () => { onCategoriesChange(new Set()); }; - const filteredCategories = categories.filter((cat) => - cat.toLowerCase().includes(searchTerm.toLowerCase()) + const toggleGroup = useCallback( + (groupName: string) => { + const group = groups.find((g) => g.name === groupName); + if (!group) return; + const allSelected = group.categories.every((c) => selectedCategories.has(c)); + const newSet = new Set(selectedCategories); + if (allSelected) { + group.categories.forEach((c) => newSet.delete(c)); + } else { + group.categories.forEach((c) => newSet.add(c)); + } + onCategoriesChange(newSet); + }, + [groups, selectedCategories, onCategoriesChange] ); + const toggleCollapse = (groupName: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupName)) { + next.delete(groupName); + } else { + next.add(groupName); + } + return next; + }); + }; + + const lowerSearch = searchTerm.toLowerCase(); + + // Filter groups and categories by search term + const filteredGroups = groups + .map((group) => { + if (!searchTerm) return group; + const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch)); + const groupMatches = group.name.toLowerCase().includes(lowerSearch); + if (groupMatches) return group; + if (matchingCats.length === 0) return null; + return { ...group, categories: matchingCats }; + }) + .filter(Boolean) as POICategoryGroup[]; + const selectedCount = selectedCategories.size; return ( @@ -58,7 +99,6 @@ export default function POIPane({

Points of Interest

-
- {filteredCategories.map((category) => ( - - ))} + {filteredGroups.map((group) => { + const groupSelected = group.categories.filter((c) => + selectedCategories.has(c) + ).length; + const allInGroupSelected = groupSelected === group.categories.length; + const someInGroupSelected = groupSelected > 0 && !allInGroupSelected; + const isCollapsed = collapsedGroups.has(group.name) && !searchTerm; + + return ( +
+
+ + + + {groupSelected}/{group.categories.length} + +
+ {!isCollapsed && + group.categories.map((category) => ( + + ))} +
+ ); + })}
)} diff --git a/frontend/src/components/PropertiesPane.tsx b/frontend/src/components/PropertiesPane.tsx index f7718ac..e2f3da7 100644 --- a/frontend/src/components/PropertiesPane.tsx +++ b/frontend/src/components/PropertiesPane.tsx @@ -6,7 +6,6 @@ interface PropertiesPaneProps { total: number; loading: boolean; hexagonId: string | null; - postcodeId?: string | null; onLoadMore: () => void; onClose: () => void; } @@ -18,7 +17,6 @@ export function PropertiesPane({ total, loading, hexagonId, - postcodeId, onLoadMore, onClose, }: PropertiesPaneProps) { @@ -38,11 +36,10 @@ export function PropertiesPane({ }); }, [properties, sortBy]); - const selectionId = hexagonId || postcodeId; - if (!selectionId) { + if (!hexagonId) { return (
- Click a hexagon or postcode to view properties + Click a hexagon to view properties
); } @@ -52,9 +49,7 @@ export function PropertiesPane({ {/* Header */}
-

- {postcodeId ? `Properties in ${postcodeId}` : 'Properties in Hexagon'} -

+

Properties in Hexagon

diff --git a/frontend/src/index.css b/frontend/src/index.css index 9be6aa7..2e41d70 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -9,3 +9,17 @@ body, margin: 0; padding: 0; } + +/* Fade-in animation for homepage sections */ +.fade-in-section { + opacity: 0; + transform: translateY(24px); + transition: + opacity 0.6s ease-out, + transform 0.6s ease-out; +} + +.fade-in-visible { + opacity: 1; + transform: translateY(0); +} diff --git a/frontend/src/index.html b/frontend/src/index.html index 4db1612..514767e 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -3,7 +3,7 @@ - UK Property Prices Map + Narrowit
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 81b7e61..3c901c9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,7 +1,7 @@ export interface FeatureMeta { name: string; - label: string; type: 'numeric' | 'enum'; + group?: string; // Numeric-only fields min?: number; max?: number; @@ -9,6 +9,11 @@ export interface FeatureMeta { values?: string[]; } +export interface FeatureGroup { + name: string; + features: FeatureMeta[]; +} + // Filters: feature name -> [selectedMin, selectedMax] for numeric, string[] for enum export type FeatureFilters = Record; @@ -49,6 +54,7 @@ export interface POI { id: string; name: string; category: string; + group: string; lat: number; lng: number; emoji: string; @@ -58,10 +64,15 @@ export interface POIResponse { pois: POI[]; } -export interface POICategoriesResponse { +export interface POICategoryGroup { + name: string; categories: string[]; } +export interface POICategoriesResponse { + groups: POICategoryGroup[]; +} + export interface Property { // String fields address?: string; @@ -76,8 +87,10 @@ export interface Property { lat: number; lon: number; + is_construction_date_approximate?: boolean; + // All other numeric features (dynamic, including construction_age_band) - [key: string]: string | number | undefined; + [key: string]: string | number | boolean | undefined; } export interface HexagonPropertiesResponse {