This commit is contained in:
Andras Schmelczer 2026-02-14 12:53:29 +00:00
parent 3a3f899ea2
commit 128b3191e7
68 changed files with 28060 additions and 1152 deletions

View file

@ -2,24 +2,32 @@ import { useState, useCallback, useRef } from 'react';
import type { FeatureFilters } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
interface AiFiltersResult {
filters: FeatureFilters;
notes: string;
}
interface UseAiFiltersResult {
fetchAiFilters: (query: string) => Promise<FeatureFilters | null>;
fetchAiFilters: (query: string) => Promise<AiFiltersResult | null>;
loading: boolean;
error: string | null;
notes: string | null;
}
export function useAiFilters(): UseAiFiltersResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notes, setNotes] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchAiFilters = useCallback(async (query: string): Promise<FeatureFilters | null> => {
const fetchAiFilters = useCallback(async (query: string): Promise<AiFiltersResult | null> => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
setNotes(null);
try {
const url = apiUrl('ai-filters');
@ -39,8 +47,13 @@ export function useAiFilters(): UseAiFiltersResult {
}
const json = await response.json();
const result: AiFiltersResult = {
filters: json.filters as FeatureFilters,
notes: json.notes || '',
};
setNotes(result.notes || null);
setLoading(false);
return json.filters as FeatureFilters;
return result;
} catch (err) {
if (controller.signal.aborted) return null;
logNonAbortError('ai-filters', err);
@ -51,5 +64,5 @@ export function useAiFilters(): UseAiFiltersResult {
}
}, []);
return { fetchAiFilters, loading, error };
return { fetchAiFilters, loading, error, notes };
}

View file

@ -7,13 +7,14 @@ export interface AuthUser {
verified: boolean;
}
// PocketBase RecordModel stores user fields as dynamic properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function recordToUser(record: any): AuthUser {
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
if (typeof record.email !== 'string') {
throw new Error('PocketBase record missing email field');
}
return {
id: record.id || '',
email: record.email || '',
verified: record.verified || false,
id: record.id,
email: record.email,
verified: typeof record.verified === 'boolean' ? record.verified : false,
};
}

View file

@ -6,13 +6,18 @@ import type {
HexagonData,
PostcodeFeature,
PostcodeProperties,
PostcodeGeometry,
POI,
FeatureMeta,
Bounds,
} from '../types';
import type { SearchedPostcode } from '../components/map/PostcodeSearch';
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
} from './useTravelTime';
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
function osmIdToUrl(id: string): string | null {
@ -38,12 +43,10 @@ interface UseDeckLayersProps {
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
theme: 'light' | 'dark';
searchedPostcode?: SearchedPostcode | null;
selectedPostcodeGeometry?: PostcodeGeometry | null;
bounds?: Bounds | null;
travelTimeEnabled?: boolean;
travelTimeDestination?: [number, number] | null;
travelTimeColorRange?: [number, number] | null;
travelTimeRange?: [number, number] | null;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
}
export interface PopupInfo {
@ -54,6 +57,17 @@ export interface PopupInfo {
id: string;
}
/** Find the primary travel mode: first mode (in canonical order) with a destination and color range. */
function getPrimaryTravelMode(
entries: TravelTimeEntries,
colorRanges: Partial<Record<TransportMode, [number, number]>>
): TransportMode | null {
for (const mode of TRANSPORT_MODES) {
if (entries[mode]?.destination && colorRanges[mode]) return mode;
}
return null;
}
export function useDeckLayers({
data,
postcodeData,
@ -68,12 +82,10 @@ export function useDeckLayers({
onHexagonClick,
onHexagonHover,
theme,
searchedPostcode,
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEnabled = false,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
travelTimeEntries = {},
travelTimeColorRanges = {},
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -103,14 +115,17 @@ export function useDeckLayers({
const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode;
const travelTimeEnabledRef = useRef(travelTimeEnabled);
travelTimeEnabledRef.current = travelTimeEnabled;
const travelTimeDestinationRef = useRef(travelTimeDestination);
travelTimeDestinationRef.current = travelTimeDestination;
const travelTimeColorRangeRef = useRef(travelTimeColorRange);
travelTimeColorRangeRef.current = travelTimeColorRange;
const travelTimeRangeRef = useRef(travelTimeRange);
travelTimeRangeRef.current = travelTimeRange;
const travelTimeEntriesRef = useRef(travelTimeEntries);
travelTimeEntriesRef.current = travelTimeEntries;
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
travelTimeColorRangesRef.current = travelTimeColorRanges;
const primaryTravelMode = useMemo(
() => getPrimaryTravelMode(travelTimeEntries, travelTimeColorRanges),
[travelTimeEntries, travelTimeColorRanges]
);
const primaryTravelModeRef = useRef(primaryTravelMode);
primaryTravelModeRef.current = primaryTravelMode;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -238,7 +253,17 @@ export function useDeckLayers({
}, []);
// --- Color triggers ---
const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}|${travelTimeDestination?.[1]}`;
// Build travel time trigger from all entries
const ttTrigger = useMemo(() => {
const parts: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
const cr = travelTimeColorRanges[mode];
parts.push(`${mode}:${entry?.destination?.[0]}|${entry?.destination?.[1]}|${cr?.[0]}|${cr?.[1]}|${entry?.timeRange?.[0]}|${entry?.timeRange?.[1]}`);
}
return parts.join(';');
}, [travelTimeEntries, travelTimeColorRanges]);
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}|${ttTrigger}`;
@ -251,17 +276,28 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
// Travel time coloring takes priority
if (travelTimeEnabledRef.current && travelTimeDestinationRef.current) {
const ttVal = d.travel_time;
const ttClr = travelTimeColorRangeRef.current;
const pm = primaryTravelModeRef.current;
const entries = travelTimeEntriesRef.current;
const colorRanges = travelTimeColorRangesRef.current;
// Travel time coloring: primary mode colors, others dim-filter
if (pm) {
const ttVal = d[`travel_time_${pm}`];
const ttClr = colorRanges[pm];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
const ttFr = travelTimeRangeRef.current;
if (ttFr && ((ttVal as number) < ttFr[0] || (ttVal as number) > ttFr[1])) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
// Check all modes with time ranges as filters (including primary)
for (const mode of TRANSPORT_MODES) {
const entry = entries[mode];
if (!entry?.timeRange) continue;
const modeVal = d[`travel_time_${mode}`];
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
}
}
if (ttClr) {
return getFeatureFillColor(
ttVal as number,
@ -464,19 +500,19 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
// Check if the searched postcode has data (passes current filters)
const searchedPostcodeHasData = useMemo(() => {
if (!searchedPostcode) return false;
return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode);
}, [searchedPostcode, postcodeData]);
// Check if the selected postcode has data (passes current filters)
const selectedPostcodeHasData = useMemo(() => {
if (!selectedPostcodeGeometry || !selectedHexagonId) return false;
return postcodeData.some((f) => f.properties.postcode === selectedHexagonId);
}, [selectedPostcodeGeometry, selectedHexagonId, postcodeData]);
// Highlight layer for searched postcode
const searchedPostcodeHighlightLayer = useMemo(() => {
if (!searchedPostcode) return null;
const hasData = searchedPostcodeHasData;
// Highlight layer for selected postcode (from search)
const selectedPostcodeHighlightLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null;
const hasData = selectedPostcodeHasData;
const feature = {
type: 'Feature' as const,
geometry: searchedPostcode.geometry,
geometry: selectedPostcodeGeometry,
properties: {},
};
return new GeoJsonLayer({
@ -494,13 +530,25 @@ export function useDeckLayers({
filled: true,
pickable: false,
});
}, [searchedPostcode, searchedPostcodeHasData]);
}, [selectedPostcodeGeometry, selectedPostcodeHasData]);
// Destination markers: one red dot per mode with a destination
const destinationMarkerData = useMemo(() => {
const points: { position: [number, number] }[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
points.push({ position: [entry.destination[1], entry.destination[0]] });
}
}
return points;
}, [travelTimeEntries]);
const destinationMarkerLayer = useMemo(() => {
if (!travelTimeEnabled || !travelTimeDestination) return null;
if (destinationMarkerData.length === 0) return null;
return new ScatterplotLayer({
id: 'travel-time-destination',
data: [{ position: [travelTimeDestination[1], travelTimeDestination[0]] }],
id: 'travel-time-destinations',
data: destinationMarkerData,
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 8,
getFillColor: [239, 68, 68, 220],
@ -511,14 +559,14 @@ export function useDeckLayers({
stroked: true,
pickable: false,
});
}, [travelTimeEnabled, travelTimeDestination]);
}, [destinationMarkerData]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (searchedPostcodeHighlightLayer) baseLayers.push(searchedPostcodeHighlightLayer);
if (selectedPostcodeHighlightLayer) baseLayers.push(selectedPostcodeHighlightLayer);
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
return baseLayers;
}, [
@ -527,7 +575,7 @@ export function useDeckLayers({
postcodeLayer,
postcodeLabelsLayer,
poiLayer,
searchedPostcodeHighlightLayer,
selectedPostcodeHighlightLayer,
destinationMarkerLayer,
]);
@ -548,5 +596,6 @@ export function useDeckLayers({
handleMouseLeave,
selectedPostcode,
hoveredPostcode,
primaryTravelMode,
};
}

View file

@ -78,7 +78,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const m = features.find((f) => f.name === n);
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
const [min, max] = value as [number, number];
return `${n}:${min}:${max}`;
const maxStr = m?.absolute && max === m.max ? 'inf' : String(max);
return `${n}:${min}:${maxStr}`;
})
.join(',');
}

View file

@ -3,10 +3,11 @@ import type {
FeatureMeta,
FeatureFilters,
Property,
PostcodeGeometry,
HexagonPropertiesResponse,
HexagonStatsResponse,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
interface SelectedHexagon {
id: string;
@ -30,6 +31,8 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] =
useState<PostcodeGeometry | null>(null);
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
@ -43,6 +46,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
params.set('fields', fields.join(','));
}
const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal }));
assertOk(response, 'hexagon-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
@ -54,6 +58,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
assertOk(response, 'postcode-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
@ -74,6 +79,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
assertOk(response, 'hexagon-properties');
const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) {
@ -84,7 +90,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
console.error('Failed to fetch properties:', err);
logNonAbortError('Failed to fetch properties', err);
} finally {
setLoadingProperties(false);
}
@ -94,6 +100,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const handleHexagonClick = useCallback(
(id: string, isPostcode = false) => {
setSelectedPostcodeGeometry(null);
if (selectedHexagon?.id === id) {
setSelectedHexagon(null);
setProperties([]);
@ -154,8 +161,27 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setSelectedPostcodeGeometry(null);
}, []);
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchPostcodeStats(postcode)
.then((stats) => setAreaStats(stats))
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
},
[resolution, fetchPostcodeStats]
);
return {
selectedHexagon,
properties,
@ -172,5 +198,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
};
}

View file

@ -0,0 +1,123 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api';
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
export function looksLikePostcode(s: string) {
return POSTCODE_RE.test(s.trim());
}
export type SearchResult =
| { type: 'postcode'; label: string }
| { type: 'place'; name: string; place_type: string; lat: number; lon: number; city?: string };
export function useLocationSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleInputChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(-1);
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
setOpen(false);
return;
}
if (looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: trimmed.toUpperCase() }]);
setOpen(true);
return;
}
if (trimmed.length < 2) {
setResults([]);
setOpen(false);
return;
}
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed, limit: '7' });
const res = await fetch(
`/api/places?${params}`,
authHeaders({ signal: controller.signal }),
);
if (!res.ok) return;
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
type: 'place' as const,
...p,
}));
setResults(placeResults);
setOpen(placeResults.length > 0);
} catch (err) {
logNonAbortError('places search', err);
}
}, 200);
}, []);
const close = useCallback(() => setOpen(false), []);
const clear = useCallback(() => {
setQuery('');
setResults([]);
setOpen(false);
setActiveIndex(-1);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < results.length) {
onSelect(results[activeIndex]);
} else if (looksLikePostcode(query)) {
onSelect({ type: 'postcode', label: query.trim().toUpperCase() });
}
} else if (e.key === 'Escape') {
setOpen(false);
}
},
[results, activeIndex, query],
);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return {
query,
results,
activeIndex,
setActiveIndex,
open,
setOpen,
handleInputChange,
handleKeyDown,
close,
clear,
};
}

View file

@ -8,9 +8,10 @@ import type {
ViewChangeParams,
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { TRANSPORT_MODES, type TransportMode, type TravelTimeEntries } from './useTravelTime';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -32,9 +33,7 @@ interface UseMapDataOptions {
activeFeature: string | null;
dragValue: [number, number] | null;
dragData: HexagonData[] | null;
travelTimeEnabled: boolean;
travelTimeDestination: [number, number] | null;
travelTimeMode: string;
travelTimeEntries: TravelTimeEntries;
}
export function useMapData({
@ -44,9 +43,7 @@ export function useMapData({
activeFeature,
dragValue,
dragData,
travelTimeEnabled,
travelTimeDestination,
travelTimeMode,
travelTimeEntries,
}: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
@ -71,6 +68,18 @@ export function useMapData({
[filters, features]
);
// Build the travel param string from entries with destinations
const travelParam = useMemo((): string => {
const segments: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
segments.push(`${entry.destination[0]},${entry.destination[1]},${mode}`);
}
}
return segments.join('|');
}, [travelTimeEntries]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
if (!bounds) return;
@ -100,6 +109,7 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
assertOk(res, 'postcodes');
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features);
setRawData([]);
@ -110,9 +120,8 @@ export function useMapData({
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || '');
if (travelTimeEnabled && travelTimeDestination) {
params.set('destination', `${travelTimeDestination[0]},${travelTimeDestination[1]}`);
params.set('mode', travelTimeMode);
if (travelParam) {
params.set('travel', travelParam);
}
const res = await fetch(
apiUrl('hexagons', params),
@ -120,6 +129,7 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
assertOk(res, 'hexagons');
const json: ApiResponse = await res.json();
setRawData(json.features);
setPostcodeData([]);
@ -136,7 +146,7 @@ export function useMapData({
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelTimeEnabled, travelTimeDestination, travelTimeMode]);
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
const data = dragData ?? rawData;
@ -159,7 +169,7 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${viewFeature}`] ?? feat.properties[`min_${viewFeature}`];
const val = feat.properties[`avg_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
} else {
@ -170,7 +180,7 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${viewFeature}`] ?? item[`min_${viewFeature}`];
const val = item[`avg_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
}
@ -197,26 +207,32 @@ export function useMapData({
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
// Color range for travel time (computed from response data)
const travelTimeColorRange = useMemo((): [number, number] | null => {
if (!travelTimeEnabled || !travelTimeDestination) return null;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
// Color ranges for travel time per mode (computed from response data)
const travelTimeColorRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (!entry?.destination) continue;
const fieldName = `travel_time_${mode}`;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
const val = item.travel_time;
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges[mode] = [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [travelTimeEnabled, travelTimeDestination, data, bounds]);
return ranges;
}, [travelTimeEntries, data, bounds]);
const handleViewChange = useCallback(
({
@ -257,7 +273,7 @@ export function useMapData({
currentView,
usePostcodeView,
colorRange,
travelTimeColorRange,
travelTimeColorRanges,
handleViewChange,
setInitialView,
};

View file

@ -1,67 +1,83 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
export interface TravelTimeState {
enabled: boolean;
export const TRANSPORT_MODES: TransportMode[] = ['car', 'bicycle', 'walking', 'transit'];
export const MODE_LABELS: Record<TransportMode, string> = {
car: 'Car',
bicycle: 'Bicycle',
walking: 'Walking',
transit: 'Transit',
};
export interface TravelTimeEntry {
destination: [number, number] | null; // [lat, lon]
destinationLabel: string;
mode: TransportMode;
timeRange: [number, number] | null;
}
export type TravelTimeEntries = Partial<Record<TransportMode, TravelTimeEntry>>;
export interface TravelTimeInitial {
destination?: [number, number];
destinationLabel?: string;
mode?: TransportMode;
timeRange?: [number, number];
entries?: TravelTimeEntries;
}
export function useTravelTime(initial?: TravelTimeInitial) {
const [enabled, setEnabled] = useState(!!initial?.destination);
const [destination, setDestination] = useState<[number, number] | null>(
initial?.destination ?? null
);
const [destinationLabel, setDestinationLabel] = useState(initial?.destinationLabel ?? '');
const [mode, setMode] = useState<TransportMode>(initial?.mode ?? 'car');
const [timeRange, setTimeRange] = useState<[number, number] | null>(
initial?.timeRange ?? null
const [entries, setEntries] = useState<TravelTimeEntries>(initial?.entries ?? {});
const activeModes = useMemo(
() => TRANSPORT_MODES.filter((m) => m in entries),
[entries]
);
const handleEnable = useCallback(() => {
setEnabled(true);
const modesWithDestination = useMemo(
() => TRANSPORT_MODES.filter((m) => entries[m]?.destination != null),
[entries]
);
const handleEnableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => ({
...prev,
[mode]: { destination: null, destinationLabel: '', timeRange: null },
}));
}, []);
const handleDisable = useCallback(() => {
setEnabled(false);
setDestination(null);
setDestinationLabel('');
setTimeRange(null);
const handleDisableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => {
const next = { ...prev };
delete next[mode];
return next;
});
}, []);
const handleSetDestination = useCallback((lat: number, lon: number, label: string) => {
setDestination([lat, lon]);
setDestinationLabel(label);
}, []);
const handleSetDestination = useCallback(
(mode: TransportMode, lat: number, lon: number, label: string) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], destination: [lat, lon] as [number, number], destinationLabel: label },
}));
},
[]
);
const handleModeChange = useCallback((newMode: TransportMode) => {
setMode(newMode);
}, []);
const handleTimeRangeChange = useCallback((range: [number, number]) => {
setTimeRange(range);
}, []);
const handleTimeRangeChange = useCallback(
(mode: TransportMode, range: [number, number]) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], timeRange: range },
}));
},
[]
);
return {
enabled,
destination,
destinationLabel,
mode,
timeRange,
handleEnable,
handleDisable,
entries,
activeModes,
modesWithDestination,
handleEnableMode,
handleDisableMode,
handleSetDestination,
handleModeChange,
handleTimeRangeChange,
};
}

View file

@ -1,15 +1,7 @@
import { useEffect, useRef } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state';
import type { TransportMode } from './useTravelTime';
export interface TravelTimeUrlState {
enabled: boolean;
destination: [number, number] | null;
destinationLabel: string;
mode: TransportMode;
timeRange: [number, number] | null;
}
import type { TravelTimeEntries } from './useTravelTime';
const URL_DEBOUNCE_MS = 300;
@ -19,7 +11,7 @@ export function useUrlSync(
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTime?: TravelTimeUrlState
travelTimeEntries?: TravelTimeEntries
) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -34,7 +26,7 @@ export function useUrlSync(
features,
selectedPOICategories,
rightPaneTab,
travelTime
travelTimeEntries
);
const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
@ -44,5 +36,5 @@ export function useUrlSync(
return () => {
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
};
}, [currentView, filters, features, selectedPOICategories, rightPaneTab, travelTime]);
}, [currentView, filters, features, selectedPOICategories, rightPaneTab, travelTimeEntries]);
}