Codex changes
This commit is contained in:
parent
0bae902e08
commit
d4dde21ad2
46 changed files with 4953 additions and 966 deletions
|
|
@ -1,8 +1,7 @@
|
|||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { cellToBoundary } from 'h3-js';
|
||||
import Supercluster from 'supercluster';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import type {
|
||||
HexagonData,
|
||||
|
|
@ -16,16 +15,12 @@ import type {
|
|||
import {
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
POI_GROUP_COLORS,
|
||||
POI_DEFAULT_COLOR,
|
||||
MINOR_POI_CATEGORIES,
|
||||
MINOR_POI_ZOOM_THRESHOLD,
|
||||
POI_CLUSTER_RADIUS,
|
||||
POI_CLUSTER_MAX_ZOOM,
|
||||
getEnumPaletteForFeature,
|
||||
getFeatureGradient,
|
||||
} from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
import { getFeatureFillColor } from '../lib/map-utils';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { usePoiLayers } from './usePoiLayers';
|
||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||
import { PieHexExtension } from '../lib/PieHexExtension';
|
||||
|
||||
|
|
@ -45,29 +40,11 @@ interface UseDeckLayersProps {
|
|||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
theme: 'light' | 'dark';
|
||||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||
currentLocation?: { lat: number; lng: number } | null;
|
||||
bounds?: Bounds | null;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
}
|
||||
|
||||
interface PopupInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
category: string;
|
||||
group: string;
|
||||
emoji: string;
|
||||
id: string;
|
||||
isCluster?: boolean;
|
||||
clusterCount?: number;
|
||||
}
|
||||
|
||||
interface ClusterPoint {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: number;
|
||||
clusterId: number;
|
||||
}
|
||||
|
||||
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
|
||||
function distToRatios(dist: unknown): number[] {
|
||||
if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
|
@ -95,10 +72,10 @@ export function useDeckLayers({
|
|||
onHexagonHover,
|
||||
theme,
|
||||
selectedPostcodeGeometry,
|
||||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries = [],
|
||||
}: UseDeckLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -114,6 +91,7 @@ export function useDeckLayers({
|
|||
|
||||
const isDark = theme === 'dark';
|
||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
|
||||
|
||||
// --- Refs for deck.gl accessors ---
|
||||
const viewFeatureRef = useRef(viewFeature);
|
||||
|
|
@ -126,6 +104,8 @@ export function useDeckLayers({
|
|||
isDarkRef.current = isDark;
|
||||
const densityGradientRef = useRef(densityGradient);
|
||||
densityGradientRef.current = densityGradient;
|
||||
const featureGradientRef = useRef(getFeatureGradient(viewFeature));
|
||||
featureGradientRef.current = getFeatureGradient(viewFeature);
|
||||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||
selectedHexagonIdRef.current = selectedHexagonId;
|
||||
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
||||
|
|
@ -148,9 +128,7 @@ export function useDeckLayers({
|
|||
: 0;
|
||||
|
||||
// Per-feature color palette (uses overrides when defined)
|
||||
const enumPaletteRef = useRef(
|
||||
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
|
||||
);
|
||||
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values));
|
||||
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
|
||||
|
||||
const countRange = useMemo(() => {
|
||||
|
|
@ -231,52 +209,6 @@ export function useDeckLayers({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: info.object.name,
|
||||
category: info.object.category,
|
||||
group: info.object.group,
|
||||
emoji: info.object.emoji,
|
||||
id: info.object.id,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||
handlePoiHoverRef.current = handlePoiHover;
|
||||
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: `${info.object.count} places`,
|
||||
category: 'Zoom in to see details',
|
||||
group: '',
|
||||
emoji: '',
|
||||
id: '',
|
||||
isCluster: true,
|
||||
clusterCount: info.object.count,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClusterHoverRef = useRef(handleClusterHover);
|
||||
handleClusterHoverRef.current = handleClusterHover;
|
||||
const stableClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||
handleClusterHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
||||
const pc = info.object?.properties?.postcode;
|
||||
|
|
@ -380,7 +312,10 @@ export function useDeckLayers({
|
|||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
255
|
||||
255,
|
||||
0,
|
||||
undefined,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +334,8 @@ export function useDeckLayers({
|
|||
dark,
|
||||
255,
|
||||
enumCountRef.current,
|
||||
enumPaletteRef.current
|
||||
enumPaletteRef.current,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -481,7 +417,10 @@ export function useDeckLayers({
|
|||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
180
|
||||
180,
|
||||
0,
|
||||
undefined,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -501,7 +440,8 @@ export function useDeckLayers({
|
|||
dark,
|
||||
180,
|
||||
enumCountRef.current,
|
||||
enumPaletteRef.current
|
||||
enumPaletteRef.current,
|
||||
featureGradientRef.current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -576,148 +516,6 @@ export function useDeckLayers({
|
|||
[postcodeData, theme]
|
||||
);
|
||||
|
||||
// --- POI clustering ---
|
||||
const clusterIndex = useMemo(() => {
|
||||
if (pois.length === 0) return null;
|
||||
const index = new Supercluster<POI>({
|
||||
radius: POI_CLUSTER_RADIUS,
|
||||
maxZoom: POI_CLUSTER_MAX_ZOOM,
|
||||
});
|
||||
const features: Supercluster.PointFeature<POI>[] = pois.map((poi) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [poi.lng, poi.lat] },
|
||||
properties: poi,
|
||||
}));
|
||||
index.load(features);
|
||||
return index;
|
||||
}, [pois]);
|
||||
|
||||
const clusterZoom = Math.floor(zoom);
|
||||
const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
|
||||
|
||||
const { visiblePois, clusters } = useMemo(() => {
|
||||
if (!clusterIndex || pois.length === 0) {
|
||||
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allFeatures = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
|
||||
const individual: POI[] = [];
|
||||
const clusterPoints: ClusterPoint[] = [];
|
||||
for (const feature of allFeatures) {
|
||||
if (feature.properties.cluster) {
|
||||
clusterPoints.push({
|
||||
lng: feature.geometry.coordinates[0],
|
||||
lat: feature.geometry.coordinates[1],
|
||||
count: feature.properties.point_count,
|
||||
clusterId: feature.properties.cluster_id,
|
||||
});
|
||||
} else {
|
||||
const poi = feature.properties as POI;
|
||||
if (!showMinorPois && MINOR_POI_CATEGORIES.has(poi.category)) continue;
|
||||
individual.push(poi);
|
||||
}
|
||||
}
|
||||
return { visiblePois: individual, clusters: clusterPoints };
|
||||
}, [clusterIndex, clusterZoom, showMinorPois, pois]);
|
||||
|
||||
// --- Individual POI layers (shadow → background → emoji) ---
|
||||
const poiShadowLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<POI>({
|
||||
id: 'poi-shadow',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 16,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
|
||||
pickable: false,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois, isDark]
|
||||
);
|
||||
|
||||
const poiBackgroundLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<POI>({
|
||||
id: 'poi-background',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 14,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255],
|
||||
getLineColor: (d) => {
|
||||
const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR;
|
||||
return [c[0], c[1], c[2], 255] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: 2.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
onHover: stablePoiHover,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois, isDark, stablePoiHover]
|
||||
);
|
||||
|
||||
const poiIconLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
id: 'poi-icons',
|
||||
data: visiblePois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: emojiToTwemojiUrl(d.emoji),
|
||||
width: 72,
|
||||
height: 72,
|
||||
}),
|
||||
getSize: 18,
|
||||
sizeUnits: 'pixels',
|
||||
pickable: false,
|
||||
transitions: { getSize: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[visiblePois]
|
||||
);
|
||||
|
||||
// --- Cluster layers ---
|
||||
const clusterCircleLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ClusterPoint>({
|
||||
id: 'poi-clusters',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => Math.min(30, 14 + Math.sqrt(d.count) * 2),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [5, 129, 114, 220] : [20, 184, 166, 220],
|
||||
getLineColor: [255, 255, 255, isDark ? 60 : 120],
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
onHover: stableClusterHover,
|
||||
transitions: { getRadius: { duration: 300, enter: () => [0] } },
|
||||
}),
|
||||
[clusters, isDark, stableClusterHover]
|
||||
);
|
||||
|
||||
const clusterTextLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<ClusterPoint>({
|
||||
id: 'poi-cluster-text',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 255],
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
sizeUnits: 'pixels',
|
||||
pickable: false,
|
||||
}),
|
||||
[clusters]
|
||||
);
|
||||
|
||||
// Marching ants highlight layer for selected hexagon or postcode
|
||||
const marchingAntsLayer = useMemo(() => {
|
||||
let geometry: PostcodeGeometry | null = null;
|
||||
|
|
@ -748,10 +546,25 @@ export function useDeckLayers({
|
|||
});
|
||||
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
|
||||
|
||||
const poiLayers = useMemo(
|
||||
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
|
||||
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
|
||||
);
|
||||
const currentLocationLayer = useMemo(() => {
|
||||
if (!currentLocation) return null;
|
||||
return new ScatterplotLayer<{ lat: number; lng: number; kind: 'ring' | 'dot' }>({
|
||||
id: 'current-location-dot',
|
||||
data: [
|
||||
{ ...currentLocation, kind: 'ring' },
|
||||
{ ...currentLocation, kind: 'dot' },
|
||||
],
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => (d.kind === 'ring' ? 16 : 5),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 45] : [220, 38, 38, 255]),
|
||||
getLineColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 240] : [255, 255, 255, 240]),
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
});
|
||||
}, [currentLocation]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -761,6 +574,7 @@ export function useDeckLayers({
|
|||
: [postcodeLayer, ...poiLayers]
|
||||
: [hexLayer, ...poiLayers];
|
||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
|
||||
return baseLayers;
|
||||
}, [
|
||||
usePostcodeView,
|
||||
|
|
@ -770,16 +584,15 @@ export function useDeckLayers({
|
|||
postcodeLabelsLayer,
|
||||
poiLayers,
|
||||
marchingAntsLayer,
|
||||
currentLocationLayer,
|
||||
]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverPosition(null);
|
||||
setHoveredPostcode(null);
|
||||
setPopupInfo(null);
|
||||
clearPopupInfo();
|
||||
onHexagonHoverRef.current(null);
|
||||
}, []);
|
||||
|
||||
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
||||
}, [clearPopupInfo]);
|
||||
|
||||
return {
|
||||
layers,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') return;
|
||||
pendingDragRef.current = name;
|
||||
setActiveFeature(name);
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
|
@ -112,8 +113,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (pendingDragRef.current) {
|
||||
// Click without drag — no state was changed, just clear the ref
|
||||
// Click without drag — no filter value was changed, just clear preview state.
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
return;
|
||||
}
|
||||
const af = dragActiveRef.current;
|
||||
|
|
@ -131,6 +133,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
|
||||
if (pendingDragRef.current) {
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
return null;
|
||||
}
|
||||
const dv = dragValueRef.current;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { latLngToCell } from 'h3-js';
|
||||
import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
|
|
@ -11,10 +11,13 @@ import type {
|
|||
} from '../types';
|
||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
|
||||
|
||||
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
|
||||
|
||||
interface SelectedHexagon {
|
||||
id: string;
|
||||
type: 'hexagon' | 'postcode';
|
||||
resolution: number;
|
||||
lockedResolution?: boolean;
|
||||
}
|
||||
|
||||
interface JourneyDest {
|
||||
|
|
@ -22,10 +25,18 @@ interface JourneyDest {
|
|||
slug: string;
|
||||
}
|
||||
|
||||
interface PostcodeLookupResponse {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
}
|
||||
|
||||
interface UseHexagonSelectionOptions {
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
resolution: number;
|
||||
usePostcodeView: boolean;
|
||||
/** First transit destination — used to pick the best central_postcode for journey display. */
|
||||
journeyDest?: JourneyDest | null;
|
||||
}
|
||||
|
|
@ -34,6 +45,7 @@ export function useHexagonSelection({
|
|||
filters,
|
||||
features,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
journeyDest,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||
|
|
@ -42,6 +54,7 @@ export function useHexagonSelection({
|
|||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||
const [loadingProperties, setLoadingProperties] = useState(false);
|
||||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [unfilteredAreaCount, setUnfilteredAreaCount] = useState<number | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
||||
|
|
@ -50,12 +63,18 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
||||
async (
|
||||
h3: string,
|
||||
res: number,
|
||||
signal?: AbortSignal,
|
||||
fields?: string[],
|
||||
includeFilters = true
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
h3,
|
||||
resolution: res.toString(),
|
||||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (fields) {
|
||||
params.set('fields', fields.join(';;'));
|
||||
|
|
@ -72,9 +91,9 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const fetchPostcodeStats = useCallback(
|
||||
async (postcode: string, signal?: AbortSignal) => {
|
||||
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
|
||||
const params = new URLSearchParams({ postcode });
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
|
||||
assertOk(response, 'postcode-stats');
|
||||
|
|
@ -83,6 +102,47 @@ export function useHexagonSelection({
|
|||
[filters, features]
|
||||
);
|
||||
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
|
||||
const fetchUnfilteredAreaCount = useCallback(
|
||||
async (selection: SelectedHexagon, signal?: AbortSignal) => {
|
||||
if (!filterStr) {
|
||||
setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats =
|
||||
selection.type === 'postcode'
|
||||
? await fetchPostcodeStats(selection.id, signal, false)
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||
setUnfilteredAreaCount(stats.count);
|
||||
},
|
||||
[filterStr, fetchHexagonStats, fetchPostcodeStats]
|
||||
);
|
||||
|
||||
const refreshUnfilteredAreaCount = useCallback(
|
||||
(selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => {
|
||||
if (!filterStr || filteredCount > 0) {
|
||||
setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchUnfilteredAreaCount(selection, signal).catch((error) =>
|
||||
logNonAbortError('Failed to fetch unfiltered area count', error)
|
||||
);
|
||||
},
|
||||
[filterStr, fetchUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
|
||||
const response = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(postcode)}`,
|
||||
authHeaders({ signal })
|
||||
);
|
||||
assertOk(response, 'postcode lookup');
|
||||
return (await response.json()) as PostcodeLookupResponse;
|
||||
}, []);
|
||||
|
||||
const fetchHexagonProperties = useCallback(
|
||||
async (h3: string, res: number, offset = 0) => {
|
||||
setLoadingProperties(true);
|
||||
|
|
@ -156,33 +216,42 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
|
||||
const selection = { id, type, resolution };
|
||||
trackEvent('Hexagon Click', { type });
|
||||
setSelectedHexagon({ id, type, resolution });
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
|
||||
if (isPostcode) {
|
||||
setLoadingAreaStats(true);
|
||||
fetchPostcodeStats(id)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(id, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
|
||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||
|
|
@ -232,11 +301,111 @@ export function useHexagonSelection({
|
|||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
}, []);
|
||||
|
||||
// Keep the selected area aligned with the active map view as zoom changes.
|
||||
useEffect(() => {
|
||||
if (!selectedHexagon) return;
|
||||
const selection = selectedHexagon;
|
||||
const shouldSync =
|
||||
(usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
areaStats?.central_postcode != null) ||
|
||||
(!usePostcodeView && selection.type === 'postcode') ||
|
||||
(!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
selection.resolution !== resolution);
|
||||
if (!shouldSync) return;
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
const refreshProperties = (selection: SelectedHexagon) => {
|
||||
if (rightPaneTab !== 'properties') return;
|
||||
if (selection.type === 'postcode') {
|
||||
fetchPostcodeProperties(selection.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selection.id, selection.resolution, 0);
|
||||
}
|
||||
};
|
||||
|
||||
async function syncSelection() {
|
||||
let nextSelection: SelectedHexagon | null = null;
|
||||
let nextGeometry: PostcodeGeometry | null = null;
|
||||
let nextStats: HexagonStatsResponse | null = null;
|
||||
|
||||
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
|
||||
if (!areaStats?.central_postcode) return;
|
||||
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
|
||||
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
|
||||
nextGeometry = lookup.geometry;
|
||||
nextStats = await fetchPostcodeStats(lookup.postcode, controller.signal);
|
||||
} else if (!usePostcodeView && selection.type === 'postcode') {
|
||||
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
|
||||
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
|
||||
} else if (
|
||||
!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
selection.resolution !== resolution
|
||||
) {
|
||||
const nextId =
|
||||
resolution < selection.resolution
|
||||
? cellToParent(selection.id, resolution)
|
||||
: latLngToCell(...cellToLatLng(selection.id), resolution);
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled || !nextSelection || !nextStats) return;
|
||||
setSelectedHexagon(nextSelection);
|
||||
setSelectedPostcodeGeometry(nextGeometry);
|
||||
setAreaStats(nextStats);
|
||||
refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal);
|
||||
refreshProperties(nextSelection);
|
||||
}
|
||||
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
syncSelection()
|
||||
.catch((error) => {
|
||||
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAreaStats(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
selectedHexagon,
|
||||
resolution,
|
||||
usePostcodeView,
|
||||
areaStats?.central_postcode,
|
||||
fetchHexagonStats,
|
||||
fetchPostcodeStats,
|
||||
fetchPostcodeLookup,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
refreshUnfilteredAreaCount,
|
||||
rightPaneTab,
|
||||
]);
|
||||
|
||||
// Re-fetch stats when filters change while a hexagon is selected
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
const prevFilterStr = useRef(filterStr);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -261,19 +430,14 @@ export function useHexagonSelection({
|
|||
fetchStats
|
||||
.then((stats) => {
|
||||
if (cancelled) return;
|
||||
if (stats.count === 0) {
|
||||
setSelectedHexagon(null);
|
||||
setAreaStats(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
setAreaStats(stats);
|
||||
// Re-fetch properties if the properties tab is active
|
||||
if (rightPaneTab === 'properties') {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
if (rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -296,6 +460,7 @@ export function useHexagonSelection({
|
|||
rightPaneTab,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
refreshUnfilteredAreaCount,
|
||||
]);
|
||||
|
||||
const handleLocationSearch = useCallback(
|
||||
|
|
@ -304,6 +469,7 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
|
|
@ -311,18 +477,22 @@ export function useHexagonSelection({
|
|||
fetchPostcodeStats(postcode)
|
||||
.then(async (stats) => {
|
||||
if (stats.count > 0) {
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
return;
|
||||
}
|
||||
|
||||
// No properties in this postcode — fall back to hexagons
|
||||
if (lat == null || lng == null) {
|
||||
// No coordinates available, show empty postcode anyway
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -332,9 +502,11 @@ export function useHexagonSelection({
|
|||
const h3 = latLngToCell(lat, lng, res);
|
||||
const hexStats = await fetchHexagonStats(h3, res);
|
||||
if (hexStats.count > 1) {
|
||||
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: res });
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(hexStats);
|
||||
refreshUnfilteredAreaCount(selection, hexStats.count);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -342,14 +514,47 @@ export function useHexagonSelection({
|
|||
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
||||
const h3 = latLngToCell(lat, lng, 9);
|
||||
const fallbackStats = await fetchHexagonStats(h3, 9);
|
||||
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: 9 });
|
||||
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setAreaStats(fallbackStats);
|
||||
refreshUnfilteredAreaCount(selection, fallbackStats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
},
|
||||
[resolution, fetchPostcodeStats, fetchHexagonStats]
|
||||
[resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
const handleCurrentLocationSearch = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION);
|
||||
const selection = {
|
||||
id: h3,
|
||||
type: 'hexagon' as const,
|
||||
resolution: CURRENT_LOCATION_HEX_RESOLUTION,
|
||||
lockedResolution: true,
|
||||
};
|
||||
|
||||
trackEvent('Current Location Search');
|
||||
setSelectedHexagon(selection);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
||||
fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION)
|
||||
.then((stats) => {
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
},
|
||||
[fetchHexagonStats, refreshUnfilteredAreaCount]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -359,6 +564,7 @@ export function useHexagonSelection({
|
|||
loadingProperties,
|
||||
areaStats,
|
||||
loadingAreaStats,
|
||||
unfilteredAreaCount,
|
||||
hoveredHexagon,
|
||||
rightPaneTab,
|
||||
setRightPaneTab,
|
||||
|
|
@ -370,5 +576,6 @@ export function useHexagonSelection({
|
|||
handleCloseSelection,
|
||||
selectedPostcodeGeometry,
|
||||
handleLocationSearch,
|
||||
handleCurrentLocationSearch,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue