Changes
This commit is contained in:
parent
3a3f899ea2
commit
128b3191e7
68 changed files with 28060 additions and 1152 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(',');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
123
frontend/src/hooks/useLocationSearch.ts
Normal file
123
frontend/src/hooks/useLocationSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 (0–100) 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue