Move into folders

This commit is contained in:
Andras Schmelczer 2026-02-07 13:34:50 +00:00
parent ee73ab77fd
commit 5cbb180c57
24 changed files with 181 additions and 185 deletions

View file

@ -1,16 +1,16 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { trackPageview } from './usePlausible'; import { trackPageview } from './usePlausible';
import Map from './components/Map'; import Map from './components/map/Map';
import type { SearchedPostcode } from './components/PostcodeSearch'; import type { SearchedPostcode } from './components/map/PostcodeSearch';
import Filters from './components/Filters'; import Filters from './components/map/Filters';
import POIPane from './components/POIPane'; import POIPane from './components/map/POIPane';
import { PropertiesPane } from './components/PropertiesPane'; import { PropertiesPane } from './components/map/PropertiesPane';
import AreaPane from './components/AreaPane'; import AreaPane from './components/map/AreaPane';
import DataSources from './components/DataSources'; import DataSources from './components/data-sources/DataSources';
import DataSourcesPage from './components/DataSourcesPage'; import DataSourcesPage from './components/data-sources/DataSourcesPage';
import FAQPage from './components/FAQPage'; import FAQPage from './components/faq/FAQPage';
import HomePage from './components/HomePage'; import HomePage from './components/home/HomePage';
import Header, { type Page } from './components/Header'; import Header, { type Page } from './components/shared/Header';
import { TabButton } from './components/ui/TabButton'; import { TabButton } from './components/ui/TabButton';
import type { import type {
FeatureMeta, FeatureMeta,
@ -18,7 +18,7 @@ import type {
FeatureFilters, FeatureFilters,
Bounds, Bounds,
HexagonData, HexagonData,
PostcodeData, PostcodeFeature,
ViewChangeParams, ViewChangeParams,
ApiResponse, ApiResponse,
POI, POI,
@ -58,7 +58,7 @@ export default function App() {
const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null); const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]); const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeData[]>([]); const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
const [dragData, setDragData] = useState<HexagonData[] | null>(null); const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const [resolution, setResolution] = useState<number>(8); const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null); const [bounds, setBounds] = useState<Bounds | null>(null);
@ -251,7 +251,7 @@ export default function App() {
const res = await fetch(apiUrl('postcodes', params), { const res = await fetch(apiUrl('postcodes', params), {
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
}); });
const json: { features: PostcodeData[] } = await res.json(); const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features || []); setPostcodeData(json.features || []);
setRawData([]); // Clear hexagon data setRawData([]); // Clear hexagon data
} else { } else {
@ -300,20 +300,30 @@ export default function App() {
// If dragData hasn't loaded yet, return null to trigger fallback // If dragData hasn't loaded yet, return null to trigger fallback
if (activeFeature && !dragData) return null; 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_<feature> values since that's what hexagon coloring uses // Only use min_<feature> values since that's what hexagon coloring uses
let min = Infinity; let min = Infinity;
let max = -Infinity; let max = -Infinity;
for (const item of sourceData) {
const val = item[`min_${viewFeature}`]; if (usePostcodeView) {
if (typeof val === 'number' && !isNaN(val)) { if (postcodeData.length === 0) return null;
min = Math.min(min, val); for (const feat of postcodeData) {
max = Math.max(max, val); 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; if (min === Infinity || max === -Infinity) return null;
return [min, max]; return [min, max];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]); }, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
@ -505,28 +515,29 @@ export default function App() {
[filters, features] [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( const buildPostcodeStats = useCallback(
(postcode: string): HexagonStatsResponse | null => { (postcode: string): HexagonStatsResponse | null => {
const pc = postcodeData.find((d) => d.postcode === postcode); const feat = postcodeData.find((f) => f.properties.postcode === postcode);
if (!pc) return null; if (!feat) return null;
const props = feat.properties;
const numeric_features: NumericFeatureStats[] = []; const numeric_features: NumericFeatureStats[] = [];
for (const f of features) { for (const f of features) {
if (f.type !== 'numeric') continue; if (f.type !== 'numeric') continue;
const minVal = pc[`min_${f.name}`]; const minVal = props[`min_${f.name}`];
const maxVal = pc[`max_${f.name}`]; const maxVal = props[`max_${f.name}`];
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue; if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
numeric_features.push({ numeric_features.push({
name: f.name, name: f.name,
count: pc.count, count: props.count,
min: minVal, min: minVal,
max: maxVal, max: maxVal,
mean: (minVal + maxVal) / 2, mean: (minVal + maxVal) / 2,
}); });
} }
return { count: pc.count, numeric_features, enum_features: [] }; return { count: props.count, numeric_features, enum_features: [] };
}, },
[postcodeData, features] [postcodeData, features]
); );
@ -815,19 +826,16 @@ export default function App() {
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm"> <div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton <TabButton
label="Area" label="Area"
count={areaStats?.count}
isActive={rightPaneTab === 'area'} isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')} onClick={() => setRightPaneTab('area')}
/> />
<TabButton <TabButton
label="Properties" label="Properties"
count={propertiesTotal > 0 ? propertiesTotal : undefined}
isActive={rightPaneTab === 'properties'} isActive={rightPaneTab === 'properties'}
onClick={handlePropertiesTabClick} onClick={handlePropertiesTabClick}
/> />
<TabButton <TabButton
label="POIs" label="POIs"
count={pois.length > 0 ? pois.length : undefined}
isActive={rightPaneTab === 'pois'} isActive={rightPaneTab === 'pois'}
onClick={() => setRightPaneTab('pois')} onClick={() => setRightPaneTab('pois')}
/> />
@ -843,7 +851,7 @@ export default function App() {
isPostcode={selectedHexagon?.type === 'postcode'} isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={ postcodeData={
selectedHexagon?.type === 'postcode' selectedHexagon?.type === 'postcode'
? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null ? postcodeData.find((f) => f.properties.postcode === selectedHexagon.id) || null
: null : null
} }
onViewProperties={handleViewPropertiesFromArea} onViewProperties={handleViewPropertiesFromArea}

View file

@ -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', url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
license: 'Open Government Licence v3.0', 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', id: 'council-tax',
name: 'Council Tax Levels 2025-26', name: 'Council Tax Levels 2025-26',

View file

@ -1,5 +1,5 @@
import { useRef, useState, useEffect, useCallback } from 'react'; import { useRef, useState, useEffect, useCallback } from 'react';
import { useFadeInRef } from '../hooks/useFadeIn'; import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas'; import HexCanvas from './HexCanvas';
export default function HomePage({ export default function HomePage({

View file

@ -1,18 +1,19 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData } from '../types'; import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
import type { HexagonLocation } from '../lib/external-search'; import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, calculateHistogramMean } from '../lib/format'; import { formatValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../lib/features'; import { groupFeaturesByCategory } from '../../lib/features';
import { STACKED_GROUPS } from '../lib/consts'; import { STACKED_GROUPS } from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart'; import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart'; import StackedBarChart from './StackedBarChart';
import PriceHistoryChart from './PriceHistoryChart'; import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks'; import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from './ui/icons'; import { InfoIcon, CloseIcon } from '../ui/icons';
import { IconButton } from './ui/IconButton'; import { IconButton } from '../ui/IconButton';
import { FeatureInfoPopup } from './FeatureInfoPopup'; import { FeatureInfoPopup } from '../shared/FeatureInfoPopup';
import { EmptyState } from './ui/EmptyState'; import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
interface AreaPaneProps { interface AreaPaneProps {
stats: HexagonStatsResponse | null; stats: HexagonStatsResponse | null;
@ -20,7 +21,7 @@ interface AreaPaneProps {
loading: boolean; loading: boolean;
hexagonId: string | null; hexagonId: string | null;
isPostcode?: boolean; isPostcode?: boolean;
postcodeData?: PostcodeData | null; postcodeData?: PostcodeFeature | null;
onViewProperties: () => void; onViewProperties: () => void;
onClose: () => void; onClose: () => void;
hexagonLocation: HexagonLocation | null; hexagonLocation: HexagonLocation | null;
@ -42,7 +43,7 @@ export default function AreaPane({
onNavigateToSource, onNavigateToSource,
}: AreaPaneProps) { }: AreaPaneProps) {
// For postcodes, use local data for count // 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 featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null); const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -162,20 +163,17 @@ export default function AreaPane({
className="bg-warm-50 dark:bg-warm-800 rounded p-2" className="bg-warm-50 dark:bg-warm-800 rounded p-2"
> >
<div className="flex justify-between items-baseline mb-1.5"> <div className="flex justify-between items-baseline mb-1.5">
<div className="flex items-center gap-1 min-w-0 mr-2"> {featureMeta ? (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate"> <FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{chart.label} {chart.label}
</span> </span>
{featureMeta?.detail && ( )}
<button
onClick={() => setInfoFeature(featureMeta)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Feature info"
>
<InfoIcon className="w-3 h-3" />
</button>
)}
</div>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap"> <span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(total)} {formatValue(total)}
{chart.unit ? ` ${chart.unit}` : ''} {chart.unit ? ` ${chart.unit}` : ''}
@ -203,20 +201,11 @@ export default function AreaPane({
className="bg-warm-50 dark:bg-warm-800 rounded p-2" className="bg-warm-50 dark:bg-warm-800 rounded p-2"
> >
<div className="flex justify-between items-baseline"> <div className="flex justify-between items-baseline">
<div className="flex items-center gap-1 min-w-0 mr-2"> <FeatureLabel
<span className="text-xs text-warm-700 dark:text-warm-300 truncate"> feature={feature}
{feature.name} onShowInfo={setInfoFeature}
</span> className="mr-2"
{feature.detail && ( />
<button
onClick={() => setInfoFeature(feature)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Feature info"
>
<InfoIcon className="w-3 h-3" />
</button>
)}
</div>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap"> <span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean)} {formatValue(numericStats.mean)}
</span> </span>
@ -255,20 +244,7 @@ export default function AreaPane({
key={feature.name} key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2" className="bg-warm-50 dark:bg-warm-800 rounded p-2"
> >
<div className="flex items-center gap-1"> <FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<span className="text-xs text-warm-700 dark:text-warm-300">
{feature.name}
</span>
{feature.detail && (
<button
onClick={() => setInfoFeature(feature)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Feature info"
>
<InfoIcon className="w-3 h-3" />
</button>
)}
</div>
<EnumBarChart counts={enumStats.counts} /> <EnumBarChart counts={enumStats.counts} />
</div> </div>
); );

View file

@ -1,10 +1,10 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { FeatureFilters } from '../types'; import type { FeatureFilters } from '../../types';
import { import {
buildPropertySearchUrls, buildPropertySearchUrls,
H3_RADIUS_MILES, H3_RADIUS_MILES,
type HexagonLocation, type HexagonLocation,
} from '../lib/external-search'; } from '../../lib/external-search';
export default function ExternalSearchLinks({ export default function ExternalSearchLinks({
location, location,

View file

@ -1,16 +1,16 @@
import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Slider } from './ui/Slider'; import { Slider } from '../ui/Slider';
import { Label } from './ui/Label'; import { Label } from '../ui/Label';
import { SearchInput } from './ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { FilterIcon, LightbulbIcon } from './ui/icons'; import { FilterIcon, LightbulbIcon } from '../ui/icons';
import { EmptyState } from './ui/EmptyState'; import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta, FeatureFilters } from '../types'; import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue } from '../lib/format'; import { formatFilterValue } from '../../lib/format';
import { groupFeaturesByCategory } from '../lib/features'; import { groupFeaturesByCategory } from '../../lib/features';
import InfoPopup from './InfoPopup'; import InfoPopup from '../shared/InfoPopup';
import { FeatureInfoPopup } from './FeatureInfoPopup'; import { FeatureInfoPopup } from '../shared/FeatureInfoPopup';
import { FeatureActions } from './FeatureIcons'; import { FeatureActions } from '../shared/FeatureIcons';
import { FeatureLabel } from './ui/FeatureLabel'; import { FeatureLabel } from '../ui/FeatureLabel';
interface FiltersProps { interface FiltersProps {
features: FeatureMeta[]; features: FeatureMeta[];

View file

@ -1,13 +1,18 @@
import { memo } from 'react'; import { memo } from 'react';
import type { HexagonData, PostcodeData, FeatureFilters } from '../types'; import type { FeatureFilters } from '../../types';
import { formatValue } from '../lib/format'; import { formatValue } from '../../lib/format';
interface HoverCardData {
count: number;
[key: string]: string | number | [number, number] | null;
}
interface HoverCardProps { interface HoverCardProps {
x: number; x: number;
y: number; y: number;
id: string; id: string;
isPostcode: boolean; isPostcode: boolean;
data: HexagonData | PostcodeData | null; data: HoverCardData | null;
filters: FeatureFilters; filters: FeatureFilters;
} }

View file

@ -3,17 +3,18 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { MapboxOverlay } from '@deck.gl/mapbox';
import { H3HexagonLayer } from '@deck.gl/geo-layers'; 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 type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import type { import type {
HexagonData, HexagonData,
PostcodeData, PostcodeFeature,
PostcodeProperties,
ViewState, ViewState,
ViewChangeParams, ViewChangeParams,
POI, POI,
FeatureMeta, FeatureMeta,
} from '../types'; } from '../../types';
import { import {
GRADIENT, GRADIENT,
normalizedToColor, normalizedToColor,
@ -22,12 +23,12 @@ import {
getBoundsFromViewState, getBoundsFromViewState,
emojiToTwemojiUrl, emojiToTwemojiUrl,
getMapStyle, getMapStyle,
} from '../lib/map-utils'; } from '../../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../lib/consts'; import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch'; import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
import MapLegend from './MapLegend'; import MapLegend from './MapLegend';
import HoverCard from './HoverCard'; import HoverCard from './HoverCard';
import type { FeatureFilters } from '../types'; import type { FeatureFilters } from '../../types';
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */ /** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
function osmIdToUrl(id: string): string | null { 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]}`; 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 { interface MapProps {
data: HexagonData[]; data: HexagonData[];
postcodeData: PostcodeData[]; postcodeData: PostcodeFeature[];
usePostcodeView: boolean; usePostcodeView: boolean;
pois: POI[]; pois: POI[];
onViewChange: (params: ViewChangeParams) => void; onViewChange: (params: ViewChangeParams) => void;
@ -266,7 +255,7 @@ export default memo(function Map({
let min = Infinity; let min = Infinity;
let max = -Infinity; let max = -Infinity;
for (const d of postcodeData) { for (const d of postcodeData) {
const c = d.count as number; const c = d.properties.count;
if (c < min) min = c; if (c < min) min = c;
if (c > max) max = c; if (c > max) max = c;
} }
@ -285,20 +274,22 @@ export default memo(function Map({
const hoveredPostcodeRef = useRef(hoveredPostcode); const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode; hoveredPostcodeRef.current = hoveredPostcode;
const handlePostcodeClick = useCallback((info: PickingInfo<PostcodeData>) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (info.object && 'postcode' in info.object) { const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
const pc = info.object.postcode; const pc = info.object?.properties?.postcode;
if (pc) {
setSelectedPostcode((prev) => (prev === pc ? null : pc)); setSelectedPostcode((prev) => (prev === pc ? null : pc));
// Also trigger the hexagon click handler with the postcode as identifier
onHexagonClickRef.current(pc, true); onHexagonClickRef.current(pc, true);
} }
}, []); }, []);
const handlePostcodeHoverCallback = useCallback((info: PickingInfo<PostcodeData>) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (info.object && 'postcode' in info.object && info.x !== undefined && info.y !== undefined) { const handlePostcodeHoverCallback = useCallback((info: PickingInfo<any>) => {
setHoveredPostcode(info.object.postcode); const pc = info.object?.properties?.postcode;
if (pc && info.x !== undefined && info.y !== undefined) {
setHoveredPostcode(pc);
setHoverPosition({ x: info.x, y: info.y }); setHoverPosition({ x: info.x, y: info.y });
onHexagonHoverRef.current(info.object.postcode, info.x, info.y); onHexagonHoverRef.current(pc, info.x, info.y);
} else { } else {
setHoveredPostcode(null); setHoveredPostcode(null);
setHoverPosition(null); setHoverPosition(null);
@ -378,11 +369,11 @@ export default memo(function Map({
const postcodeLayer = useMemo( const postcodeLayer = useMemo(
() => () =>
new PolygonLayer<PostcodeData>({ new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons', id: 'postcode-polygons',
data: postcodeData, data: postcodeData as PostcodeFeature[],
getPolygon: (d) => d.vertices, getFillColor: (f) => {
getFillColor: (d) => { const d = f.properties;
const vf = viewFeatureRef.current; const vf = viewFeatureRef.current;
const clr = colorRangeRef.current; const clr = colorRangeRef.current;
const fr = filterRangeRef.current; const fr = filterRangeRef.current;
@ -404,7 +395,7 @@ export default memo(function Map({
return [...rgb, 255] as [number, number, number, number]; return [...rgb, 255] as [number, number, number, number];
} }
const cr = postcodeCountRangeRef.current; const cr = postcodeCountRangeRef.current;
const c = d.count as number; const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min); const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [ return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
number, number,
@ -413,16 +404,18 @@ export default memo(function Map({
number, number,
]; ];
}, },
getLineColor: (d) => { getLineColor: (f) => {
if (d.postcode === selectedPostcodeRef.current) const pc = f.properties.postcode;
if (pc === selectedPostcodeRef.current)
return [255, 255, 255, 255] as [number, number, number, number]; 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 [29, 228, 195, 200] as [number, number, number, number];
return [100, 100, 100, 150] as [number, number, number, number]; return [100, 100, 100, 150] as [number, number, number, number];
}, },
getLineWidth: (d) => { getLineWidth: (f) => {
if (d.postcode === selectedPostcodeRef.current) return 3; const pc = f.properties.postcode;
if (d.postcode === hoveredPostcodeRef.current) return 2; if (pc === selectedPostcodeRef.current) return 3;
if (pc === hoveredPostcodeRef.current) return 2;
return 1; return 1;
}, },
lineWidthUnits: 'pixels', lineWidthUnits: 'pixels',
@ -435,19 +428,17 @@ export default memo(function Map({
pickable: true, pickable: true,
onClick: handlePostcodeClick, onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback, onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
}), }),
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback] [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
); );
const postcodeLabelsLayer = useMemo( const postcodeLabelsLayer = useMemo(
() => () =>
new TextLayer<PostcodeData>({ new TextLayer<PostcodeFeature>({
id: 'postcode-labels', id: 'postcode-labels',
data: postcodeData, data: postcodeData,
getPosition: (d) => polygonCentroid(d.vertices), getPosition: (f) => f.properties.centroid,
getText: (d) => d.postcode, getText: (f) => f.properties.postcode,
getSize: 12, getSize: 12,
getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220], getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220],
getTextAnchor: 'middle', getTextAnchor: 'middle',
@ -488,19 +479,21 @@ export default memo(function Map({
// Check if the searched postcode has data (passes current filters) // Check if the searched postcode has data (passes current filters)
const searchedPostcodeHasData = useMemo(() => { const searchedPostcodeHasData = useMemo(() => {
if (!searchedPostcode) return false; if (!searchedPostcode) return false;
return postcodeData.some((d) => d.postcode === searchedPostcode.postcode); return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode);
}, [searchedPostcode, postcodeData]); }, [searchedPostcode, postcodeData]);
// Highlight layer for searched postcode // Highlight layer for searched postcode
const searchedPostcodeHighlightLayer = useMemo(() => { const searchedPostcodeHighlightLayer = useMemo(() => {
if (!searchedPostcode) return null; if (!searchedPostcode) return null;
const hasData = searchedPostcodeHasData; const hasData = searchedPostcodeHasData;
// Use different layers for dashed vs solid lines const feature = {
return new PolygonLayer<{ vertices: [number, number][] }>({ type: 'Feature' as const,
geometry: searchedPostcode.geometry,
properties: {},
};
return new GeoJsonLayer({
id: 'searched-postcode-highlight', id: 'searched-postcode-highlight',
data: [{ vertices: searchedPostcode.vertices }], data: [feature],
getPolygon: (d) => d.vertices,
// Transparent fill - just show outline
getFillColor: hasData getFillColor: hasData
? [29, 228, 195, 40] // teal tint when has data ? [29, 228, 195, 40] // teal tint when has data
: [255, 180, 0, 30], // orange tint when filtered out : [255, 180, 0, 30], // orange tint when filtered out
@ -619,7 +612,8 @@ export default memo(function Map({
isPostcode={usePostcodeView} isPostcode={usePostcodeView}
data={ data={
usePostcodeView usePostcodeView
? postcodeData.find((d) => d.postcode === hoveredHexagonId) || null ? postcodeData.find((f) => f.properties.postcode === hoveredHexagonId)
?.properties || null
: data.find((d) => d.h3 === hoveredHexagonId) || null : data.find((d) => d.h3 === hoveredHexagonId) || null
} }
filters={filters} filters={filters}

View file

@ -1,4 +1,4 @@
import { formatValue } from '../lib/format'; import { formatValue } from '../../lib/format';
export default function MapLegend({ export default function MapLegend({
featureLabel, featureLabel,

View file

@ -1,10 +1,10 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import type { POICategoryGroup } from '../types'; import type { POICategoryGroup } from '../../types';
import { useClickOutside } from '../hooks/useClickOutside'; import { useClickOutside } from '../../hooks/useClickOutside';
import InfoPopup from './InfoPopup'; import InfoPopup from '../shared/InfoPopup';
import { SearchInput } from './ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { InfoIcon, ChevronIcon } from './ui/icons'; import { InfoIcon, ChevronIcon } from '../ui/icons';
import { IconButton } from './ui/IconButton'; import { IconButton } from '../ui/IconButton';
interface POIPaneProps { interface POIPaneProps {
groups: POICategoryGroup[]; groups: POICategoryGroup[];

View file

@ -1,8 +1,9 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { PostcodeGeometry } from '../../types';
export interface SearchedPostcode { export interface SearchedPostcode {
postcode: string; postcode: string;
vertices: [number, number][]; geometry: PostcodeGeometry;
} }
export default function PostcodeSearch({ export default function PostcodeSearch({
@ -34,10 +35,10 @@ export default function PostcodeSearch({
postcode: string; postcode: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
vertices: [number, number][]; geometry: PostcodeGeometry;
} = await res.json(); } = await res.json();
onFlyTo(json.latitude, json.longitude, 16); onFlyTo(json.latitude, json.longitude, 16);
onPostcodeSearched?.({ postcode: json.postcode, vertices: json.vertices }); onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry });
setQuery(''); setQuery('');
} catch { } catch {
setError('Lookup failed'); setError('Lookup failed');

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { PricePoint } from '../types'; import type { PricePoint } from '../../types';
import { formatValue } from '../lib/format'; import { formatValue } from '../../lib/format';
interface PriceHistoryChartProps { interface PriceHistoryChartProps {
points: PricePoint[]; points: PricePoint[];

View file

@ -1,11 +1,11 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Property } from '../types'; import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber } from '../lib/format'; import { formatDuration, formatAge, formatNumber } from '../../lib/format';
import { getNum } from '../lib/property-fields'; import { getNum } from '../../lib/property-fields';
import InfoPopup from './InfoPopup'; import InfoPopup from '../shared/InfoPopup';
import { SearchInput } from './ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from './ui/EmptyState'; import { EmptyState } from '../ui/EmptyState';
import { InfoIcon } from './ui/icons'; import { InfoIcon } from '../ui/icons';
interface PropertiesPaneProps { interface PropertiesPaneProps {
properties: Property[]; properties: Property[];

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { SEGMENT_COLORS } from '../lib/consts'; import { SEGMENT_COLORS } from '../../lib/consts';
import { formatValue } from '../lib/format'; import { formatValue } from '../../lib/format';
interface Segment { interface Segment {
name: string; name: string;

View file

@ -1,6 +1,6 @@
import type { FeatureMeta } from '../types'; import type { FeatureMeta } from '../../types';
import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from './ui/icons'; import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from '../ui/icons';
import { IconButton } from './ui/IconButton'; import { IconButton } from '../ui/IconButton';
interface FeatureActionsProps { interface FeatureActionsProps {
feature: FeatureMeta; feature: FeatureMeta;

View file

@ -1,4 +1,4 @@
import type { FeatureMeta } from '../types'; import type { FeatureMeta } from '../../types';
import InfoPopup from './InfoPopup'; import InfoPopup from './InfoPopup';
interface FeatureInfoPopupProps { interface FeatureInfoPopupProps {

View file

@ -1,7 +1,7 @@
import { useRef, useCallback, type ReactNode } from 'react'; import { useRef, useCallback, type ReactNode } from 'react';
import { useClickOutside } from '../hooks/useClickOutside'; import { useClickOutside } from '../../hooks/useClickOutside';
import { CloseIcon } from './ui/icons'; import { CloseIcon } from '../ui/icons';
import { IconButton } from './ui/IconButton'; import { IconButton } from '../ui/IconButton';
interface InfoPopupProps { interface InfoPopupProps {
title: string; title: string;

View file

@ -29,13 +29,17 @@ export interface HexagonData {
[key: string]: string | number | null; [key: string]: string | number | null;
} }
export interface PostcodeData { export interface PostcodeProperties {
postcode: string; postcode: string;
vertices: [number, number][];
count: 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<GeoJSON.Polygon | GeoJSON.MultiPolygon, PostcodeProperties>;
export type PostcodeGeometry = GeoJSON.Polygon | GeoJSON.MultiPolygon;
export interface Bounds { export interface Bounds {
south: number; south: number;
west: number; west: number;