From 7627818e98a21eeb26bd5581f63fc298eb8a8d64 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 31 Jan 2026 22:04:28 +0000 Subject: [PATCH] Spice up website --- frontend/src/components/DataSources.tsx | 53 +-- frontend/src/components/DataSourcesPage.tsx | 175 ++++++++++ frontend/src/components/Filters.tsx | 89 +++-- frontend/src/components/HomePage.tsx | 362 ++++++++++++++++++++ frontend/src/components/Map.tsx | 233 +++++++++++-- frontend/src/components/POIPane.tsx | 26 +- frontend/src/components/PropertiesPane.tsx | 49 +-- frontend/src/components/ui/label.tsx | 2 +- frontend/src/components/ui/slider.tsx | 6 +- 9 files changed, 831 insertions(+), 164 deletions(-) create mode 100644 frontend/src/components/DataSourcesPage.tsx create mode 100644 frontend/src/components/HomePage.tsx diff --git a/frontend/src/components/DataSources.tsx b/frontend/src/components/DataSources.tsx index 1d558bf..bd7c074 100644 --- a/frontend/src/components/DataSources.tsx +++ b/frontend/src/components/DataSources.tsx @@ -1,49 +1,10 @@ -export default function DataSources() { - const sources = [ - { - name: 'Land Registry', - url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads', - }, - { - name: 'EPC', - url: 'https://epc.opendatacommunities.org/downloads/domestic', - }, - { - name: 'ArcGIS Postcodes', - url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data', - }, - { - name: 'OpenStreetMap', - url: 'https://www.openstreetmap.org/copyright', - }, - { - name: 'IoD 2025', - url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025', - }, - { - name: 'TfL API', - url: 'https://api-portal.tfl.gov.uk/', - }, - ]; - +export default function DataSources({ onNavigate }: { onNavigate: () => void }) { return ( -
-
Data Sources
-
- {sources.map((source, idx) => ( - - - {source.name} - - {idx < sources.length - 1 && } - - ))} -
-
+ ); } diff --git a/frontend/src/components/DataSourcesPage.tsx b/frontend/src/components/DataSourcesPage.tsx new file mode 100644 index 0000000..1515412 --- /dev/null +++ b/frontend/src/components/DataSourcesPage.tsx @@ -0,0 +1,175 @@ +const DATA_SOURCES = [ + { + name: 'Price Paid Data', + origin: 'HM Land Registry', + use: 'Complete historical property sale prices for England and Wales. Used for the last known sale price of each property.', + url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads', + license: 'Open Government Licence v3.0', + }, + { + name: 'Energy Performance Certificates (EPC)', + origin: 'Ministry of Housing, Communities & Local Government', + use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets.', + url: 'https://epc.opendatacommunities.org/downloads/domestic', + license: 'Open Government Licence v3.0', + }, + { + name: 'National Statistics Postcode Lookup (NSPL)', + origin: 'ONS / ArcGIS', + use: 'Maps postcodes to latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.', + url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data', + license: 'Open Government Licence v3.0', + }, + { + name: 'English Indices of Deprivation 2025', + origin: 'Ministry of Housing, Communities & Local Government', + use: 'Relative deprivation scores for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.', + url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025', + license: 'Open Government Licence v3.0', + }, + { + name: 'Population by Ethnicity (2021 Census)', + origin: 'ONS', + use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.', + url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data', + license: 'Open Government Licence v3.0', + }, + { + name: 'Street-level Crime Data', + origin: 'data.police.uk', + use: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).', + url: 'https://data.police.uk/data/', + license: 'Open Government Licence v3.0', + }, + { + 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.', + url: 'https://api-portal.tfl.gov.uk/', + license: 'Powered by TfL Open Data', + }, + { + name: 'OpenStreetMap POIs', + origin: 'OpenStreetMap contributors / Geofabrik', + use: 'Points of interest extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.', + url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf', + license: 'Open Data Commons Open Database License (ODbL)', + }, + { + name: 'NaPTAN (Public Transport Stops)', + origin: 'Department for Transport', + use: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.', + url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf', + license: 'Open Government Licence v3.0', + }, + { + name: 'Defra Noise Mapping', + origin: 'Defra / Environment Agency', + use: 'Strategic noise mapping Round 4 (2022) for road, rail, and airport sources. Lden (day-evening-night 24h weighted average) at 10m grid resolution, modelled at 4m above ground. Sampled at postcode centroids via WCS GeoTIFF tiles.', + url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs', + license: 'Open Government Licence v3.0', + }, + { + name: 'Ofsted School Inspections', + origin: 'Ofsted', + use: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).', + url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes', + license: 'Open Government Licence v3.0', + }, + { + name: 'Ofcom Broadband Performance', + origin: 'Ofcom', + use: 'Fixed broadband coverage and speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.', + url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025', + license: 'Open Government Licence v3.0', + }, +]; + +export default function DataSourcesPage() { + return ( +
+
+
+

Data Sources

+

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

+ + {source.license} + +
+

Source: {source.origin}

+

{source.use}

+ + {source.url} + +
+ ))} +
+
+
+ + +
+ ); +} diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx index 498552a..a1da18c 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/Filters.tsx @@ -16,6 +16,9 @@ interface FiltersProps { onDragChange: (value: [number, number]) => void; onDragEnd: () => void; zoom: number; + pinnedFeature: string | null; + onTogglePin: (name: string) => void; + onCancelPin: () => void; } function formatValue(value: number): string { @@ -38,13 +41,16 @@ export default memo(function Filters({ onDragChange, onDragEnd, zoom, + pinnedFeature, + onTogglePin, + onCancelPin, }: FiltersProps) { const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); return (
-
Zoom: {zoom.toFixed(1)}
+
Zoom: {zoom.toFixed(1)}
{/* Add filter dropdown */} {availableFeatures.length > 0 && ( @@ -60,7 +66,7 @@ export default memo(function Filters({ {availableFeatures.map((f) => ( ))} @@ -74,10 +80,10 @@ export default memo(function Filters({ return (
- +
+
+ + {isPinned && ( + + )} + +
-
Color Scale
- {activeFeature ? ( - <> -
-
- Low - High -
- - ) : ( - <> -
-
- Few - Many -
- - )} -
); }); diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx new file mode 100644 index 0000000..7c381e9 --- /dev/null +++ b/frontend/src/components/HomePage.tsx @@ -0,0 +1,362 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; + +// --- Floating hex particle canvas that reacts to scroll --- + +const HEX_COUNT = 60; +const TAU = Math.PI * 2; + +interface Hex { + x: number; + y: number; + baseY: number; + size: number; + opacity: number; + speed: number; // horizontal drift px/s + phase: number; // for gentle bob +} + +function initHexes(w: number, h: number): Hex[] { + const hexes: Hex[] = []; + for (let i = 0; i < HEX_COUNT; i++) { + const y = Math.random() * h; + hexes.push({ + x: Math.random() * w, + y, + baseY: y, + size: 8 + Math.random() * 20, + opacity: 0.06 + Math.random() * 0.12, + speed: 6 + Math.random() * 14, + phase: Math.random() * TAU, + }); + } + return hexes; +} + +function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = (TAU / 6) * i - Math.PI / 6; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); +} + +function HexCanvas({ scrollProgress }: { scrollProgress: number }) { + const canvasRef = useRef(null); + const hexesRef = useRef([]); + const animRef = useRef(0); + const scrollRef = useRef(scrollProgress); + scrollRef.current = scrollProgress; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let w = 0; + let h = 0; + + function resize() { + const dpr = window.devicePixelRatio || 1; + const rect = canvas!.parentElement!.getBoundingClientRect(); + w = rect.width; + h = rect.height; + canvas!.width = w * dpr; + canvas!.height = h * dpr; + canvas!.style.width = `${w}px`; + canvas!.style.height = `${h}px`; + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + hexesRef.current = initHexes(w, h); + } + + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas.parentElement!); + + let prev = performance.now(); + + function frame(now: number) { + const dt = (now - prev) / 1000; + prev = now; + const scroll = scrollRef.current; + ctx!.clearRect(0, 0, w, h); + + // Teal accent color, fade to 0 as user scrolls down + const globalAlpha = Math.max(0, 1 - scroll * 2); + + for (const hex of hexesRef.current) { + // drift right, wrap + hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2); + // gentle vertical bob + parallax push from scroll + const bob = Math.sin(now / 1000 + hex.phase) * 8; + const parallax = scroll * h * 0.3 * (hex.speed / 20); + hex.y = hex.baseY + bob - parallax; + + // wrap vertically + if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4; + if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4; + + ctx!.globalAlpha = hex.opacity * globalAlpha; + ctx!.fillStyle = '#00a28c'; + drawHex(ctx!, hex.x, hex.y, hex.size); + ctx!.fill(); + + ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha; + ctx!.strokeStyle = '#05c9aa'; + ctx!.lineWidth = 1; + drawHex(ctx!, hex.x, hex.y, hex.size); + ctx!.stroke(); + } + + animRef.current = requestAnimationFrame(frame); + } + + animRef.current = requestAnimationFrame(frame); + return () => { + cancelAnimationFrame(animRef.current); + ro.disconnect(); + }; + }, []); + + return ( + + ); +} + +// --- Fade-in hook --- + +function useFadeInRef() { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + el.classList.add('fade-in-visible'); + observer.unobserve(el); + } + }, + { threshold: 0.15 } + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + return ref; +} + +// --- Page --- + +export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => void }) { + const scrollRef = useRef(null); + const [scrollProgress, setScrollProgress] = useState(0); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const max = el.scrollHeight - el.clientHeight; + if (max <= 0) return; + setScrollProgress(el.scrollTop / max); + }, []); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.addEventListener('scroll', handleScroll, { passive: true }); + return () => el.removeEventListener('scroll', handleScroll); + }, [handleScroll]); + + const heroRef = useFadeInRef(); + const problemRef = useFadeInRef(); + const filtersRef = useFadeInRef(); + const howRef = useFadeInRef(); + const numbersRef = useFadeInRef(); + const ctaRef = useFadeInRef(); + + return ( +
+ + +
+ {/* Hero */} +
+
+

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

+

+ 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. +

+
+ + + No signup · Free · Open data + +
+
+
+ + {/* The flip */} +
+
+
+
+
+

The old way

+

+ Pick a postcode. Google the schools. Check crime stats on + another site. Look up commute times. Realise it's too + expensive. Start over. Repeat 40 times. +

+
+
+

With Narrowit

+

+ Tell the map what you need. Every hexagon that lights up is + a place worth looking at. Drill into any one to see + individual properties, prices, and energy ratings. +

+
+
+
+
+
+ + {/* Filter showcase */} +
+
+

+ 12 datasets. One slider each. +

+

+ Every filter narrows the map in real time. Combine as many as you like. +

+
+ {FILTERS.map((f) => ( +
+
{f.icon}
+
{f.label}
+
{f.example}
+
+ ))} +
+
+
+ + {/* How it works */} +
+
+

+ Three clicks to clarity +

+
+ {STEPS.map((step, i) => ( +
+ + {i + 1} + +
+

{step.title}

+

{step.body}

+
+
+ ))} +
+
+
+ + {/* Numbers */} +
+
+
+ {STATS.map((s) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+
+
+ + {/* Final CTA */} +
+
+

+ Ready to narrow it down? +

+

+ 100% open data. No account required. Just set your filters and go. +

+ +
+
+
+
+ ); +} + +// --- Data --- + +const FILTERS = [ + { icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' }, + { icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' }, + { icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' }, + { icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' }, + { icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' }, + { icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' }, + { icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' }, + { icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' }, +]; + +const STEPS = [ + { + title: 'Add your deal-breakers', + body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.', + }, + { + title: 'Spot the clusters', + body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.', + }, + { + title: 'Dive into a neighbourhood', + body: 'Click any hexagon to see every property inside it \u2014 sale prices, floor plans, energy ratings, tenure. Layer on cafes, GP surgeries, and parks from OpenStreetMap.', + }, +]; + +const STATS = [ + { value: '26M+', label: 'property records' }, + { value: '12', label: 'open datasets' }, + { value: '1.7M', label: 'postcodes mapped' }, +]; diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 0458978..ae01815 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -3,21 +3,26 @@ 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, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta, PostcodeData } from '../types'; interface MapProps { data: HexagonData[]; pois: POI[]; onViewChange: (params: ViewChangeParams) => void; - activeFeature: string | null; - dragValue: [number, number] | null; + viewFeature: string | null; + viewRange: [number, number] | null; + viewSource: 'drag' | 'eye' | null; + onCancelPin: () => void; features: FeatureMeta[]; selectedHexagonId: string | null; onHexagonClick: (h3: string) => void; initialViewState?: ViewState; + postcodeData: PostcodeData[]; + selectedPostcode: string | null; + onPostcodeClick: (postcode: string) => void; } // Twemoji CDN base URL @@ -123,7 +128,8 @@ function DeckOverlay({ layers, getTooltip, }: { - layers: (H3HexagonLayer | IconLayer)[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layers: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any getTooltip: any; }) { @@ -207,7 +213,7 @@ function PostcodeSearch({ @@ -221,16 +227,70 @@ function PostcodeSearch({ ); } +function MapLegend({ + featureLabel, + range, + showCancel, + onCancel, +}: { + featureLabel: string; + range: [number, number]; + showCancel: boolean; + onCancel: () => void; +}) { + const formatVal = (v: number) => { + if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`; + if (Number.isInteger(v)) return v.toString(); + return v.toFixed(1); + }; + + return ( +
+
+ {featureLabel} + {showCancel && ( + + )} +
+
+
+ {formatVal(range[0])} + {formatVal(range[1])} +
+
+ ); +} + export default memo(function Map({ data, pois, onViewChange, - activeFeature, - dragValue, + viewFeature, + viewRange, + viewSource, + onCancelPin, features, selectedHexagonId, onHexagonClick, initialViewState, + postcodeData, + selectedPostcode, + onPostcodeClick, }: MapProps) { const containerRef = useRef(null); const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW); @@ -332,15 +392,15 @@ export default memo(function Map({ // Memoize feature lookup to avoid new reference each render const colorFeatureMeta = useMemo( - () => (activeFeature ? features.find((f) => f.name === activeFeature) || null : null), - [activeFeature, features] + () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), + [viewFeature, features] ); // Use refs for values that change during drag so layers aren't recreated - const activeFeatureRef = useRef(activeFeature); - activeFeatureRef.current = activeFeature; - const dragValueRef = useRef(dragValue); - dragValueRef.current = dragValue; + const viewFeatureRef = useRef(viewFeature); + viewFeatureRef.current = viewFeature; + const viewRangeRef = useRef(viewRange); + viewRangeRef.current = viewRange; const colorFeatureMetaRef = useRef(colorFeatureMeta); colorFeatureMetaRef.current = colorFeatureMeta; const countRangeRef = useRef(countRange); @@ -348,6 +408,10 @@ 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; @@ -360,6 +424,17 @@ export default memo(function Map({ [] ); + const onPostcodeClickRef = useRef(onPostcodeClick); + onPostcodeClickRef.current = onPostcodeClick; + const handlePostcodeClick = useCallback( + (info: PickingInfo) => { + if (info.object && 'postcode' in info.object) { + onPostcodeClickRef.current(info.object.postcode); + } + }, + [] + ); + // Stable hover handler using ref const handlePoiHoverRef = useRef(handlePoiHover); handlePoiHoverRef.current = handlePoiHover; @@ -368,7 +443,7 @@ export default memo(function Map({ }, []); // Derive a trigger value from color-affecting state — avoids useEffect+setState double-render - const colorTrigger = `${activeFeature}|${dragValue?.[0]}|${dragValue?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`; + const colorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`; // Hexagon layer — only recreated when data or color trigger changes const hexLayer = useMemo( @@ -378,17 +453,17 @@ export default memo(function Map({ data, getHexagon: (d) => d.h3, getFillColor: (d) => { - const af = activeFeatureRef.current; - const dv = dragValueRef.current; + const vf = viewFeatureRef.current; + const vr = viewRangeRef.current; const cfm = colorFeatureMetaRef.current; - if (af && dv && cfm) { - const val = d[`min_${af}`]; + if (vf && vr && cfm) { + const val = d[`min_${vf}`]; if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; - const min = dv[0]; - const max = dv[1]; - const minVal = d[`min_${af}`] as number; - const maxVal = d[`max_${af}`] as number; - // Gray out hexagons outside drag range + 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]; } @@ -428,6 +503,79 @@ export default memo(function Map({ [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( () => @@ -449,23 +597,34 @@ export default memo(function Map({ [pois, stablePoiHover] ); - const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]); + const layers = useMemo( + () => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]), + [postcodeData.length, postcodeLayer, hexLayer, poiLayer] + ); // Tooltip uses refs to avoid being a layer dependency const featuresRef = useRef(features); featuresRef.current = features; const getTooltip = useCallback( - ({ object }: { object?: HexagonData }) => { - if (!object || !('h3' in object)) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ({ 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; - const hex = object; const lines: string[] = []; - lines.push(`${(hex.count as number).toLocaleString()} properties`); + if (isPostcode) { + lines.push(`${object.postcode}`); + } + lines.push(`
${(object.count as number).toLocaleString()} properties
`); for (const f of featuresRef.current) { - const minVal = hex[`min_${f.name}`]; - const maxVal = hex[`max_${f.name}`]; + const minVal = object[`min_${f.name}`]; + const maxVal = object[`max_${f.name}`]; if (minVal != null && maxVal != null) { const minStr = typeof minVal === 'number' @@ -475,8 +634,8 @@ export default memo(function Map({ typeof maxVal === 'number' ? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 }) : String(maxVal); - const highlight = f.name === activeFeatureRef.current ? 'font-weight: bold;' : ''; - lines.push(`
${f.label}: ${minStr} - ${maxStr}
`); + const highlight = f.name === viewFeatureRef.current ? 'font-weight: bold;' : ''; + lines.push(`
${f.name}: ${minStr} - ${maxStr}
`); } } @@ -505,6 +664,14 @@ export default memo(function Map({ + {viewFeature && viewRange && colorFeatureMeta && ( + + )} {popupInfo && (
Categories {dropdownOpen && ( -
-
- - | -
-
+
setSearchTerm(e.target.value)} - className="w-full px-2 py-1 text-sm border border-slate-300 rounded" + className="w-full px-2 py-1 text-sm border border-warm-300 rounded" />
{filteredCategories.map((category) => (