vibes
This commit is contained in:
parent
39ef5c6646
commit
c995f12f8b
78 changed files with 4830 additions and 1619 deletions
|
|
@ -46,6 +46,7 @@ interface UseDeckLayersProps {
|
|||
currentLocation?: { lat: number; lng: number } | null;
|
||||
bounds?: Bounds | null;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
mapDataBeforeId: string;
|
||||
}
|
||||
|
||||
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
|
||||
|
|
@ -88,6 +89,7 @@ export function useDeckLayers({
|
|||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries = [],
|
||||
mapDataBeforeId,
|
||||
}: UseDeckLayersProps) {
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
|
|
@ -419,10 +421,10 @@ export function useDeckLayers({
|
|||
highPrecision: true,
|
||||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
beforeId: 'landuse_park',
|
||||
beforeId: mapDataBeforeId,
|
||||
...pieProps,
|
||||
});
|
||||
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
|
||||
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover, mapDataBeforeId]);
|
||||
|
||||
const postcodeLayer = useMemo(() => {
|
||||
const isEnum = enumCountRef.current > 0;
|
||||
|
|
@ -578,9 +580,15 @@ export function useDeckLayers({
|
|||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'landuse_park',
|
||||
beforeId: mapDataBeforeId,
|
||||
});
|
||||
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
|
||||
}, [
|
||||
postcodeData,
|
||||
postcodeColorTrigger,
|
||||
handlePostcodeClick,
|
||||
handlePostcodeHoverCallback,
|
||||
mapDataBeforeId,
|
||||
]);
|
||||
|
||||
const labeledPostcodeData = useMemo(
|
||||
() => postcodeData.filter((feature) => feature.properties.count > 0),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { useHexagonSelection } from './useHexagonSelection';
|
||||
import type { FeatureMeta, HexagonStatsResponse, PostcodeGeometry } from '../types';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
vi.mock('../lib/pocketbase', () => ({
|
||||
default: { authStore: { isValid: false, token: '' } },
|
||||
|
|
@ -41,9 +42,24 @@ function jsonResponse(body: unknown): Response {
|
|||
});
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('useHexagonSelection', () => {
|
||||
const requests: string[] = [];
|
||||
const features: FeatureMeta[] = [{ name: 'Price', type: 'numeric', min: 0, max: 100 }];
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Price', type: 'numeric', min: 0, max: 100 },
|
||||
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
|
||||
{ name: 'Estimated current price', type: 'numeric', min: 0, max: 1_000_000 },
|
||||
{ name: 'Price per sqm', type: 'numeric', min: 0, max: 20_000 },
|
||||
{ name: 'Est. price per sqm', type: 'numeric', min: 0, max: 20_000 },
|
||||
{ name: 'Total floor area (sqm)', type: 'numeric', min: 0, max: 500 },
|
||||
{ name: 'Number of bedrooms & living rooms', type: 'numeric', min: 0, max: 12 },
|
||||
{ name: 'Construction year', type: 'numeric', min: 0, max: 2026 },
|
||||
{ name: 'Date of last transaction', type: 'numeric', min: 0, max: 2026 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
requests.length = 0;
|
||||
|
|
@ -64,6 +80,18 @@ describe('useHexagonSelection', () => {
|
|||
return Promise.resolve(jsonResponse(stats(12)));
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/postcode-properties') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/hexagon-properties') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
})
|
||||
);
|
||||
|
|
@ -201,4 +229,203 @@ describe('useHexagonSelection', () => {
|
|||
expect(requests.some((url) => url.startsWith('/api/postcode/'))).toBe(false);
|
||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||
});
|
||||
|
||||
it('passes area stat field projections to stats requests', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: {},
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: false,
|
||||
travelTimeEntries: [],
|
||||
areaStatsFields: ['Price'],
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleHexagonClick('89195da49abffff');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(12);
|
||||
});
|
||||
|
||||
const statsRequest = requests.find((url) => url.startsWith('/api/hexagon-stats'));
|
||||
expect(statsRequest).toBeDefined();
|
||||
expect(new URL(statsRequest!, 'http://localhost').searchParams.get('fields')).toBe('Price');
|
||||
});
|
||||
|
||||
it('keeps existing area stats visible while area field projections refetch', async () => {
|
||||
const pendingStatsRequests: Array<{ resolve: (response: Response) => void }> = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
requests.push(`${url.pathname}${url.search}`);
|
||||
|
||||
if (url.pathname === '/api/hexagon-stats') {
|
||||
return new Promise<Response>((resolve) => {
|
||||
pendingStatsRequests.push({ resolve });
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
})
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ areaStatsFields }: { areaStatsFields: string[] }) =>
|
||||
useHexagonSelection({
|
||||
filters: {},
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: false,
|
||||
travelTimeEntries: [],
|
||||
areaStatsFields,
|
||||
}),
|
||||
{ initialProps: { areaStatsFields: [] as string[] } }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleHexagonClick('89195da49abffff');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pendingStatsRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
pendingStatsRequests[0].resolve(jsonResponse(stats(12)));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(12);
|
||||
expect(result.current.loadingAreaStats).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rerender({ areaStatsFields: ['Price'] });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pendingStatsRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current.loadingAreaStats).toBe(true);
|
||||
expect(result.current.areaStats?.count).toBe(12);
|
||||
|
||||
const refetchRequest = requests.filter((url) => url.startsWith('/api/hexagon-stats'))[1];
|
||||
expect(new URL(refetchRequest, 'http://localhost').searchParams.get('fields')).toBe('Price');
|
||||
|
||||
await act(async () => {
|
||||
pendingStatsRequests[1].resolve(jsonResponse(stats(12)));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loadingAreaStats).toBe(false);
|
||||
expect(result.current.areaStats?.count).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes property card field projections to property requests', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: {},
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: true,
|
||||
travelTimeEntries: [],
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(4);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handlePropertiesTabClick();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests.some((url) => url.startsWith('/api/postcode-properties'))).toBe(true);
|
||||
});
|
||||
|
||||
const propertiesRequest = requests.find((url) => url.startsWith('/api/postcode-properties'));
|
||||
const fieldsParam = new URL(propertiesRequest!, 'http://localhost').searchParams.get('fields');
|
||||
expect(fieldsParam).toContain('Last known price');
|
||||
expect(fieldsParam).toContain('Date of last transaction');
|
||||
expect(fieldsParam).not.toContain('Distance to nearest amenity');
|
||||
});
|
||||
|
||||
it('refetches property requests when stats basis switches to all properties', async () => {
|
||||
const propertyFilters = { Price: [0, 50] as [number, number] };
|
||||
const travelTimeEntries: TravelTimeEntry[] = [
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'kings-cross',
|
||||
label: 'Kings Cross',
|
||||
timeRange: [0, 30],
|
||||
useBest: false,
|
||||
},
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: propertyFilters,
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: true,
|
||||
travelTimeEntries,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handlePropertiesTabClick();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests.filter((url) => url.startsWith('/api/postcode-properties')).length).toBe(1);
|
||||
});
|
||||
|
||||
const filteredPropertiesRequest = requests.find((url) =>
|
||||
url.startsWith('/api/postcode-properties')
|
||||
);
|
||||
const filteredParams = new URL(filteredPropertiesRequest!, 'http://localhost').searchParams;
|
||||
expect(filteredParams.has('filters')).toBe(true);
|
||||
expect(filteredParams.has('travel')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setAreaStatsUseFilters(false);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(4);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(requests.filter((url) => url.startsWith('/api/postcode-properties')).length).toBe(2);
|
||||
});
|
||||
|
||||
const propertyRequests = requests.filter((url) => url.startsWith('/api/postcode-properties'));
|
||||
const allPropertiesRequest = propertyRequests[propertyRequests.length - 1];
|
||||
const allPropertiesParams = new URL(allPropertiesRequest, 'http://localhost').searchParams;
|
||||
expect(allPropertiesParams.has('filters')).toBe(false);
|
||||
expect(allPropertiesParams.has('travel')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,11 +42,23 @@ interface UseHexagonSelectionOptions {
|
|||
resolution: number;
|
||||
usePostcodeView: boolean;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
areaStatsFields?: string[];
|
||||
shareCode?: string;
|
||||
/** First transit destination — used to pick the best central_postcode for journey display. */
|
||||
journeyDest?: JourneyDest | null;
|
||||
}
|
||||
|
||||
const PROPERTY_PANE_FIELDS = [
|
||||
'Last known price',
|
||||
'Estimated current price',
|
||||
'Price per sqm',
|
||||
'Est. price per sqm',
|
||||
'Total floor area (sqm)',
|
||||
'Number of bedrooms & living rooms',
|
||||
'Construction year',
|
||||
'Date of last transaction',
|
||||
];
|
||||
|
||||
export function useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
|
|
@ -54,6 +66,7 @@ export function useHexagonSelection({
|
|||
resolution,
|
||||
usePostcodeView,
|
||||
travelTimeEntries,
|
||||
areaStatsFields,
|
||||
shareCode,
|
||||
journeyDest,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
|
|
@ -93,6 +106,11 @@ export function useHexagonSelection({
|
|||
}, []);
|
||||
|
||||
const travelParam = useMemo(() => buildTravelParam(travelTimeEntries), [travelTimeEntries]);
|
||||
const areaStatsFieldsKey = useMemo(() => areaStatsFields?.join(';;') ?? '', [areaStatsFields]);
|
||||
const propertyPaneFieldsParam = useMemo(() => {
|
||||
const availableFields = new Set(features.map((feature) => feature.name));
|
||||
return PROPERTY_PANE_FIELDS.filter((field) => availableFields.has(field)).join(';;');
|
||||
}, [features]);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (
|
||||
|
|
@ -110,8 +128,9 @@ export function useHexagonSelection({
|
|||
if (filterStr) params.append('filters', filterStr);
|
||||
if (includeFilters && travelParam) params.set('travel', travelParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
if (fields) {
|
||||
params.set('fields', fields.join(';;'));
|
||||
const requestedFields = fields ?? areaStatsFields;
|
||||
if (requestedFields) {
|
||||
params.set('fields', requestedFields.join(';;'));
|
||||
}
|
||||
if (journeyDest) {
|
||||
params.set('journey_mode', journeyDest.mode);
|
||||
|
|
@ -121,27 +140,34 @@ export function useHexagonSelection({
|
|||
assertOk(response, 'hexagon-stats');
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features, journeyDest, shareCode, travelParam]
|
||||
[areaStatsFields, filters, features, journeyDest, shareCode, travelParam]
|
||||
);
|
||||
|
||||
const fetchPostcodeStats = useCallback(
|
||||
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
|
||||
async (
|
||||
postcode: string,
|
||||
signal?: AbortSignal,
|
||||
includeFilters = true,
|
||||
fields?: string[]
|
||||
) => {
|
||||
const params = new URLSearchParams({ postcode });
|
||||
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (includeFilters && travelParam) params.set('travel', travelParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
const requestedFields = fields ?? areaStatsFields;
|
||||
if (requestedFields) params.set('fields', requestedFields.join(';;'));
|
||||
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
|
||||
assertOk(response, 'postcode-stats');
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features, shareCode, travelParam]
|
||||
[areaStatsFields, filters, features, shareCode, travelParam]
|
||||
);
|
||||
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
const hasStatsFilters = filterStr.length > 0 || travelParam.length > 0;
|
||||
const journeyKey = journeyDest ? `${journeyDest.mode}:${journeyDest.slug}` : '';
|
||||
const areaStatsQueryKey = useMemo(
|
||||
const areaStatsDataKey = useMemo(
|
||||
() =>
|
||||
[
|
||||
areaStatsUseFilters ? 'filtered' : 'all',
|
||||
|
|
@ -152,6 +178,10 @@ export function useHexagonSelection({
|
|||
].join('|'),
|
||||
[areaStatsUseFilters, filterStr, journeyKey, shareCode, travelParam]
|
||||
);
|
||||
const areaStatsQueryKey = useMemo(
|
||||
() => [areaStatsDataKey, areaStatsFieldsKey].join('|'),
|
||||
[areaStatsDataKey, areaStatsFieldsKey]
|
||||
);
|
||||
|
||||
const fetchUnfilteredAreaCount = useCallback(
|
||||
async (selection: SelectedHexagon, requestId: number, signal?: AbortSignal) => {
|
||||
|
|
@ -162,8 +192,8 @@ export function useHexagonSelection({
|
|||
|
||||
const stats =
|
||||
selection.type === 'postcode'
|
||||
? await fetchPostcodeStats(selection.id, signal, false)
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||
? await fetchPostcodeStats(selection.id, signal, false, [])
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, [], false);
|
||||
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(stats.count);
|
||||
},
|
||||
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters, isCurrentAreaRequest]
|
||||
|
|
@ -209,9 +239,10 @@ export function useHexagonSelection({
|
|||
offset: offset.toString(),
|
||||
});
|
||||
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = areaStatsUseFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
if (areaStatsUseFilters && travelParam) params.set('travel', travelParam);
|
||||
params.set('fields', propertyPaneFieldsParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
||||
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
|
||||
|
|
@ -235,8 +266,10 @@ export function useHexagonSelection({
|
|||
[
|
||||
filters,
|
||||
features,
|
||||
areaStatsUseFilters,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentPropertyRequest,
|
||||
propertyPaneFieldsParam,
|
||||
shareCode,
|
||||
travelParam,
|
||||
]
|
||||
|
|
@ -255,9 +288,10 @@ export function useHexagonSelection({
|
|||
params.set('focus_address', focusAddress);
|
||||
}
|
||||
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = areaStatsUseFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
if (areaStatsUseFilters && travelParam) params.set('travel', travelParam);
|
||||
params.set('fields', propertyPaneFieldsParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
||||
const response = await fetch(apiUrl('postcode-properties', params), authHeaders());
|
||||
|
|
@ -281,8 +315,10 @@ export function useHexagonSelection({
|
|||
[
|
||||
filters,
|
||||
features,
|
||||
areaStatsUseFilters,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentPropertyRequest,
|
||||
propertyPaneFieldsParam,
|
||||
shareCode,
|
||||
travelParam,
|
||||
]
|
||||
|
|
@ -546,25 +582,34 @@ export function useHexagonSelection({
|
|||
rightPaneTab,
|
||||
]);
|
||||
|
||||
// Re-fetch stats when filters or travel constraints change while an area is selected
|
||||
const prevAreaStatsQueryKey = useRef(areaStatsQueryKey);
|
||||
// Re-fetch stats when the selected stats basis or requested field projection changes.
|
||||
const prevAreaStatsQueryRef = useRef({
|
||||
dataKey: areaStatsDataKey,
|
||||
queryKey: areaStatsQueryKey,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (prevAreaStatsQueryKey.current === areaStatsQueryKey) return;
|
||||
prevAreaStatsQueryKey.current = areaStatsQueryKey;
|
||||
const previousQuery = prevAreaStatsQueryRef.current;
|
||||
if (previousQuery.queryKey === areaStatsQueryKey) return;
|
||||
prevAreaStatsQueryRef.current = {
|
||||
dataKey: areaStatsDataKey,
|
||||
queryKey: areaStatsQueryKey,
|
||||
};
|
||||
|
||||
if (!selectedHexagon) return;
|
||||
const fieldProjectionOnlyChanged = previousQuery.dataKey === areaStatsDataKey;
|
||||
|
||||
// Clear stale properties
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
invalidatePropertyRequests();
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
if (!fieldProjectionOnlyChanged) {
|
||||
// Clear stale properties
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
invalidatePropertyRequests();
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
}
|
||||
|
||||
setLoadingAreaStats(true);
|
||||
let cancelled = false;
|
||||
const requestId = invalidateAreaRequests();
|
||||
|
||||
const fetchStats =
|
||||
|
|
@ -580,11 +625,11 @@ export function useHexagonSelection({
|
|||
|
||||
fetchStats
|
||||
.then((stats) => {
|
||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters, requestId);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
|
||||
// Re-fetch properties if the properties tab is active and the selected basis has matches.
|
||||
if (!fieldProjectionOnlyChanged && rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
|
|
@ -593,17 +638,14 @@ export function useHexagonSelection({
|
|||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
logNonAbortError('Failed to refresh stats', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
areaStatsDataKey,
|
||||
areaStatsQueryKey,
|
||||
selectedHexagon,
|
||||
fetchHexagonStats,
|
||||
|
|
|
|||
|
|
@ -1,42 +1,211 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import Supercluster from 'supercluster';
|
||||
|
||||
import type { ActualListing } from '../types';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
const PRICE_LABEL_MIN_ZOOM = 14;
|
||||
const ADDRESS_LABEL_MIN_ZOOM = 16;
|
||||
const LISTING_CLUSTER_RADIUS = 18;
|
||||
const LISTING_CLUSTER_MAX_ZOOM = 24;
|
||||
const LISTING_CLUSTER_POPUP_LIMIT = 30;
|
||||
const LISTING_SPIDERFY_LIMIT = 12;
|
||||
const TILE_SIZE = 512;
|
||||
|
||||
export interface ListingPopupInfo {
|
||||
interface SingleListingPopupInfo {
|
||||
mode: 'single';
|
||||
x: number;
|
||||
y: number;
|
||||
listing: ActualListing;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
interface ListingClusterPopupInfo {
|
||||
mode: 'cluster';
|
||||
x: number;
|
||||
y: number;
|
||||
count: number;
|
||||
listings: ActualListing[];
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export type ListingPopupInfo = SingleListingPopupInfo | ListingClusterPopupInfo;
|
||||
|
||||
interface UseListingLayersProps {
|
||||
listings: ActualListing[];
|
||||
zoom: number;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
interface ListingClusterPoint {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: number;
|
||||
clusterId: number;
|
||||
}
|
||||
|
||||
interface ExpandedListingMarker {
|
||||
listing: ActualListing;
|
||||
lng: number;
|
||||
lat: number;
|
||||
anchorLng: number;
|
||||
anchorLat: number;
|
||||
}
|
||||
|
||||
function formatShortPrice(price: number): string {
|
||||
if (price >= 1_000_000) return `£${(price / 1_000_000).toFixed(price >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (price >= 1_000) return `£${Math.round(price / 1_000)}k`;
|
||||
return `£${price}`;
|
||||
}
|
||||
|
||||
function formatClusterCount(count: number): string {
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
function compareListingsForDisplay(left: ActualListing, right: ActualListing): number {
|
||||
const dateCompare = (right.listing_date_iso ?? '').localeCompare(left.listing_date_iso ?? '');
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
return (right.asking_price ?? 0) - (left.asking_price ?? 0);
|
||||
}
|
||||
|
||||
function getClusterListings(
|
||||
index: Supercluster<ActualListing>,
|
||||
clusterId: number,
|
||||
limit: number
|
||||
): ActualListing[] {
|
||||
return index
|
||||
.getLeaves(clusterId, limit, 0)
|
||||
.map((feature) => feature.properties)
|
||||
.sort(compareListingsForDisplay);
|
||||
}
|
||||
|
||||
function offsetLngLat(
|
||||
lng: number,
|
||||
lat: number,
|
||||
dxPixels: number,
|
||||
dyPixels: number,
|
||||
zoom: number
|
||||
): [number, number] {
|
||||
const worldSize = TILE_SIZE * Math.pow(2, zoom);
|
||||
const lngPerPixel = 360 / worldSize;
|
||||
const cosLat = Math.max(0.25, Math.cos((lat * Math.PI) / 180));
|
||||
const latPerPixel = lngPerPixel / cosLat;
|
||||
return [lng + dxPixels * lngPerPixel, lat - dyPixels * latPerPixel];
|
||||
}
|
||||
|
||||
function spiderfyPosition(
|
||||
lng: number,
|
||||
lat: number,
|
||||
index: number,
|
||||
total: number,
|
||||
zoom: number
|
||||
): [number, number] {
|
||||
if (total <= 1) return [lng, lat];
|
||||
const radius = total <= 6 ? 24 : 32;
|
||||
const angle = -Math.PI / 2 + (index / total) * Math.PI * 2;
|
||||
return offsetLngLat(lng, lat, Math.cos(angle) * radius, Math.sin(angle) * radius, zoom);
|
||||
}
|
||||
|
||||
export function useListingLayers({ listings, zoom, isDark }: UseListingLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
|
||||
const [selectedCluster, setSelectedCluster] = useState<ListingClusterPoint | null>(null);
|
||||
|
||||
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({ x: info.x, y: info.y, listing: info.object });
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
useEffect(() => {
|
||||
setSelectedCluster(null);
|
||||
setPopupInfo(null);
|
||||
}, [listings]);
|
||||
|
||||
const clusterIndex = useMemo(() => {
|
||||
if (listings.length === 0) return null;
|
||||
const index = new Supercluster<ActualListing>({
|
||||
radius: LISTING_CLUSTER_RADIUS,
|
||||
maxZoom: LISTING_CLUSTER_MAX_ZOOM,
|
||||
});
|
||||
const features: Supercluster.PointFeature<ActualListing>[] = listings
|
||||
.filter((listing) => Number.isFinite(listing.lat) && Number.isFinite(listing.lon))
|
||||
.map((listing) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [listing.lon, listing.lat] },
|
||||
properties: listing,
|
||||
}));
|
||||
index.load(features);
|
||||
return index;
|
||||
}, [listings]);
|
||||
|
||||
const clusterIndexRef = useRef(clusterIndex);
|
||||
clusterIndexRef.current = clusterIndex;
|
||||
|
||||
const clusterZoom = Math.min(Math.floor(zoom), LISTING_CLUSTER_MAX_ZOOM);
|
||||
const { visibleListings, clusters } = useMemo(() => {
|
||||
if (!clusterIndex) {
|
||||
return {
|
||||
visibleListings: [] as ActualListing[],
|
||||
clusters: [] as ListingClusterPoint[],
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const features = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
|
||||
const individual: ActualListing[] = [];
|
||||
const clusterPoints: ListingClusterPoint[] = [];
|
||||
for (const feature of features) {
|
||||
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 {
|
||||
individual.push(feature.properties as ActualListing);
|
||||
}
|
||||
}
|
||||
return { visibleListings: individual, clusters: clusterPoints };
|
||||
}, [clusterIndex, clusterZoom]);
|
||||
|
||||
const expandedListings = useMemo(() => {
|
||||
if (!selectedCluster || !clusterIndex) return [];
|
||||
const leaves = getClusterListings(
|
||||
clusterIndex,
|
||||
selectedCluster.clusterId,
|
||||
LISTING_SPIDERFY_LIMIT
|
||||
);
|
||||
return leaves.map((listing, index) => {
|
||||
const [lng, lat] = spiderfyPosition(
|
||||
selectedCluster.lng,
|
||||
selectedCluster.lat,
|
||||
index,
|
||||
leaves.length,
|
||||
zoom
|
||||
);
|
||||
return {
|
||||
listing,
|
||||
lng,
|
||||
lat,
|
||||
anchorLng: selectedCluster.lng,
|
||||
anchorLat: selectedCluster.lat,
|
||||
};
|
||||
});
|
||||
}, [clusterIndex, selectedCluster, zoom]);
|
||||
|
||||
const clearUnlockedPopup = useCallback(() => {
|
||||
setPopupInfo((current) => (current?.locked ? current : null));
|
||||
}, []);
|
||||
|
||||
const handleHover = useCallback(
|
||||
(info: PickingInfo<ActualListing>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({ mode: 'single', x: info.x, y: info.y, listing: info.object });
|
||||
} else {
|
||||
clearUnlockedPopup();
|
||||
}
|
||||
},
|
||||
[clearUnlockedPopup]
|
||||
);
|
||||
|
||||
const handleClick = useCallback((info: PickingInfo<ActualListing>) => {
|
||||
const url = info.object?.listing_url;
|
||||
if (!url) return;
|
||||
|
|
@ -58,25 +227,115 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
[]
|
||||
);
|
||||
|
||||
const handleExpandedHover = useCallback(
|
||||
(info: PickingInfo<ExpandedListingMarker>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({ mode: 'single', x: info.x, y: info.y, listing: info.object.listing });
|
||||
} else {
|
||||
clearUnlockedPopup();
|
||||
}
|
||||
},
|
||||
[clearUnlockedPopup]
|
||||
);
|
||||
|
||||
const handleExpandedClick = useCallback((info: PickingInfo<ExpandedListingMarker>) => {
|
||||
const url = info.object?.listing.listing_url;
|
||||
if (!url) return;
|
||||
trackEvent('Actual Listing Click', { url, source: 'cluster_expanded' });
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
const handleExpandedHoverRef = useRef(handleExpandedHover);
|
||||
handleExpandedHoverRef.current = handleExpandedHover;
|
||||
const stableExpandedHover = useCallback(
|
||||
(info: PickingInfo<ExpandedListingMarker>) => handleExpandedHoverRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleExpandedClickRef = useRef(handleExpandedClick);
|
||||
handleExpandedClickRef.current = handleExpandedClick;
|
||||
const stableExpandedClick = useCallback(
|
||||
(info: PickingInfo<ExpandedListingMarker>) => handleExpandedClickRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClusterHover = useCallback(
|
||||
(info: PickingInfo<ListingClusterPoint>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
const cluster = info.object;
|
||||
setPopupInfo((current) =>
|
||||
current?.locked
|
||||
? current
|
||||
: {
|
||||
mode: 'cluster',
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
count: cluster.count,
|
||||
listings: [],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
clearUnlockedPopup();
|
||||
}
|
||||
},
|
||||
[clearUnlockedPopup]
|
||||
);
|
||||
|
||||
const handleClusterClick = useCallback((info: PickingInfo<ListingClusterPoint>) => {
|
||||
if (!info.object || info.x === undefined || info.y === undefined) return;
|
||||
const index = clusterIndexRef.current;
|
||||
if (!index) return;
|
||||
const cluster = info.object;
|
||||
const clusterListings = getClusterListings(
|
||||
index,
|
||||
cluster.clusterId,
|
||||
LISTING_CLUSTER_POPUP_LIMIT
|
||||
);
|
||||
setSelectedCluster(cluster);
|
||||
setPopupInfo({
|
||||
mode: 'cluster',
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
count: cluster.count,
|
||||
listings: clusterListings,
|
||||
locked: true,
|
||||
});
|
||||
trackEvent('Actual Listing Cluster Click', { count: cluster.count });
|
||||
}, []);
|
||||
|
||||
const handleClusterHoverRef = useRef(handleClusterHover);
|
||||
handleClusterHoverRef.current = handleClusterHover;
|
||||
const stableClusterHover = useCallback(
|
||||
(info: PickingInfo<ListingClusterPoint>) => handleClusterHoverRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClusterClickRef = useRef(handleClusterClick);
|
||||
handleClusterClickRef.current = handleClusterClick;
|
||||
const stableClusterClick = useCallback(
|
||||
(info: PickingInfo<ListingClusterPoint>) => handleClusterClickRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const pinShadowLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ActualListing>({
|
||||
id: 'actual-listing-shadow',
|
||||
data: listings,
|
||||
data: visibleListings,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: 8,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
|
||||
pickable: false,
|
||||
}),
|
||||
[listings, isDark]
|
||||
[visibleListings, isDark]
|
||||
);
|
||||
|
||||
const pinLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ActualListing>({
|
||||
id: 'actual-listing-pin',
|
||||
data: listings,
|
||||
data: visibleListings,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: 7,
|
||||
radiusUnits: 'pixels',
|
||||
|
|
@ -91,12 +350,108 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
onHover: stableHover,
|
||||
onClick: stableClick,
|
||||
}),
|
||||
[listings, stableHover, stableClick]
|
||||
[visibleListings, stableHover, stableClick]
|
||||
);
|
||||
|
||||
const clusterShadowLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ListingClusterPoint>({
|
||||
id: 'actual-listing-cluster-shadow',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => Math.min(32, 13 + Math.sqrt(d.count) * 1.8),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 90] : [0, 0, 0, 45],
|
||||
pickable: false,
|
||||
}),
|
||||
[clusters, isDark]
|
||||
);
|
||||
|
||||
const clusterLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ListingClusterPoint>({
|
||||
id: 'actual-listing-cluster',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => Math.min(30, 12 + Math.sqrt(d.count) * 1.8),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [185, 28, 28, 230] : [220, 38, 38, 230],
|
||||
getLineColor: [255, 255, 255, isDark ? 90 : 180],
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
autoHighlight: true,
|
||||
highlightColor: [29, 228, 195, 220],
|
||||
onHover: stableClusterHover,
|
||||
onClick: stableClusterClick,
|
||||
}),
|
||||
[clusters, isDark, stableClusterHover, stableClusterClick]
|
||||
);
|
||||
|
||||
const clusterTextLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<ListingClusterPoint>({
|
||||
id: 'actual-listing-cluster-text',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => formatClusterCount(d.count),
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 255],
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 800,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 10,
|
||||
sizeMaxPixels: 13,
|
||||
pickable: false,
|
||||
}),
|
||||
[clusters]
|
||||
);
|
||||
|
||||
const expandedConnectorLayer = useMemo(
|
||||
() =>
|
||||
new PathLayer<ExpandedListingMarker>({
|
||||
id: 'actual-listing-expanded-lines',
|
||||
data: expandedListings,
|
||||
getPath: (d) => [
|
||||
[d.anchorLng, d.anchorLat],
|
||||
[d.lng, d.lat],
|
||||
],
|
||||
getColor: isDark ? [255, 255, 255, 80] : [80, 60, 50, 110],
|
||||
getWidth: 1,
|
||||
widthUnits: 'pixels',
|
||||
pickable: false,
|
||||
}),
|
||||
[expandedListings, isDark]
|
||||
);
|
||||
|
||||
const expandedPinLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ExpandedListingMarker>({
|
||||
id: 'actual-listing-expanded-pin',
|
||||
data: expandedListings,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 6,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: [231, 76, 60, 245],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
autoHighlight: true,
|
||||
highlightColor: [29, 228, 195, 220],
|
||||
onHover: stableExpandedHover,
|
||||
onClick: stableExpandedClick,
|
||||
}),
|
||||
[expandedListings, stableExpandedHover, stableExpandedClick]
|
||||
);
|
||||
|
||||
const priceLabelLayer = useMemo(() => {
|
||||
if (zoom < PRICE_LABEL_MIN_ZOOM) return null;
|
||||
const labeled = listings.filter((l) => l.asking_price && l.asking_price > 0);
|
||||
const labeled = visibleListings.filter((l) => l.asking_price && l.asking_price > 0);
|
||||
return new TextLayer<ActualListing>({
|
||||
id: 'actual-listing-price',
|
||||
data: labeled,
|
||||
|
|
@ -117,11 +472,11 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
sizeMaxPixels: 14,
|
||||
pickable: false,
|
||||
});
|
||||
}, [listings, zoom, isDark]);
|
||||
}, [visibleListings, zoom, isDark]);
|
||||
|
||||
const detailLabelLayer = useMemo(() => {
|
||||
if (zoom < ADDRESS_LABEL_MIN_ZOOM) return null;
|
||||
const labeled = listings.filter((l) => l.address || l.bedrooms != null);
|
||||
const labeled = visibleListings.filter((l) => l.address || l.bedrooms != null);
|
||||
return new TextLayer<ActualListing>({
|
||||
id: 'actual-listing-detail',
|
||||
data: labeled,
|
||||
|
|
@ -148,16 +503,39 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
sizeMaxPixels: 12,
|
||||
pickable: false,
|
||||
});
|
||||
}, [listings, zoom, isDark]);
|
||||
}, [visibleListings, zoom, isDark]);
|
||||
|
||||
const listingLayers = useMemo(() => {
|
||||
const layers: Layer[] = [pinShadowLayer, pinLayer];
|
||||
const layers: Layer[] = [
|
||||
clusterShadowLayer,
|
||||
clusterLayer,
|
||||
clusterTextLayer,
|
||||
pinShadowLayer,
|
||||
pinLayer,
|
||||
];
|
||||
if (expandedListings.length > 0) {
|
||||
layers.push(expandedConnectorLayer, expandedPinLayer);
|
||||
}
|
||||
if (priceLabelLayer) layers.push(priceLabelLayer);
|
||||
if (detailLabelLayer) layers.push(detailLabelLayer);
|
||||
return layers;
|
||||
}, [pinShadowLayer, pinLayer, priceLabelLayer, detailLabelLayer]);
|
||||
}, [
|
||||
clusterShadowLayer,
|
||||
clusterLayer,
|
||||
clusterTextLayer,
|
||||
pinShadowLayer,
|
||||
pinLayer,
|
||||
expandedListings.length,
|
||||
expandedConnectorLayer,
|
||||
expandedPinLayer,
|
||||
priceLabelLayer,
|
||||
detailLabelLayer,
|
||||
]);
|
||||
|
||||
const clearListingPopup = useCallback(() => setPopupInfo(null), []);
|
||||
const clearListingPopup = useCallback(() => {
|
||||
setPopupInfo(null);
|
||||
setSelectedCluster(null);
|
||||
}, []);
|
||||
|
||||
return { listingLayers, listingPopup: popupInfo, clearListingPopup };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ function viewChange(bounds: Bounds): ViewChangeParams {
|
|||
return {
|
||||
resolution: 8,
|
||||
bounds,
|
||||
visibleBounds: bounds,
|
||||
zoom: 10,
|
||||
latitude: (bounds.south + bounds.north) / 2,
|
||||
longitude: (bounds.west + bounds.east) / 2,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export function useMapData({
|
|||
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
|
||||
const [resolution, setResolution] = useState<number>(8);
|
||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||
const [visibleBounds, setVisibleBounds] = useState<Bounds | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [zoom, setZoom] = useState<number>(10);
|
||||
const [currentView, setCurrentView] = useState<{
|
||||
|
|
@ -685,6 +686,7 @@ export function useMapData({
|
|||
({
|
||||
resolution: newRes,
|
||||
bounds: newBounds,
|
||||
visibleBounds: newVisibleBounds,
|
||||
zoom: newZoom,
|
||||
latitude,
|
||||
longitude,
|
||||
|
|
@ -697,6 +699,7 @@ export function useMapData({
|
|||
setResolution(newRes);
|
||||
setBounds(newBounds);
|
||||
}
|
||||
setVisibleBounds(newVisibleBounds);
|
||||
setZoom(newZoom);
|
||||
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||||
setCurrentVisibleView({
|
||||
|
|
@ -729,6 +732,7 @@ export function useMapData({
|
|||
postcodeData: effectivePostcodeData,
|
||||
resolution,
|
||||
bounds,
|
||||
visibleBounds,
|
||||
loading: isLoading,
|
||||
zoom,
|
||||
currentView,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { stateToParams } from '../lib/url-state';
|
||||
import type { OverlayId } from '../lib/overlays';
|
||||
import type { BasemapId } from '../lib/basemaps';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
const URL_DEBOUNCE_MS = 300;
|
||||
|
|
@ -14,7 +15,8 @@ export function useUrlSync(
|
|||
rightPaneTab: 'properties' | 'area',
|
||||
travelTimeEntries?: TravelTimeEntry[],
|
||||
share?: string,
|
||||
selectedOverlays?: Set<OverlayId>
|
||||
selectedOverlays?: Set<OverlayId>,
|
||||
basemap?: BasemapId
|
||||
) {
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -31,7 +33,8 @@ export function useUrlSync(
|
|||
rightPaneTab,
|
||||
travelTimeEntries,
|
||||
share,
|
||||
selectedOverlays
|
||||
selectedOverlays,
|
||||
basemap
|
||||
);
|
||||
const search = params.toString();
|
||||
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||
|
|
@ -50,5 +53,6 @@ export function useUrlSync(
|
|||
travelTimeEntries,
|
||||
share,
|
||||
selectedOverlays,
|
||||
basemap,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue