Move into folders
This commit is contained in:
parent
ee73ab77fd
commit
5cbb180c57
24 changed files with 181 additions and 185 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
@ -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({
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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[];
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { formatValue } from '../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
|
|
||||||
export default function MapLegend({
|
export default function MapLegend({
|
||||||
featureLabel,
|
featureLabel,
|
||||||
|
|
@ -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[];
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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[];
|
||||||
|
|
@ -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[];
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue