Codex changes

This commit is contained in:
Andras Schmelczer 2026-05-04 16:19:09 +01:00
parent 0bae902e08
commit d4dde21ad2
46 changed files with 4953 additions and 966 deletions

View file

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