diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 37864af..8bd7b99 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,16 @@ 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'; -import AreaPane from './components/AreaPane'; -import DataSources from './components/DataSources'; -import DataSourcesPage from './components/DataSourcesPage'; -import FAQPage from './components/FAQPage'; -import HomePage from './components/HomePage'; -import Header, { type Page } from './components/Header'; +import Map from './components/map/Map'; +import type { SearchedPostcode } from './components/map/PostcodeSearch'; +import Filters from './components/map/Filters'; +import POIPane from './components/map/POIPane'; +import { PropertiesPane } from './components/map/PropertiesPane'; +import AreaPane from './components/map/AreaPane'; +import DataSources from './components/data-sources/DataSources'; +import DataSourcesPage from './components/data-sources/DataSourcesPage'; +import FAQPage from './components/faq/FAQPage'; +import HomePage from './components/home/HomePage'; +import Header, { type Page } from './components/shared/Header'; import { TabButton } from './components/ui/TabButton'; import type { FeatureMeta, @@ -18,7 +18,7 @@ import type { FeatureFilters, Bounds, HexagonData, - PostcodeData, + PostcodeFeature, ViewChangeParams, ApiResponse, POI, @@ -58,7 +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 [postcodeData, setPostcodeData] = useState([]); const [dragData, setDragData] = useState(null); const [resolution, setResolution] = useState(8); const [bounds, setBounds] = useState(null); @@ -251,7 +251,7 @@ export default function App() { const res = await fetch(apiUrl('postcodes', params), { signal: abortControllerRef.current.signal, }); - const json: { features: PostcodeData[] } = await res.json(); + const json: { features: PostcodeFeature[] } = await res.json(); setPostcodeData(json.features || []); setRawData([]); // Clear hexagon data } else { @@ -300,20 +300,30 @@ export default function App() { // 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 (usePostcodeView) { + if (postcodeData.length === 0) return null; + for (const feat of postcodeData) { + const val = feat.properties[`min_${viewFeature}`]; + if (typeof val === 'number' && !isNaN(val)) { + min = Math.min(min, val); + max = Math.max(max, val); + } + } + } else { + if (data.length === 0) return null; + for (const item of data) { + const val = item[`min_${viewFeature}`]; + if (typeof val === 'number' && !isNaN(val)) { + min = Math.min(min, val); + max = Math.max(max, val); + } } } + if (min === Infinity || max === -Infinity) return null; return [min, max]; }, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]); @@ -505,28 +515,29 @@ export default function App() { [filters, features] ); - /** Build stats from already-loaded PostcodeData (min/max per feature). */ + /** Build stats from already-loaded PostcodeFeature (min/max per feature). */ const buildPostcodeStats = useCallback( (postcode: string): HexagonStatsResponse | null => { - const pc = postcodeData.find((d) => d.postcode === postcode); - if (!pc) return null; + const feat = postcodeData.find((f) => f.properties.postcode === postcode); + if (!feat) return null; + const props = feat.properties; const numeric_features: NumericFeatureStats[] = []; for (const f of features) { if (f.type !== 'numeric') continue; - const minVal = pc[`min_${f.name}`]; - const maxVal = pc[`max_${f.name}`]; + const minVal = props[`min_${f.name}`]; + const maxVal = props[`max_${f.name}`]; if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue; numeric_features.push({ name: f.name, - count: pc.count, + count: props.count, min: minVal, max: maxVal, mean: (minVal + maxVal) / 2, }); } - return { count: pc.count, numeric_features, enum_features: [] }; + return { count: props.count, numeric_features, enum_features: [] }; }, [postcodeData, features] ); @@ -815,19 +826,16 @@ export default function App() {
setRightPaneTab('area')} /> 0 ? propertiesTotal : undefined} isActive={rightPaneTab === 'properties'} onClick={handlePropertiesTabClick} /> 0 ? pois.length : undefined} isActive={rightPaneTab === 'pois'} onClick={() => setRightPaneTab('pois')} /> @@ -843,7 +851,7 @@ export default function App() { isPostcode={selectedHexagon?.type === 'postcode'} postcodeData={ selectedHexagon?.type === 'postcode' - ? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null + ? postcodeData.find((f) => f.properties.postcode === selectedHexagon.id) || null : null } onViewProperties={handleViewPropertiesFromArea} diff --git a/frontend/src/components/DataSources.tsx b/frontend/src/components/data-sources/DataSources.tsx similarity index 100% rename from frontend/src/components/DataSources.tsx rename to frontend/src/components/data-sources/DataSources.tsx diff --git a/frontend/src/components/DataSourcesPage.tsx b/frontend/src/components/data-sources/DataSourcesPage.tsx similarity index 95% rename from frontend/src/components/DataSourcesPage.tsx rename to frontend/src/components/data-sources/DataSourcesPage.tsx index f39b8a8..72ad496 100644 --- a/frontend/src/components/DataSourcesPage.tsx +++ b/frontend/src/components/data-sources/DataSourcesPage.tsx @@ -97,6 +97,14 @@ const DATA_SOURCES = [ url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025', license: 'Open Government Licence v3.0', }, + { + id: 'geosure', + name: 'GeoSure Ground Stability', + origin: 'Ordnance Survey', + use: 'Ground stability hazard ratings on a 5km hex grid covering Great Britain. Six risk categories (collapsible deposits, compressible ground, landslides, running sand, shrink-swell, and soluble rocks) rated Low, Moderate, or Significant. Spatial-joined to postcodes via centroid intersection.', + url: 'https://osdatahub.os.uk/downloads/open/GeoSure', + license: 'Open Government Licence v3.0', + }, { id: 'council-tax', name: 'Council Tax Levels 2025-26', diff --git a/frontend/src/components/FAQPage.tsx b/frontend/src/components/faq/FAQPage.tsx similarity index 100% rename from frontend/src/components/FAQPage.tsx rename to frontend/src/components/faq/FAQPage.tsx diff --git a/frontend/src/components/HexCanvas.tsx b/frontend/src/components/home/HexCanvas.tsx similarity index 100% rename from frontend/src/components/HexCanvas.tsx rename to frontend/src/components/home/HexCanvas.tsx diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/home/HomePage.tsx similarity index 99% rename from frontend/src/components/HomePage.tsx rename to frontend/src/components/home/HomePage.tsx index 19f42dd..32101bb 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -1,5 +1,5 @@ import { useRef, useState, useEffect, useCallback } from 'react'; -import { useFadeInRef } from '../hooks/useFadeIn'; +import { useFadeInRef } from '../../hooks/useFadeIn'; import HexCanvas from './HexCanvas'; export default function HomePage({ diff --git a/frontend/src/components/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx similarity index 77% rename from frontend/src/components/AreaPane.tsx rename to frontend/src/components/map/AreaPane.tsx index 2ea9729..b68c573 100644 --- a/frontend/src/components/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -1,18 +1,19 @@ import { useMemo, useState } from 'react'; -import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData } from '../types'; -import type { HexagonLocation } from '../lib/external-search'; -import { formatValue, calculateHistogramMean } from '../lib/format'; -import { groupFeaturesByCategory } from '../lib/features'; -import { STACKED_GROUPS } from '../lib/consts'; +import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types'; +import type { HexagonLocation } from '../../lib/external-search'; +import { formatValue, calculateHistogramMean } from '../../lib/format'; +import { groupFeaturesByCategory } from '../../lib/features'; +import { STACKED_GROUPS } from '../../lib/consts'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import EnumBarChart from './EnumBarChart'; import StackedBarChart from './StackedBarChart'; import PriceHistoryChart from './PriceHistoryChart'; import ExternalSearchLinks from './ExternalSearchLinks'; -import { InfoIcon, CloseIcon } from './ui/icons'; -import { IconButton } from './ui/IconButton'; -import { FeatureInfoPopup } from './FeatureInfoPopup'; -import { EmptyState } from './ui/EmptyState'; +import { InfoIcon, CloseIcon } from '../ui/icons'; +import { IconButton } from '../ui/IconButton'; +import { FeatureInfoPopup } from '../shared/FeatureInfoPopup'; +import { EmptyState } from '../ui/EmptyState'; +import { FeatureLabel } from '../ui/FeatureLabel'; interface AreaPaneProps { stats: HexagonStatsResponse | null; @@ -20,7 +21,7 @@ interface AreaPaneProps { loading: boolean; hexagonId: string | null; isPostcode?: boolean; - postcodeData?: PostcodeData | null; + postcodeData?: PostcodeFeature | null; onViewProperties: () => void; onClose: () => void; hexagonLocation: HexagonLocation | null; @@ -42,7 +43,7 @@ export default function AreaPane({ onNavigateToSource, }: AreaPaneProps) { // For postcodes, use local data for count - const propertyCount = isPostcode && postcodeData ? postcodeData.count : stats?.count; + const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count; const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]); const [infoFeature, setInfoFeature] = useState(null); @@ -162,20 +163,17 @@ export default function AreaPane({ className="bg-warm-50 dark:bg-warm-800 rounded p-2" >
-
- + {featureMeta ? ( + + ) : ( + {chart.label} - {featureMeta?.detail && ( - - )} -
+ )} {formatValue(total)} {chart.unit ? ` ${chart.unit}` : ''} @@ -203,20 +201,11 @@ export default function AreaPane({ className="bg-warm-50 dark:bg-warm-800 rounded p-2" >
-
- - {feature.name} - - {feature.detail && ( - - )} -
+ {formatValue(numericStats.mean)} @@ -255,20 +244,7 @@ export default function AreaPane({ key={feature.name} className="bg-warm-50 dark:bg-warm-800 rounded p-2" > -
- - {feature.name} - - {feature.detail && ( - - )} -
+
); diff --git a/frontend/src/components/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx similarity index 100% rename from frontend/src/components/DualHistogram.tsx rename to frontend/src/components/map/DualHistogram.tsx diff --git a/frontend/src/components/EnumBarChart.tsx b/frontend/src/components/map/EnumBarChart.tsx similarity index 100% rename from frontend/src/components/EnumBarChart.tsx rename to frontend/src/components/map/EnumBarChart.tsx diff --git a/frontend/src/components/ExternalSearchLinks.tsx b/frontend/src/components/map/ExternalSearchLinks.tsx similarity index 95% rename from frontend/src/components/ExternalSearchLinks.tsx rename to frontend/src/components/map/ExternalSearchLinks.tsx index f7da92e..c43f84c 100644 --- a/frontend/src/components/ExternalSearchLinks.tsx +++ b/frontend/src/components/map/ExternalSearchLinks.tsx @@ -1,10 +1,10 @@ import { useMemo } from 'react'; -import type { FeatureFilters } from '../types'; +import type { FeatureFilters } from '../../types'; import { buildPropertySearchUrls, H3_RADIUS_MILES, type HexagonLocation, -} from '../lib/external-search'; +} from '../../lib/external-search'; export default function ExternalSearchLinks({ location, diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/map/Filters.tsx similarity index 96% rename from frontend/src/components/Filters.tsx rename to frontend/src/components/map/Filters.tsx index 12982bf..01a74c0 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -1,16 +1,16 @@ 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 { FilterIcon, LightbulbIcon } from './ui/icons'; -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'; -import { FeatureLabel } from './ui/FeatureLabel'; +import { Slider } from '../ui/Slider'; +import { Label } from '../ui/Label'; +import { SearchInput } from '../ui/SearchInput'; +import { FilterIcon, LightbulbIcon } from '../ui/icons'; +import { EmptyState } from '../ui/EmptyState'; +import type { FeatureMeta, FeatureFilters } from '../../types'; +import { formatFilterValue } from '../../lib/format'; +import { groupFeaturesByCategory } from '../../lib/features'; +import InfoPopup from '../shared/InfoPopup'; +import { FeatureInfoPopup } from '../shared/FeatureInfoPopup'; +import { FeatureActions } from '../shared/FeatureIcons'; +import { FeatureLabel } from '../ui/FeatureLabel'; interface FiltersProps { features: FeatureMeta[]; diff --git a/frontend/src/components/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx similarity index 92% rename from frontend/src/components/HoverCard.tsx rename to frontend/src/components/map/HoverCard.tsx index 08a1c40..813fee3 100644 --- a/frontend/src/components/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -1,13 +1,18 @@ import { memo } from 'react'; -import type { HexagonData, PostcodeData, FeatureFilters } from '../types'; -import { formatValue } from '../lib/format'; +import type { FeatureFilters } from '../../types'; +import { formatValue } from '../../lib/format'; + +interface HoverCardData { + count: number; + [key: string]: string | number | [number, number] | null; +} interface HoverCardProps { x: number; y: number; id: string; isPostcode: boolean; - data: HexagonData | PostcodeData | null; + data: HoverCardData | null; filters: FeatureFilters; } diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/map/Map.tsx similarity index 89% rename from frontend/src/components/Map.tsx rename to frontend/src/components/map/Map.tsx index a7f3780..a288e18 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -3,17 +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, PolygonLayer, TextLayer } from '@deck.gl/layers'; +import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers'; import type { PickingInfo } from '@deck.gl/core'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { HexagonData, - PostcodeData, + PostcodeFeature, + PostcodeProperties, ViewState, ViewChangeParams, POI, FeatureMeta, -} from '../types'; +} from '../../types'; import { GRADIENT, normalizedToColor, @@ -22,12 +23,12 @@ import { getBoundsFromViewState, emojiToTwemojiUrl, getMapStyle, -} from '../lib/map-utils'; -import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../lib/consts'; +} from '../../lib/map-utils'; +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'; +import type { FeatureFilters } from '../../types'; /** Convert POI id (e.g. "n12345") to OpenStreetMap URL */ function osmIdToUrl(id: string): string | null { @@ -37,21 +38,9 @@ function osmIdToUrl(id: string): string | null { return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`; } -/** Calculate the centroid of a polygon from its vertices */ -function polygonCentroid(vertices: [number, number][]): [number, number] { - if (vertices.length === 0) return [0, 0]; - let sumLng = 0; - let sumLat = 0; - for (const [lng, lat] of vertices) { - sumLng += lng; - sumLat += lat; - } - return [sumLng / vertices.length, sumLat / vertices.length]; -} - interface MapProps { data: HexagonData[]; - postcodeData: PostcodeData[]; + postcodeData: PostcodeFeature[]; usePostcodeView: boolean; pois: POI[]; onViewChange: (params: ViewChangeParams) => void; @@ -266,7 +255,7 @@ export default memo(function Map({ let min = Infinity; let max = -Infinity; for (const d of postcodeData) { - const c = d.count as number; + const c = d.properties.count; if (c < min) min = c; if (c > max) max = c; } @@ -285,20 +274,22 @@ export default memo(function Map({ const hoveredPostcodeRef = useRef(hoveredPostcode); hoveredPostcodeRef.current = hoveredPostcode; - const handlePostcodeClick = useCallback((info: PickingInfo) => { - if (info.object && 'postcode' in info.object) { - const pc = info.object.postcode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handlePostcodeClick = useCallback((info: PickingInfo) => { + const pc = info.object?.properties?.postcode; + if (pc) { 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handlePostcodeHoverCallback = useCallback((info: PickingInfo) => { + const pc = info.object?.properties?.postcode; + if (pc && info.x !== undefined && info.y !== undefined) { + setHoveredPostcode(pc); setHoverPosition({ x: info.x, y: info.y }); - onHexagonHoverRef.current(info.object.postcode, info.x, info.y); + onHexagonHoverRef.current(pc, info.x, info.y); } else { setHoveredPostcode(null); setHoverPosition(null); @@ -378,11 +369,11 @@ export default memo(function Map({ const postcodeLayer = useMemo( () => - new PolygonLayer({ + new GeoJsonLayer({ id: 'postcode-polygons', - data: postcodeData, - getPolygon: (d) => d.vertices, - getFillColor: (d) => { + data: postcodeData as PostcodeFeature[], + getFillColor: (f) => { + const d = f.properties; const vf = viewFeatureRef.current; const clr = colorRangeRef.current; const fr = filterRangeRef.current; @@ -404,7 +395,7 @@ export default memo(function Map({ return [...rgb, 255] as [number, number, number, number]; } const cr = postcodeCountRangeRef.current; - const c = d.count as number; + const c = d.count; const t = (c - cr.min) / (cr.max - cr.min); return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [ number, @@ -413,16 +404,18 @@ export default memo(function Map({ number, ]; }, - getLineColor: (d) => { - if (d.postcode === selectedPostcodeRef.current) + getLineColor: (f) => { + const pc = f.properties.postcode; + if (pc === selectedPostcodeRef.current) return [255, 255, 255, 255] as [number, number, number, number]; - if (d.postcode === hoveredPostcodeRef.current) + if (pc === 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; + getLineWidth: (f) => { + const pc = f.properties.postcode; + if (pc === selectedPostcodeRef.current) return 3; + if (pc === hoveredPostcodeRef.current) return 2; return 1; }, lineWidthUnits: 'pixels', @@ -435,19 +428,17 @@ export default memo(function Map({ pickable: true, onClick: handlePostcodeClick, onHover: handlePostcodeHoverCallback, - // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps - beforeId: 'landuse_park', }), [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback] ); const postcodeLabelsLayer = useMemo( () => - new TextLayer({ + new TextLayer({ id: 'postcode-labels', data: postcodeData, - getPosition: (d) => polygonCentroid(d.vertices), - getText: (d) => d.postcode, + getPosition: (f) => f.properties.centroid, + getText: (f) => f.properties.postcode, getSize: 12, getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220], getTextAnchor: 'middle', @@ -488,19 +479,21 @@ export default memo(function Map({ // 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); + return postcodeData.some((f) => f.properties.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][] }>({ + const feature = { + type: 'Feature' as const, + geometry: searchedPostcode.geometry, + properties: {}, + }; + return new GeoJsonLayer({ id: 'searched-postcode-highlight', - data: [{ vertices: searchedPostcode.vertices }], - getPolygon: (d) => d.vertices, - // Transparent fill - just show outline + data: [feature], getFillColor: hasData ? [29, 228, 195, 40] // teal tint when has data : [255, 180, 0, 30], // orange tint when filtered out @@ -619,7 +612,8 @@ export default memo(function Map({ isPostcode={usePostcodeView} data={ usePostcodeView - ? postcodeData.find((d) => d.postcode === hoveredHexagonId) || null + ? postcodeData.find((f) => f.properties.postcode === hoveredHexagonId) + ?.properties || null : data.find((d) => d.h3 === hoveredHexagonId) || null } filters={filters} diff --git a/frontend/src/components/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx similarity index 97% rename from frontend/src/components/MapLegend.tsx rename to frontend/src/components/map/MapLegend.tsx index 1520224..ecbf3f8 100644 --- a/frontend/src/components/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend.tsx @@ -1,4 +1,4 @@ -import { formatValue } from '../lib/format'; +import { formatValue } from '../../lib/format'; export default function MapLegend({ featureLabel, diff --git a/frontend/src/components/POIPane.tsx b/frontend/src/components/map/POIPane.tsx similarity index 96% rename from frontend/src/components/POIPane.tsx rename to frontend/src/components/map/POIPane.tsx index 7cbe77e..7655f98 100644 --- a/frontend/src/components/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -1,10 +1,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 { InfoIcon, ChevronIcon } from './ui/icons'; -import { IconButton } from './ui/IconButton'; +import type { POICategoryGroup } from '../../types'; +import { useClickOutside } from '../../hooks/useClickOutside'; +import InfoPopup from '../shared/InfoPopup'; +import { SearchInput } from '../ui/SearchInput'; +import { InfoIcon, ChevronIcon } from '../ui/icons'; +import { IconButton } from '../ui/IconButton'; interface POIPaneProps { groups: POICategoryGroup[]; diff --git a/frontend/src/components/PostcodeSearch.tsx b/frontend/src/components/map/PostcodeSearch.tsx similarity index 91% rename from frontend/src/components/PostcodeSearch.tsx rename to frontend/src/components/map/PostcodeSearch.tsx index 75f9a71..ec7c452 100644 --- a/frontend/src/components/PostcodeSearch.tsx +++ b/frontend/src/components/map/PostcodeSearch.tsx @@ -1,8 +1,9 @@ import { useState, useCallback } from 'react'; +import type { PostcodeGeometry } from '../../types'; export interface SearchedPostcode { postcode: string; - vertices: [number, number][]; + geometry: PostcodeGeometry; } export default function PostcodeSearch({ @@ -34,10 +35,10 @@ export default function PostcodeSearch({ postcode: string; latitude: number; longitude: number; - vertices: [number, number][]; + geometry: PostcodeGeometry; } = await res.json(); onFlyTo(json.latitude, json.longitude, 16); - onPostcodeSearched?.({ postcode: json.postcode, vertices: json.vertices }); + onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry }); setQuery(''); } catch { setError('Lookup failed'); diff --git a/frontend/src/components/PriceHistoryChart.tsx b/frontend/src/components/map/PriceHistoryChart.tsx similarity index 98% rename from frontend/src/components/PriceHistoryChart.tsx rename to frontend/src/components/map/PriceHistoryChart.tsx index ee8ec95..2ee0205 100644 --- a/frontend/src/components/PriceHistoryChart.tsx +++ b/frontend/src/components/map/PriceHistoryChart.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import type { PricePoint } from '../types'; -import { formatValue } from '../lib/format'; +import type { PricePoint } from '../../types'; +import { formatValue } from '../../lib/format'; interface PriceHistoryChartProps { points: PricePoint[]; diff --git a/frontend/src/components/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx similarity index 95% rename from frontend/src/components/PropertiesPane.tsx rename to frontend/src/components/map/PropertiesPane.tsx index d496a70..6873476 100644 --- a/frontend/src/components/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -1,11 +1,11 @@ import React, { useMemo, useState } from 'react'; -import { Property } from '../types'; -import { formatDuration, formatAge, formatNumber } from '../lib/format'; -import { getNum } from '../lib/property-fields'; -import InfoPopup from './InfoPopup'; -import { SearchInput } from './ui/SearchInput'; -import { EmptyState } from './ui/EmptyState'; -import { InfoIcon } from './ui/icons'; +import { Property } from '../../types'; +import { formatDuration, formatAge, formatNumber } from '../../lib/format'; +import { getNum } from '../../lib/property-fields'; +import InfoPopup from '../shared/InfoPopup'; +import { SearchInput } from '../ui/SearchInput'; +import { EmptyState } from '../ui/EmptyState'; +import { InfoIcon } from '../ui/icons'; interface PropertiesPaneProps { properties: Property[]; diff --git a/frontend/src/components/StackedBarChart.tsx b/frontend/src/components/map/StackedBarChart.tsx similarity index 96% rename from frontend/src/components/StackedBarChart.tsx rename to frontend/src/components/map/StackedBarChart.tsx index b43ce45..c793cb7 100644 --- a/frontend/src/components/StackedBarChart.tsx +++ b/frontend/src/components/map/StackedBarChart.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { SEGMENT_COLORS } from '../lib/consts'; -import { formatValue } from '../lib/format'; +import { SEGMENT_COLORS } from '../../lib/consts'; +import { formatValue } from '../../lib/format'; interface Segment { name: string; diff --git a/frontend/src/components/FeatureIcons.tsx b/frontend/src/components/shared/FeatureIcons.tsx similarity index 87% rename from frontend/src/components/FeatureIcons.tsx rename to frontend/src/components/shared/FeatureIcons.tsx index 71f99a9..2335859 100644 --- a/frontend/src/components/FeatureIcons.tsx +++ b/frontend/src/components/shared/FeatureIcons.tsx @@ -1,6 +1,6 @@ -import type { FeatureMeta } from '../types'; -import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from './ui/icons'; -import { IconButton } from './ui/IconButton'; +import type { FeatureMeta } from '../../types'; +import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from '../ui/icons'; +import { IconButton } from '../ui/IconButton'; interface FeatureActionsProps { feature: FeatureMeta; diff --git a/frontend/src/components/FeatureInfoPopup.tsx b/frontend/src/components/shared/FeatureInfoPopup.tsx similarity index 95% rename from frontend/src/components/FeatureInfoPopup.tsx rename to frontend/src/components/shared/FeatureInfoPopup.tsx index a710e34..c976b91 100644 --- a/frontend/src/components/FeatureInfoPopup.tsx +++ b/frontend/src/components/shared/FeatureInfoPopup.tsx @@ -1,4 +1,4 @@ -import type { FeatureMeta } from '../types'; +import type { FeatureMeta } from '../../types'; import InfoPopup from './InfoPopup'; interface FeatureInfoPopupProps { diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/shared/Header.tsx similarity index 100% rename from frontend/src/components/Header.tsx rename to frontend/src/components/shared/Header.tsx diff --git a/frontend/src/components/InfoPopup.tsx b/frontend/src/components/shared/InfoPopup.tsx similarity index 90% rename from frontend/src/components/InfoPopup.tsx rename to frontend/src/components/shared/InfoPopup.tsx index 2e1c607..adbad08 100644 --- a/frontend/src/components/InfoPopup.tsx +++ b/frontend/src/components/shared/InfoPopup.tsx @@ -1,7 +1,7 @@ import { useRef, useCallback, type ReactNode } from 'react'; -import { useClickOutside } from '../hooks/useClickOutside'; -import { CloseIcon } from './ui/icons'; -import { IconButton } from './ui/IconButton'; +import { useClickOutside } from '../../hooks/useClickOutside'; +import { CloseIcon } from '../ui/icons'; +import { IconButton } from '../ui/IconButton'; interface InfoPopupProps { title: string; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e33b7d7..d5ec33f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -29,13 +29,17 @@ export interface HexagonData { [key: string]: string | number | null; } -export interface PostcodeData { +export interface PostcodeProperties { postcode: string; - vertices: [number, number][]; count: number; - [key: string]: string | number | [number, number][] | null; + centroid: [number, number]; + [key: string]: string | number | [number, number] | null; } +export type PostcodeFeature = GeoJSON.Feature; + +export type PostcodeGeometry = GeoJSON.Polygon | GeoJSON.MultiPolygon; + export interface Bounds { south: number; west: number;