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 { 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<string | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(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_<feature> 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() {
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
count={areaStats?.count}
isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')}
/>
<TabButton
label="Properties"
count={propertiesTotal > 0 ? propertiesTotal : undefined}
isActive={rightPaneTab === 'properties'}
onClick={handlePropertiesTabClick}
/>
<TabButton
label="POIs"
count={pois.length > 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}

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',
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',

View file

@ -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({

View file

@ -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<FeatureMeta | null>(null);
@ -162,20 +163,17 @@ export default function AreaPane({
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
<div className="flex items-center gap-1 min-w-0 mr-2">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
{featureMeta ? (
<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}
</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">
{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"
>
<div className="flex justify-between items-baseline">
<div className="flex items-center gap-1 min-w-0 mr-2">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
{feature.name}
</span>
{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>
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean)}
</span>
@ -255,20 +244,7 @@ export default function AreaPane({
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex items-center gap-1">
<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>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
</div>
);

View file

@ -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,

View file

@ -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[];

View file

@ -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;
}

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 { 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<PostcodeData>) => {
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<any>) => {
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<PostcodeData>) => {
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<any>) => {
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<PostcodeData>({
new GeoJsonLayer<PostcodeProperties>({
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<PostcodeData>({
new TextLayer<PostcodeFeature>({
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}

View file

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

View file

@ -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[];

View file

@ -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');

View file

@ -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[];

View file

@ -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[];

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -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;

View file

@ -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<GeoJSON.Polygon | GeoJSON.MultiPolygon, PostcodeProperties>;
export type PostcodeGeometry = GeoJSON.Polygon | GeoJSON.MultiPolygon;
export interface Bounds {
south: number;
west: number;