Fmt
This commit is contained in:
parent
63713c3a2b
commit
bd6b511f16
17 changed files with 544 additions and 377 deletions
|
|
@ -31,7 +31,6 @@
|
||||||
"import io\n",
|
"import io\n",
|
||||||
"from pathlib import Path\n",
|
"from pathlib import Path\n",
|
||||||
"import json\n",
|
"import json\n",
|
||||||
"import math\n",
|
|
||||||
"import sys\n",
|
"import sys\n",
|
||||||
"\n",
|
"\n",
|
||||||
"import folium\n",
|
"import folium\n",
|
||||||
|
|
@ -50,7 +49,7 @@
|
||||||
"if str(ROOT) not in sys.path:\n",
|
"if str(ROOT) not in sys.path:\n",
|
||||||
" sys.path.insert(0, str(ROOT))\n",
|
" sys.path.insert(0, str(ROOT))\n",
|
||||||
"\n",
|
"\n",
|
||||||
"from pipeline.transform.tree_density import (\n",
|
"from pipeline.transform.tree_density import ( # noqa: E402\n",
|
||||||
" DEFAULT_TOW_TYPES,\n",
|
" DEFAULT_TOW_TYPES,\n",
|
||||||
" _layers,\n",
|
" _layers,\n",
|
||||||
" _metric_columns,\n",
|
" _metric_columns,\n",
|
||||||
|
|
|
||||||
|
|
@ -126,11 +126,7 @@ const DS_KEYS: Record<string, [string, string, string]> = {
|
||||||
'learnPage.dsGreenspaceOrigin',
|
'learnPage.dsGreenspaceOrigin',
|
||||||
'learnPage.dsGreenspaceUse',
|
'learnPage.dsGreenspaceUse',
|
||||||
],
|
],
|
||||||
'forest-research-tow': [
|
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
|
||||||
'learnPage.dsTowName',
|
|
||||||
'learnPage.dsTowOrigin',
|
|
||||||
'learnPage.dsTowUse',
|
|
||||||
],
|
|
||||||
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
||||||
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
||||||
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
|
||||||
import { apiUrl, authHeaders } from '../../lib/api';
|
import { apiUrl, authHeaders } from '../../lib/api';
|
||||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||||
import { useLicense } from '../../hooks/useLicense';
|
import { useLicense } from '../../hooks/useLicense';
|
||||||
import {
|
import {
|
||||||
AreaPane,
|
AreaPane,
|
||||||
|
|
@ -51,6 +51,8 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
|
||||||
|
|
||||||
export type { ExportState } from './map-page/types';
|
export type { ExportState } from './map-page/types';
|
||||||
|
|
||||||
|
type PendingFlyTo = { lat: number; lng: number; zoom: number };
|
||||||
|
|
||||||
export default function MapPage({
|
export default function MapPage({
|
||||||
features,
|
features,
|
||||||
poiCategoryGroups,
|
poiCategoryGroups,
|
||||||
|
|
@ -150,6 +152,7 @@ export default function MapPage({
|
||||||
|
|
||||||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||||||
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
|
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
|
||||||
|
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
|
||||||
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
||||||
|
|
||||||
const mapData = useMapData({
|
const mapData = useMapData({
|
||||||
|
|
@ -296,11 +299,27 @@ export default function MapPage({
|
||||||
journeyDest,
|
journeyDest,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const consumePendingLocationSearchFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
||||||
|
const pending = pendingLocationSearchFlyToRef.current;
|
||||||
|
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
||||||
|
if (!pending || !panelRect) return;
|
||||||
|
|
||||||
|
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||||
|
const flyTo = mapFlyToRef.current;
|
||||||
|
if (!flyTo) return;
|
||||||
|
flyTo(pending.lat, pending.lng, pending.zoom, {
|
||||||
|
visibleViewportArea: { bottom: bottomInset },
|
||||||
|
});
|
||||||
|
pendingLocationSearchFlyToRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLocationSearchResult = useCallback(
|
const handleLocationSearchResult = useCallback(
|
||||||
(result: SearchedLocation | null) => {
|
(result: SearchedLocation | null) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
if (result.markerLatitude != null && result.markerLongitude != null) {
|
const markerLat = result.markerLatitude;
|
||||||
setCurrentLocation({ lat: result.markerLatitude, lng: result.markerLongitude });
|
const markerLng = result.markerLongitude;
|
||||||
|
if (markerLat != null && markerLng != null) {
|
||||||
|
setCurrentLocation({ lat: markerLat, lng: markerLng });
|
||||||
} else {
|
} else {
|
||||||
setCurrentLocation(null);
|
setCurrentLocation(null);
|
||||||
}
|
}
|
||||||
|
|
@ -312,13 +331,22 @@ export default function MapPage({
|
||||||
result.openProperties,
|
result.openProperties,
|
||||||
result.focusAddress
|
result.focusAddress
|
||||||
);
|
);
|
||||||
if (isMobile) setMobileDrawerOpen(true);
|
if (isMobile) {
|
||||||
|
pendingLocationSearchFlyToRef.current = {
|
||||||
|
lat: markerLat ?? result.latitude,
|
||||||
|
lng: markerLng ?? result.longitude,
|
||||||
|
zoom: result.openProperties ? 17 : POSTCODE_SEARCH_ZOOM,
|
||||||
|
};
|
||||||
|
setMobileDrawerOpen(true);
|
||||||
|
consumePendingLocationSearchFlyTo();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentLocation(null);
|
setCurrentLocation(null);
|
||||||
|
pendingLocationSearchFlyToRef.current = null;
|
||||||
handleCloseSelection();
|
handleCloseSelection();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleCloseSelection, handleLocationSearch, isMobile]
|
[consumePendingLocationSearchFlyTo, handleCloseSelection, handleLocationSearch, isMobile]
|
||||||
);
|
);
|
||||||
|
|
||||||
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
||||||
|
|
@ -327,7 +355,9 @@ export default function MapPage({
|
||||||
if (!pending || !panelRect) return;
|
if (!pending || !panelRect) return;
|
||||||
|
|
||||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||||
mapFlyToRef.current?.(pending.lat, pending.lng, 17, {
|
const flyTo = mapFlyToRef.current;
|
||||||
|
if (!flyTo) return;
|
||||||
|
flyTo(pending.lat, pending.lng, 17, {
|
||||||
visibleViewportArea: { bottom: bottomInset },
|
visibleViewportArea: { bottom: bottomInset },
|
||||||
});
|
});
|
||||||
pendingCurrentLocationFlyToRef.current = null;
|
pendingCurrentLocationFlyToRef.current = null;
|
||||||
|
|
@ -352,12 +382,14 @@ export default function MapPage({
|
||||||
(rect: DOMRectReadOnly) => {
|
(rect: DOMRectReadOnly) => {
|
||||||
mobileDrawerPanelRectRef.current = rect;
|
mobileDrawerPanelRectRef.current = rect;
|
||||||
consumePendingCurrentLocationFlyTo(rect);
|
consumePendingCurrentLocationFlyTo(rect);
|
||||||
|
consumePendingLocationSearchFlyTo(rect);
|
||||||
},
|
},
|
||||||
[consumePendingCurrentLocationFlyTo]
|
[consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMobileDrawerClose = useCallback(() => {
|
const handleMobileDrawerClose = useCallback(() => {
|
||||||
pendingCurrentLocationFlyToRef.current = null;
|
pendingCurrentLocationFlyToRef.current = null;
|
||||||
|
pendingLocationSearchFlyToRef.current = null;
|
||||||
mobileDrawerPanelRectRef.current = null;
|
mobileDrawerPanelRectRef.current = null;
|
||||||
setMobileDrawerOpen(false);
|
setMobileDrawerOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -387,7 +419,11 @@ export default function MapPage({
|
||||||
isMobile,
|
isMobile,
|
||||||
flyTo: mapFlyToRef,
|
flyTo: mapFlyToRef,
|
||||||
onLocationSearch: handleLocationSearch,
|
onLocationSearch: handleLocationSearch,
|
||||||
onOpenMobileDrawer: () => setMobileDrawerOpen(true),
|
onOpenMobileDrawer: (target) => {
|
||||||
|
pendingLocationSearchFlyToRef.current = target;
|
||||||
|
setMobileDrawerOpen(true);
|
||||||
|
consumePendingLocationSearchFlyTo();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
useHorizontalSwipeNavigationGuard();
|
useHorizontalSwipeNavigationGuard();
|
||||||
useMobileBackNavigationGuard(isMobile);
|
useMobileBackNavigationGuard(isMobile);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { MutableRefObject } from 'react';
|
||||||
import type { PostcodeGeometry, ViewState } from '../../../types';
|
import type { PostcodeGeometry, ViewState } from '../../../types';
|
||||||
import type { useMapData } from '../../../hooks/useMapData';
|
import type { useMapData } from '../../../hooks/useMapData';
|
||||||
import { authHeaders } from '../../../lib/api';
|
import { authHeaders } from '../../../lib/api';
|
||||||
|
import { POSTCODE_SEARCH_ZOOM } from '../../../lib/consts';
|
||||||
import { canWheelScrollInsideTarget } from '../../../lib/dom-scroll';
|
import { canWheelScrollInsideTarget } from '../../../lib/dom-scroll';
|
||||||
import type { MapFlyTo } from './types';
|
import type { MapFlyTo } from './types';
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ interface UseInitialPostcodeSelectionOptions {
|
||||||
lat?: number,
|
lat?: number,
|
||||||
lng?: number
|
lng?: number
|
||||||
) => void;
|
) => void;
|
||||||
onOpenMobileDrawer: () => void;
|
onOpenMobileDrawer: (target: { lat: number; lng: number; zoom: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInitialPostcodeSelection({
|
export function useInitialPostcodeSelection({
|
||||||
|
|
@ -62,9 +63,15 @@ export function useInitialPostcodeSelection({
|
||||||
longitude: number;
|
longitude: number;
|
||||||
geometry: PostcodeGeometry;
|
geometry: PostcodeGeometry;
|
||||||
}) => {
|
}) => {
|
||||||
flyTo.current?.(data.latitude, data.longitude, 16);
|
flyTo.current?.(data.latitude, data.longitude, POSTCODE_SEARCH_ZOOM);
|
||||||
onLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude);
|
onLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude);
|
||||||
if (isMobile) onOpenMobileDrawer();
|
if (isMobile) {
|
||||||
|
onOpenMobileDrawer({
|
||||||
|
lat: data.latitude,
|
||||||
|
lng: data.longitude,
|
||||||
|
zoom: POSTCODE_SEARCH_ZOOM,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,10 @@ describe('useHexagonSelection', () => {
|
||||||
requests.push(`${url.pathname}${url.search}`);
|
requests.push(`${url.pathname}${url.search}`);
|
||||||
|
|
||||||
if (url.pathname === '/api/postcode-stats') {
|
if (url.pathname === '/api/postcode-stats') {
|
||||||
return Promise.resolve(jsonResponse(stats(url.searchParams.has('filters') ? 0 : 4)));
|
const emptyPostcode = url.searchParams.get('postcode') === 'EMPTY 1AA';
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse(stats(url.searchParams.has('filters') || emptyPostcode ? 0 : 4))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/hexagon-stats') {
|
if (url.pathname === '/api/hexagon-stats') {
|
||||||
|
|
@ -91,12 +94,15 @@ describe('useHexagonSelection', () => {
|
||||||
id: 'SW1A 1AA',
|
id: 'SW1A 1AA',
|
||||||
type: 'postcode',
|
type: 'postcode',
|
||||||
resolution: 9,
|
resolution: 9,
|
||||||
|
lockedResolution: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
||||||
|
await waitFor(() => {
|
||||||
expect(result.current.areaStats?.count).toBe(0);
|
expect(result.current.areaStats?.count).toBe(0);
|
||||||
expect(result.current.unfilteredAreaCount).toBe(4);
|
expect(result.current.unfilteredAreaCount).toBe(4);
|
||||||
|
});
|
||||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -124,6 +130,7 @@ describe('useHexagonSelection', () => {
|
||||||
id: 'SW1A 1AA',
|
id: 'SW1A 1AA',
|
||||||
type: 'postcode',
|
type: 'postcode',
|
||||||
resolution: 9,
|
resolution: 9,
|
||||||
|
lockedResolution: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -131,4 +138,65 @@ describe('useHexagonSelection', () => {
|
||||||
expect(result.current.unfilteredAreaCount).toBeNull();
|
expect(result.current.unfilteredAreaCount).toBeNull();
|
||||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps an empty postcode search selected instead of widening to hexagons', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useHexagonSelection({
|
||||||
|
filters: {},
|
||||||
|
features,
|
||||||
|
hexagonData: [],
|
||||||
|
resolution: 9,
|
||||||
|
usePostcodeView: true,
|
||||||
|
travelTimeEntries: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleLocationSearch('EMPTY 1AA', postcodeGeometry, 51.505, -0.115);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.areaStats?.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.selectedHexagon).toEqual({
|
||||||
|
id: 'EMPTY 1AA',
|
||||||
|
type: 'postcode',
|
||||||
|
resolution: 9,
|
||||||
|
lockedResolution: true,
|
||||||
|
});
|
||||||
|
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
||||||
|
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not convert a searched postcode back to a hexagon while the map reaches postcode zoom', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useHexagonSelection({
|
||||||
|
filters: {},
|
||||||
|
features,
|
||||||
|
hexagonData: [],
|
||||||
|
resolution: 9,
|
||||||
|
usePostcodeView: false,
|
||||||
|
travelTimeEntries: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.areaStats?.count).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.selectedHexagon).toEqual({
|
||||||
|
id: 'SW1A 1AA',
|
||||||
|
type: 'postcode',
|
||||||
|
resolution: 9,
|
||||||
|
lockedResolution: true,
|
||||||
|
});
|
||||||
|
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
||||||
|
expect(requests.some((url) => url.startsWith('/api/postcode/'))).toBe(false);
|
||||||
|
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -426,7 +426,7 @@ export function useHexagonSelection({
|
||||||
selection.type === 'hexagon' &&
|
selection.type === 'hexagon' &&
|
||||||
!selection.lockedResolution &&
|
!selection.lockedResolution &&
|
||||||
areaStats?.central_postcode != null) ||
|
areaStats?.central_postcode != null) ||
|
||||||
(!usePostcodeView && selection.type === 'postcode') ||
|
(!usePostcodeView && selection.type === 'postcode' && !selection.lockedResolution) ||
|
||||||
(!usePostcodeView &&
|
(!usePostcodeView &&
|
||||||
selection.type === 'hexagon' &&
|
selection.type === 'hexagon' &&
|
||||||
!selection.lockedResolution &&
|
!selection.lockedResolution &&
|
||||||
|
|
@ -628,103 +628,38 @@ export function useHexagonSelection({
|
||||||
(
|
(
|
||||||
postcode: string,
|
postcode: string,
|
||||||
geometry: PostcodeGeometry,
|
geometry: PostcodeGeometry,
|
||||||
lat?: number,
|
_lat?: number,
|
||||||
lng?: number,
|
_lng?: number,
|
||||||
openProperties = false,
|
openProperties = false,
|
||||||
focusAddress?: string
|
focusAddress?: string
|
||||||
) => {
|
) => {
|
||||||
const requestId = invalidateAreaRequests();
|
const requestId = invalidateAreaRequests();
|
||||||
invalidatePropertyRequests();
|
invalidatePropertyRequests();
|
||||||
|
const selection = {
|
||||||
|
id: postcode,
|
||||||
|
type: 'postcode' as const,
|
||||||
|
resolution,
|
||||||
|
lockedResolution: true,
|
||||||
|
};
|
||||||
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
|
trackEvent(openProperties ? 'Address Search' : 'Postcode Search');
|
||||||
|
setSelectedHexagon(selection);
|
||||||
|
setSelectedPostcodeGeometry(geometry);
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setPropertiesTotal(0);
|
setPropertiesTotal(0);
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
|
setAreaStats(null);
|
||||||
setUnfilteredAreaCount(null);
|
setUnfilteredAreaCount(null);
|
||||||
setRightPaneTab(openProperties ? 'properties' : 'area');
|
setRightPaneTab(openProperties ? 'properties' : 'area');
|
||||||
setLoadingAreaStats(true);
|
setLoadingAreaStats(true);
|
||||||
|
|
||||||
// First try the postcode; if it only has no matches because of active filters,
|
|
||||||
// keep the searched postcode selected instead of widening to nearby hexagons.
|
|
||||||
fetchPostcodeStats(postcode, undefined, areaStatsUseFilters)
|
fetchPostcodeStats(postcode, undefined, areaStatsUseFilters)
|
||||||
.then(async (stats) => {
|
.then((stats) => {
|
||||||
if (!isCurrentAreaRequest(requestId)) return;
|
if (!isCurrentAreaRequest(requestId)) return;
|
||||||
if (stats.count > 0) {
|
|
||||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
|
||||||
setSelectedHexagon(selection);
|
|
||||||
setSelectedPostcodeGeometry(geometry);
|
|
||||||
setAreaStats(stats);
|
setAreaStats(stats);
|
||||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||||
if (openProperties) {
|
if (openProperties && stats.count > 0) {
|
||||||
fetchPostcodeProperties(postcode, 0, focusAddress);
|
fetchPostcodeProperties(postcode, 0, focusAddress);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (areaStatsUseFilters && hasStatsFilters) {
|
|
||||||
const unfilteredStats = await fetchPostcodeStats(postcode, undefined, false);
|
|
||||||
if (!isCurrentAreaRequest(requestId)) return;
|
|
||||||
if (unfilteredStats.count > 0) {
|
|
||||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
|
||||||
setSelectedHexagon(selection);
|
|
||||||
setSelectedPostcodeGeometry(geometry);
|
|
||||||
setAreaStats(stats);
|
|
||||||
setUnfilteredAreaCount(unfilteredStats.count);
|
|
||||||
setRightPaneTab(openProperties ? 'properties' : 'area');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No properties in this postcode — fall back to hexagons
|
|
||||||
if (lat == null || lng == null) {
|
|
||||||
// No coordinates available, show empty postcode anyway
|
|
||||||
const selection = { id: postcode, type: 'postcode' as const, resolution };
|
|
||||||
setSelectedHexagon(selection);
|
|
||||||
setSelectedPostcodeGeometry(geometry);
|
|
||||||
setAreaStats(stats);
|
|
||||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
|
||||||
setRightPaneTab('area');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try progressively coarser H3 resolutions until we find >1 property
|
|
||||||
const resolutions = [9, 8, 7, 6, 5];
|
|
||||||
for (const res of resolutions) {
|
|
||||||
const h3 = latLngToCell(lat, lng, res);
|
|
||||||
const hexStats = await fetchHexagonStats(
|
|
||||||
h3,
|
|
||||||
res,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
areaStatsUseFilters
|
|
||||||
);
|
|
||||||
if (!isCurrentAreaRequest(requestId)) return;
|
|
||||||
if (hexStats.count > 1) {
|
|
||||||
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
|
|
||||||
setSelectedHexagon(selection);
|
|
||||||
setSelectedPostcodeGeometry(null);
|
|
||||||
setAreaStats(hexStats);
|
|
||||||
refreshUnfilteredAreaCount(selection, hexStats.count, areaStatsUseFilters);
|
|
||||||
setRightPaneTab('area');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
|
|
||||||
const h3 = latLngToCell(lat, lng, 9);
|
|
||||||
const fallbackStats = await fetchHexagonStats(
|
|
||||||
h3,
|
|
||||||
9,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
areaStatsUseFilters
|
|
||||||
);
|
|
||||||
if (!isCurrentAreaRequest(requestId)) return;
|
|
||||||
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
|
|
||||||
setSelectedHexagon(selection);
|
|
||||||
setSelectedPostcodeGeometry(null);
|
|
||||||
setAreaStats(fallbackStats);
|
|
||||||
refreshUnfilteredAreaCount(selection, fallbackStats.count, areaStatsUseFilters);
|
|
||||||
setRightPaneTab('area');
|
|
||||||
})
|
})
|
||||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -734,9 +669,7 @@ export function useHexagonSelection({
|
||||||
[
|
[
|
||||||
resolution,
|
resolution,
|
||||||
areaStatsUseFilters,
|
areaStatsUseFilters,
|
||||||
hasStatsFilters,
|
|
||||||
fetchPostcodeStats,
|
fetchPostcodeStats,
|
||||||
fetchHexagonStats,
|
|
||||||
fetchPostcodeProperties,
|
fetchPostcodeProperties,
|
||||||
invalidateAreaRequests,
|
invalidateAreaRequests,
|
||||||
invalidatePropertyRequests,
|
invalidatePropertyRequests,
|
||||||
|
|
|
||||||
|
|
@ -413,15 +413,14 @@ const en = {
|
||||||
heroEyebrow: 'Find where to look first',
|
heroEyebrow: 'Find where to look first',
|
||||||
heroTitle1: 'Stop searching',
|
heroTitle1: 'Stop searching',
|
||||||
heroTitle2: 'the wrong places',
|
heroTitle2: 'the wrong places',
|
||||||
heroTitle3: 'Before listings take over.',
|
heroTitle3: 'Before listings narrow your search.',
|
||||||
heroSubtitle:
|
heroSubtitle: 'Find postcodes where your budget, commute, and daily life line up.',
|
||||||
'Find postcodes where your budget, commute, and daily life line up.',
|
|
||||||
heroDescription:
|
heroDescription:
|
||||||
'Perfect Postcode shows where to look before you start chasing viewings.',
|
'Perfect Postcode filters every postcode first, so you only chase viewings in places that work.',
|
||||||
exploreTheMap: 'Show me where to look',
|
exploreTheMap: 'Show me where to look',
|
||||||
seeTheDifference: 'Watch demo',
|
seeTheDifference: 'Watch demo',
|
||||||
productDemoLabel: 'Watch the postcode shortlist demo',
|
productDemoLabel: 'See how to find where to look first',
|
||||||
playProductDemo: 'Play the postcode shortlist demo',
|
playProductDemo: 'Play the where-to-look demo',
|
||||||
scrollToProductDemo: 'Scroll to product demo',
|
scrollToProductDemo: 'Scroll to product demo',
|
||||||
showcaseHeader: 'How it works',
|
showcaseHeader: 'How it works',
|
||||||
showcaseContext: 'How Perfect Postcode works',
|
showcaseContext: 'How Perfect Postcode works',
|
||||||
|
|
@ -446,26 +445,26 @@ const en = {
|
||||||
showcaseScoutBullet2: 'Test the commute from a real front door, not a borough name.',
|
showcaseScoutBullet2: 'Test the commute from a real front door, not a borough name.',
|
||||||
showcaseScoutBullet3: 'Compare viewings with evidence already saved.',
|
showcaseScoutBullet3: 'Compare viewings with evidence already saved.',
|
||||||
showcaseStep1Tab: 'Filter',
|
showcaseStep1Tab: 'Filter',
|
||||||
showcaseStep1Title: 'Turn your needs into clear search filters',
|
showcaseStep1Title: 'Set what has to work',
|
||||||
showcaseStep1Body:
|
showcaseStep1Body:
|
||||||
'Set what matters and see how many unsuitable postcodes each requirement removes.',
|
'Add budget, commute, schools, safety, noise, and local details. Watch the wrong postcodes drop out.',
|
||||||
showcaseStep1Chip1: 'Quiet streets',
|
showcaseStep1Chip1: 'Quiet streets',
|
||||||
showcaseStep1Chip2: 'Good primaries nearby',
|
showcaseStep1Chip2: 'Good primaries nearby',
|
||||||
showcaseStep1Chip3: 'Under £500k',
|
showcaseStep1Chip3: 'Under £500k',
|
||||||
showcaseStep1VennCenter: 'Postcodes that meet all three',
|
showcaseStep1VennCenter: 'Postcodes that meet all three',
|
||||||
showcaseStep2Tab: 'Match',
|
showcaseStep2Tab: 'Match',
|
||||||
showcaseStep2Title: 'Find places you would never have known to search',
|
showcaseStep2Title: 'See the places left standing',
|
||||||
showcaseStep2Body:
|
showcaseStep2Body:
|
||||||
'Search by what you need, not by area name. The map shows suitable postcode clusters before listing sites narrow the search.',
|
'Search by practical checks, not familiar names. The map shows postcode clusters worth checking first.',
|
||||||
showcaseStep2Region: 'Greater London',
|
showcaseStep2Region: 'Greater London',
|
||||||
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
|
showcaseStep2Sources: 'Land Registry · ONS · Ofsted · DfT',
|
||||||
showcaseStep2ClustersLabel: 'Matching clusters',
|
showcaseStep2ClustersLabel: 'Matching clusters',
|
||||||
showcaseStep3Tab: 'Inspect',
|
showcaseStep3Tab: 'Inspect',
|
||||||
showcaseStep3Title: 'See why a postcode matches',
|
showcaseStep3Title: 'Check the evidence',
|
||||||
showcaseStep3Body:
|
showcaseStep3Body:
|
||||||
'Open any matching area and check prices, safety, schools, broadband, and trade-offs in one pane before you spend a weekend there.',
|
'Open a postcode and see the price, commute, schools, crime, broadband, and trade-offs before you visit.',
|
||||||
showcaseStep3HeaderArea: 'Shortlisted postcode',
|
showcaseStep3HeaderArea: 'Shortlisted postcode',
|
||||||
showcaseStep3HeaderFit: 'Why it matches',
|
showcaseStep3HeaderFit: 'What works',
|
||||||
showcaseStep3Stat1Label: 'Sold price trend',
|
showcaseStep3Stat1Label: 'Sold price trend',
|
||||||
showcaseStep3Stat2Label: 'Crime rate',
|
showcaseStep3Stat2Label: 'Crime rate',
|
||||||
showcaseStep3Stat2Value: 'Below borough avg.',
|
showcaseStep3Stat2Value: 'Below borough avg.',
|
||||||
|
|
@ -475,9 +474,9 @@ const en = {
|
||||||
showcaseStep3Stat5Label: 'Primary schools',
|
showcaseStep3Stat5Label: 'Primary schools',
|
||||||
showcaseStep3Stat5Value: '3 Outstanding within 1 mile',
|
showcaseStep3Stat5Value: '3 Outstanding within 1 mile',
|
||||||
showcaseStep4Tab: 'Scout',
|
showcaseStep4Tab: 'Scout',
|
||||||
showcaseStep4Title: 'Take the strongest areas into the real world',
|
showcaseStep4Title: 'Take the shortlist to the streets',
|
||||||
showcaseStep4Body:
|
showcaseStep4Body:
|
||||||
'Export suggested postcodes to visit. Walk the streets, test the commute, and compare viewings with the data you saved.',
|
'Export the postcodes worth checking, test the commute, walk the roads, and compare viewings with context saved.',
|
||||||
showcaseStep4FileName: 'areas-to-scout.xlsx',
|
showcaseStep4FileName: 'areas-to-scout.xlsx',
|
||||||
showcaseStep4ExportLabel: 'Export to Excel',
|
showcaseStep4ExportLabel: 'Export to Excel',
|
||||||
showcaseStep4ColPostcode: 'Postcode',
|
showcaseStep4ColPostcode: 'Postcode',
|
||||||
|
|
@ -489,20 +488,19 @@ const en = {
|
||||||
statFilters: 'ways to narrow the map',
|
statFilters: 'ways to narrow the map',
|
||||||
statEvery: 'Every',
|
statEvery: 'Every',
|
||||||
statPostcodeInEngland: 'active postcode in England',
|
statPostcodeInEngland: 'active postcode in England',
|
||||||
ourPhilosophy: 'Start with needs. End with postcodes.',
|
ourPhilosophy: 'Stop starting with towns you already know.',
|
||||||
philosophyP1:
|
philosophyP1:
|
||||||
'Listing sites force you to pick a town, borough, or postcode before you know which places can work. That means the search is limited by memory, recommendations, and whatever happens to be for sale this week.',
|
'Most searches start with a place name, then hope the right homes appear. That skips the harder question: which places are actually worth searching?',
|
||||||
philosophyP2:
|
philosophyP2:
|
||||||
'Perfect Postcode starts with your requirements instead. Tell the map your budget, commute, school, safety, noise, broadband, and local-context needs, then inspect the postcodes that match before you open listings.',
|
'Perfect Postcode starts before the listing site. Set the things a place must support, then see the postcodes that deserve your attention first.',
|
||||||
streetTitle: 'Places change street by street',
|
streetTitle: 'Places change street by street',
|
||||||
streetIntro:
|
streetIntro:
|
||||||
'Area names hide the details that matter: the station side, the road noise, the school mix, the exact commute, and what similar homes actually sold for.',
|
'The right side of a station, a noisy road, or one school catchment can change the search. Area names flatten all of that.',
|
||||||
streetCard1Title: 'Find places you may have missed',
|
streetCard1Title: 'Escape the familiar-name trap',
|
||||||
streetCard1Body:
|
streetCard1Body: 'Find postcode-level matches outside the places already on your list.',
|
||||||
'Search postcode-level data by your requirements instead of relying on familiar names, friend recommendations, or “up-and-coming” hype.',
|
streetCard2Title: 'Know the trade-offs before you go',
|
||||||
streetCard2Title: 'Check the trade-offs before viewings',
|
|
||||||
streetCard2Body:
|
streetCard2Body:
|
||||||
'Compare price, space, commute, safety, schools, broadband, noise, energy ratings, parks, and local amenities before you spend weekends travelling between viewings.',
|
'Check price, commute, noise, schools, safety, broadband, and nearby amenities before booking viewings.',
|
||||||
othersVs: 'Other tools vs',
|
othersVs: 'Other tools vs',
|
||||||
checkMyPostcode: 'Listing sites',
|
checkMyPostcode: 'Listing sites',
|
||||||
areaGuides: 'Postcode checkers',
|
areaGuides: 'Postcode checkers',
|
||||||
|
|
@ -512,11 +510,11 @@ const en = {
|
||||||
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
|
compAreaDataSub: '(crime, schools, noise, broadband, amenities)',
|
||||||
compPropertyData: 'Street-level property context',
|
compPropertyData: 'Street-level property context',
|
||||||
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
|
compPropertyDataSub: '(sold prices, EPC, floor area, estimated value)',
|
||||||
compFilters: 'All your requirements working together',
|
compFilters: 'Budget, commute, schools, safety, and local data together',
|
||||||
compFiltersSub: '(budget + commute + schools + safety + local context)',
|
compFiltersSub: '(budget + commute + schools + safety + local context)',
|
||||||
ctaTitle: 'Do the area research before you book the viewing.',
|
ctaTitle: 'Find where to look before you book viewings.',
|
||||||
ctaDescription:
|
ctaDescription:
|
||||||
'Build a postcode shortlist from price, commute, schools, safety, noise, broadband, amenities, and sold-price evidence, then verify the streets in person.',
|
'Build a postcode shortlist from the things that matter, then check the streets in person.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Pricing Page ───────────────────────────────────
|
// ── Pricing Page ───────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@ describe('poi-distance-filter', () => {
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
getPoiFilterFeatureOptions(features, POI_DISTANCE_FILTER_NAME).map((f) => f.name)
|
getPoiFilterFeatureOptions(features, POI_DISTANCE_FILTER_NAME).map((f) => f.name)
|
||||||
).toEqual(['Distance to nearest amenity (Cafe) (km)', 'Distance to nearest amenity (Park) (km)']);
|
).toEqual([
|
||||||
|
'Distance to nearest amenity (Cafe) (km)',
|
||||||
|
'Distance to nearest amenity (Park) (km)',
|
||||||
|
]);
|
||||||
expect(
|
expect(
|
||||||
getPoiFilterFeatureOptions(features, TRANSPORT_DISTANCE_FILTER_NAME).map((f) => f.name)
|
getPoiFilterFeatureOptions(features, TRANSPORT_DISTANCE_FILTER_NAME).map((f) => f.name)
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
|
@ -54,11 +57,4 @@ describe('poi-distance-filter', () => {
|
||||||
);
|
);
|
||||||
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
|
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recognizes the old static park distance name for URL migration only', () => {
|
|
||||||
expect(getPoiFilterName('Distance to nearest park (km)')).toBe(POI_DISTANCE_FILTER_NAME);
|
|
||||||
expect(
|
|
||||||
getPoiFilterFeatureOptions([numeric('Distance to nearest park (km)')], POI_DISTANCE_FILTER_NAME)
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -225,14 +225,14 @@ describe('url-state', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
|
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
|
||||||
const park = createPoiDistanceFilterKey('Distance to nearest park (km)', 3);
|
const park = createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 3);
|
||||||
const grocery = createPoiDistanceFilterKey('Distance to nearest grocery store (km)', 4);
|
const cafe = createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 4);
|
||||||
|
|
||||||
const params = stateToParams(
|
const params = stateToParams(
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
[park]: [0, 0.4],
|
[park]: [0, 0.4],
|
||||||
[grocery]: [0, 1.5],
|
[cafe]: [0, 1.5],
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
new Set(),
|
new Set(),
|
||||||
|
|
@ -240,8 +240,8 @@ describe('url-state', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(params.getAll('amenityDistance')).toEqual([
|
expect(params.getAll('amenityDistance')).toEqual([
|
||||||
'Distance%20to%20nearest%20park%20(km):0:0.4',
|
'Distance%20to%20nearest%20amenity%20(Park)%20(km):0:0.4',
|
||||||
'Distance%20to%20nearest%20grocery%20store%20(km):0:1.5',
|
'Distance%20to%20nearest%20amenity%20(Caf%C3%A9)%20(km):0:1.5',
|
||||||
]);
|
]);
|
||||||
expect(params.getAll('filter')).toEqual([]);
|
expect(params.getAll('filter')).toEqual([]);
|
||||||
|
|
||||||
|
|
@ -249,8 +249,8 @@ describe('url-state', () => {
|
||||||
const state = parseUrlState();
|
const state = parseUrlState();
|
||||||
|
|
||||||
expect(state.filters).toEqual({
|
expect(state.filters).toEqual({
|
||||||
[createPoiDistanceFilterKey('Distance to nearest park (km)', 0)]: [0, 0.4],
|
[createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 0)]: [0, 0.4],
|
||||||
[createPoiDistanceFilterKey('Distance to nearest grocery store (km)', 1)]: [0, 1.5],
|
[createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 1)]: [0, 1.5],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -289,24 +289,6 @@ describe('url-state', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('migrates legacy transport distance amenity params into transport filters', () => {
|
|
||||||
window.history.replaceState(
|
|
||||||
{},
|
|
||||||
'',
|
|
||||||
'/?amenityDistance=Distance%20to%20nearest%20amenity%20(Bus%20stop)%20(km):0:0.3'
|
|
||||||
);
|
|
||||||
|
|
||||||
const state = parseUrlState();
|
|
||||||
|
|
||||||
expect(state.filters).toEqual({
|
|
||||||
[createPoiFilterKey(
|
|
||||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
|
||||||
'Distance to nearest amenity (Bus stop) (km)',
|
|
||||||
0
|
|
||||||
)]: [0, 0.3],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trips amenity count filters with dedicated URL params', () => {
|
it('round-trips amenity count filters with dedicated URL params', () => {
|
||||||
const cafes = createPoiFilterKey(
|
const cafes = createPoiFilterKey(
|
||||||
POI_COUNT_2KM_FILTER_NAME,
|
POI_COUNT_2KM_FILTER_NAME,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -57,9 +58,6 @@ _AREA_COLUMNS = [
|
||||||
# Amenities
|
# Amenities
|
||||||
"Number of restaurants within 2km",
|
"Number of restaurants within 2km",
|
||||||
"Number of grocery shops and supermarkets within 2km",
|
"Number of grocery shops and supermarkets within 2km",
|
||||||
"Number of parks within 1km",
|
|
||||||
"Distance to nearest train or tube station (km)",
|
|
||||||
"Distance to nearest park (km)",
|
|
||||||
# Environment
|
# Environment
|
||||||
"Noise (dB)",
|
"Noise (dB)",
|
||||||
"Max available download speed (Mbps)",
|
"Max available download speed (Mbps)",
|
||||||
|
|
@ -85,6 +83,17 @@ _AREA_COLUMNS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_DYNAMIC_POI_DISTANCE_RE = re.compile(r"^Distance to nearest amenity \(.+\) \(km\)$")
|
||||||
|
_DYNAMIC_POI_COUNT_RE = re.compile(r"^Number of amenities \(.+\) within (2|5)km$")
|
||||||
|
TREE_DENSITY_FEATURE = "Street tree density percentile"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dynamic_poi_metric_column(column: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
_DYNAMIC_POI_DISTANCE_RE.match(column) or _DYNAMIC_POI_COUNT_RE.match(column)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _less_deprived_percentile_expr(column: str) -> pl.Expr:
|
def _less_deprived_percentile_expr(column: str) -> pl.Expr:
|
||||||
"""Convert an IoD deprivation score to a 0-100 less-deprived percentile."""
|
"""Convert an IoD deprivation score to a 0-100 less-deprived percentile."""
|
||||||
non_null_count = pl.col(column).count()
|
non_null_count = pl.col(column).count()
|
||||||
|
|
@ -117,6 +126,7 @@ def _build(
|
||||||
lsoa_population_path: Path,
|
lsoa_population_path: Path,
|
||||||
median_age_path: Path,
|
median_age_path: Path,
|
||||||
election_results_path: Path,
|
election_results_path: Path,
|
||||||
|
tree_density_addresses_path: Path | None = None,
|
||||||
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
||||||
"""Build postcode and properties dataframes from epc_pp + auxiliary data.
|
"""Build postcode and properties dataframes from epc_pp + auxiliary data.
|
||||||
|
|
||||||
|
|
@ -250,6 +260,18 @@ def _build(
|
||||||
school_proximity = pl.scan_parquet(school_proximity_path)
|
school_proximity = pl.scan_parquet(school_proximity_path)
|
||||||
wide = wide.join(school_proximity, on="postcode", how="left")
|
wide = wide.join(school_proximity, on="postcode", how="left")
|
||||||
|
|
||||||
|
if tree_density_addresses_path is not None:
|
||||||
|
tree_density = (
|
||||||
|
pl.scan_parquet(tree_density_addresses_path)
|
||||||
|
.select(
|
||||||
|
pl.col("postcode"),
|
||||||
|
pl.col("pp_address"),
|
||||||
|
pl.col(TREE_DENSITY_FEATURE).cast(pl.Float32),
|
||||||
|
)
|
||||||
|
.unique(["postcode", "pp_address"])
|
||||||
|
)
|
||||||
|
wide = wide.join(tree_density, on=["postcode", "pp_address"], how="left")
|
||||||
|
|
||||||
# Broadband: derive max available download speed tier per postcode from
|
# Broadband: derive max available download speed tier per postcode from
|
||||||
# Ofcom availability percentages. Tiers: Gigabit ≥1000, UFBB ≥300,
|
# Ofcom availability percentages. Tiers: Gigabit ≥1000, UFBB ≥300,
|
||||||
# UFBB(100) ≥100, SFBB ≥30 Mbps. Stored as string enum.
|
# UFBB(100) ≥100, SFBB ≥30 Mbps. Stored as string enum.
|
||||||
|
|
@ -366,9 +388,6 @@ def _build(
|
||||||
"property_type": "Property type",
|
"property_type": "Property type",
|
||||||
"restaurants_2km": "Number of restaurants within 2km",
|
"restaurants_2km": "Number of restaurants within 2km",
|
||||||
"groceries_2km": "Number of grocery shops and supermarkets within 2km",
|
"groceries_2km": "Number of grocery shops and supermarkets within 2km",
|
||||||
"parks_1km": "Number of parks within 1km",
|
|
||||||
"train_tube_nearest_km": "Distance to nearest train or tube station (km)",
|
|
||||||
"parks_nearest_km": "Distance to nearest park (km)",
|
|
||||||
"latest_price": "Last known price",
|
"latest_price": "Last known price",
|
||||||
"number_habitable_rooms": "Number of bedrooms & living rooms",
|
"number_habitable_rooms": "Number of bedrooms & living rooms",
|
||||||
"noise_lden_db": "Noise (dB)",
|
"noise_lden_db": "Noise (dB)",
|
||||||
|
|
@ -398,11 +417,18 @@ def _build(
|
||||||
df = wide.collect(engine="streaming")
|
df = wide.collect(engine="streaming")
|
||||||
|
|
||||||
# Split into postcode-level and property-level dataframes
|
# Split into postcode-level and property-level dataframes
|
||||||
area_cols = [c for c in _AREA_COLUMNS if c in df.columns]
|
area_cols = [
|
||||||
|
c for c in df.columns if c in _AREA_COLUMNS or _is_dynamic_poi_metric_column(c)
|
||||||
|
]
|
||||||
postcode_df = df.select(area_cols).group_by("Postcode").first()
|
postcode_df = df.select(area_cols).group_by("Postcode").first()
|
||||||
print(f"Postcode rows: {postcode_df.height} (unique postcodes)")
|
print(f"Postcode rows: {postcode_df.height} (unique postcodes)")
|
||||||
|
|
||||||
property_cols = [c for c in df.columns if c not in _AREA_COLUMNS or c == "Postcode"]
|
property_cols = [
|
||||||
|
c
|
||||||
|
for c in df.columns
|
||||||
|
if (c not in _AREA_COLUMNS and not _is_dynamic_poi_metric_column(c))
|
||||||
|
or c == "Postcode"
|
||||||
|
]
|
||||||
properties_df = df.select(property_cols)
|
properties_df = df.select(property_cols)
|
||||||
print(f"Property rows: {properties_df.height}")
|
print(f"Property rows: {properties_df.height}")
|
||||||
|
|
||||||
|
|
@ -481,6 +507,12 @@ def main():
|
||||||
required=True,
|
required=True,
|
||||||
help="2024 General Election results by constituency parquet file",
|
help="2024 General Election results by constituency parquet file",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tree-density-addresses",
|
||||||
|
type=Path,
|
||||||
|
required=False,
|
||||||
|
help="Address-level tree density parquet from pipeline.transform.tree_density",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output-postcodes",
|
"--output-postcodes",
|
||||||
type=Path,
|
type=Path,
|
||||||
|
|
@ -509,6 +541,7 @@ def main():
|
||||||
lsoa_population_path=args.lsoa_population,
|
lsoa_population_path=args.lsoa_population,
|
||||||
median_age_path=args.median_age,
|
median_age_path=args.median_age,
|
||||||
election_results_path=args.election_results,
|
election_results_path=args.election_results,
|
||||||
|
tree_density_addresses_path=args.tree_density_addresses,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"\nPostcode columns: {postcode_df.columns}")
|
print(f"\nPostcode columns: {postcode_df.columns}")
|
||||||
|
|
|
||||||
|
|
@ -17,27 +17,6 @@ POI_GROUPS_2KM = {
|
||||||
"groceries": ["Greengrocer", "Supermarket", "Convenience Store"],
|
"groceries": ["Greengrocer", "Supermarket", "Convenience Store"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Groups for which to compute distance to nearest POI (from filtered POIs).
|
|
||||||
# Keep `train_tube` for the existing backend feature; the individual POI
|
|
||||||
# distance filters below power the frontend dropdown.
|
|
||||||
DISTANCE_GROUPS = {
|
|
||||||
"train_tube": ["Tube station", "Rail station"],
|
|
||||||
"grocery_store": [
|
|
||||||
"Greengrocer",
|
|
||||||
"Supermarket",
|
|
||||||
"Convenience Store",
|
|
||||||
"Waitrose",
|
|
||||||
"Tesco",
|
|
||||||
],
|
|
||||||
"tube_station": ["Tube station"],
|
|
||||||
"rail_station": ["Rail station"],
|
|
||||||
"waitrose": ["Waitrose"],
|
|
||||||
"tesco": ["Tesco"],
|
|
||||||
"cafe": ["Café"],
|
|
||||||
"pub": ["Pub"],
|
|
||||||
"restaurant": ["Restaurant"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# OS Open Greenspace function types used for park counts and distance calculation.
|
# OS Open Greenspace function types used for park counts and distance calculation.
|
||||||
# Uses the authoritative OS dataset instead of OSM point POIs for better coverage
|
# Uses the authoritative OS dataset instead of OSM point POIs for better coverage
|
||||||
# of green spaces that are only mapped as polygons in OSM.
|
# of green spaces that are only mapped as polygons in OSM.
|
||||||
|
|
@ -48,6 +27,7 @@ GREENSPACE_PARK_FUNCTIONS = {
|
||||||
GROCERY_DYNAMIC_FILTER_MIN_POIS = 100
|
GROCERY_DYNAMIC_FILTER_MIN_POIS = 100
|
||||||
DYNAMIC_FILTER_ALL_GROUPS = {"Public Transport", "Leisure"}
|
DYNAMIC_FILTER_ALL_GROUPS = {"Public Transport", "Leisure"}
|
||||||
DYNAMIC_FILTER_COUNT_THRESHOLD_GROUPS = {"Groceries"}
|
DYNAMIC_FILTER_COUNT_THRESHOLD_GROUPS = {"Groceries"}
|
||||||
|
DYNAMIC_FILTER_EXCLUDED_CATEGORIES = {"Park"}
|
||||||
|
|
||||||
|
|
||||||
def _poi_category_slug(category: str) -> str:
|
def _poi_category_slug(category: str) -> str:
|
||||||
|
|
@ -78,6 +58,7 @@ def _build_poi_category_groups(
|
||||||
& (pl.col("len") > GROCERY_DYNAMIC_FILTER_MIN_POIS)
|
& (pl.col("len") > GROCERY_DYNAMIC_FILTER_MIN_POIS)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.filter(~pl.col("category").is_in(list(DYNAMIC_FILTER_EXCLUDED_CATEGORIES)))
|
||||||
.select("category")
|
.select("category")
|
||||||
.sort("category")
|
.sort("category")
|
||||||
.to_series()
|
.to_series()
|
||||||
|
|
@ -103,9 +84,11 @@ def _build_poi_category_groups(
|
||||||
def _dynamic_poi_metric_renames(display_names: dict[str, str]) -> dict[str, str]:
|
def _dynamic_poi_metric_renames(display_names: dict[str, str]) -> dict[str, str]:
|
||||||
renames: dict[str, str] = {}
|
renames: dict[str, str] = {}
|
||||||
for group_key, category in display_names.items():
|
for group_key, category in display_names.items():
|
||||||
renames[f"{group_key}_nearest_km"] = f"Distance to nearest {category} POI (km)"
|
renames[f"{group_key}_nearest_km"] = (
|
||||||
renames[f"{group_key}_2km"] = f"Number of {category} POIs within 2km"
|
f"Distance to nearest amenity ({category}) (km)"
|
||||||
renames[f"{group_key}_5km"] = f"Number of {category} POIs within 5km"
|
)
|
||||||
|
renames[f"{group_key}_2km"] = f"Number of amenities ({category}) within 2km"
|
||||||
|
renames[f"{group_key}_5km"] = f"Number of amenities ({category}) within 5km"
|
||||||
return renames
|
return renames
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -139,12 +122,12 @@ def main():
|
||||||
pois = pl.read_parquet(args.pois)
|
pois = pl.read_parquet(args.pois)
|
||||||
poi_category_groups, poi_display_names = _build_poi_category_groups(pois)
|
poi_category_groups, poi_display_names = _build_poi_category_groups(pois)
|
||||||
|
|
||||||
# Count amenity POIs within 2km
|
# Count static amenity groups within 2km.
|
||||||
counts_2km = count_pois_per_postcode(
|
counts_2km = count_pois_per_postcode(
|
||||||
postcodes, pois, groups=POI_GROUPS_2KM, radius_km=2
|
postcodes, pois, groups=POI_GROUPS_2KM, radius_km=2
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dynamic POI filters: nearest distance plus counts within 2km and 5km for
|
# Dynamic amenity filters: nearest distance plus counts within 2km and 5km for
|
||||||
# the selected public transport, grocery, and leisure categories.
|
# the selected public transport, grocery, and leisure categories.
|
||||||
dynamic_counts_2km = count_pois_per_postcode(
|
dynamic_counts_2km = count_pois_per_postcode(
|
||||||
postcodes, pois, groups=poi_category_groups, radius_km=2
|
postcodes, pois, groups=poi_category_groups, radius_km=2
|
||||||
|
|
@ -166,25 +149,37 @@ def main():
|
||||||
{k: v for k, v in dynamic_renames.items() if k in dynamic_distances.columns}
|
{k: v for k, v in dynamic_renames.items() if k in dynamic_distances.columns}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Distance to nearest train/tube station (from filtered POIs)
|
# Park counts and distances from OS Open Greenspace. They use the dynamic
|
||||||
distances = min_distance_per_postcode(postcodes, pois, groups=DISTANCE_GROUPS)
|
# amenity metric names so filters read through the same side-table path as
|
||||||
|
# OSM-derived amenity metrics.
|
||||||
# Park counts and distances from OS Open Greenspace
|
|
||||||
greenspace = pl.read_parquet(args.greenspace)
|
greenspace = pl.read_parquet(args.greenspace)
|
||||||
park_counts_1km = count_pois_per_postcode(
|
park_counts_2km = count_pois_per_postcode(
|
||||||
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=1
|
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=2
|
||||||
|
)
|
||||||
|
park_counts_5km = count_pois_per_postcode(
|
||||||
|
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS, radius_km=5
|
||||||
)
|
)
|
||||||
park_distances = min_distance_per_postcode(
|
park_distances = min_distance_per_postcode(
|
||||||
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS
|
postcodes, greenspace, groups=GREENSPACE_PARK_FUNCTIONS
|
||||||
)
|
)
|
||||||
|
park_renames = _dynamic_poi_metric_renames({"parks": "Park"})
|
||||||
|
park_counts_2km = park_counts_2km.rename(
|
||||||
|
{k: v for k, v in park_renames.items() if k in park_counts_2km.columns}
|
||||||
|
)
|
||||||
|
park_counts_5km = park_counts_5km.rename(
|
||||||
|
{k: v for k, v in park_renames.items() if k in park_counts_5km.columns}
|
||||||
|
)
|
||||||
|
park_distances = park_distances.rename(
|
||||||
|
{k: v for k, v in park_renames.items() if k in park_distances.columns}
|
||||||
|
)
|
||||||
|
|
||||||
# Join all results on postcode
|
# Join all results on postcode
|
||||||
result = (
|
result = (
|
||||||
counts_2km.join(distances, on="postcode")
|
counts_2km.join(dynamic_counts_2km, on="postcode")
|
||||||
.join(dynamic_counts_2km, on="postcode")
|
|
||||||
.join(dynamic_counts_5km, on="postcode")
|
.join(dynamic_counts_5km, on="postcode")
|
||||||
.join(dynamic_distances, on="postcode")
|
.join(dynamic_distances, on="postcode")
|
||||||
.join(park_counts_1km, on="postcode")
|
.join(park_counts_2km, on="postcode")
|
||||||
|
.join(park_counts_5km, on="postcode")
|
||||||
.join(park_distances, on="postcode")
|
.join(park_distances, on="postcode")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
2
property-data2/.gitignore
vendored
Normal file
2
property-data2/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
|
@ -983,81 +983,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
FeatureGroup {
|
FeatureGroup {
|
||||||
name: "Amenities",
|
name: "Amenities",
|
||||||
features: &[
|
features: &[
|
||||||
Feature::Numeric(FeatureConfig {
|
|
||||||
name: "Distance to nearest park (km)",
|
|
||||||
bounds: Bounds::Percentile {
|
|
||||||
low: 2.0,
|
|
||||||
high: 98.0,
|
|
||||||
},
|
|
||||||
step: 0.1,
|
|
||||||
description: "Distance to the closest park or green space",
|
|
||||||
detail: "Straight-line distance in kilometres from the postcode to the nearest park entrance. Covers public parks, gardens, playing fields, and play spaces. Uses access point locations from the OS Open Greenspace dataset, so properties bordering a large park correctly show a short distance.",
|
|
||||||
source: "os-open-greenspace",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " km",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
}),
|
|
||||||
Feature::Numeric(FeatureConfig {
|
|
||||||
name: "Distance to nearest grocery store (km)",
|
|
||||||
bounds: Bounds::Percentile {
|
|
||||||
low: 2.0,
|
|
||||||
high: 98.0,
|
|
||||||
},
|
|
||||||
step: 0.1,
|
|
||||||
description: "Distance to the closest grocery shop or supermarket",
|
|
||||||
detail: "Straight-line distance in kilometres from the postcode to the nearest grocery shop, supermarket, or convenience store. Uses OpenStreetMap POIs, with Waitrose and Tesco coverage from GEOLYTIX retail points.",
|
|
||||||
source: "osm-pois",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " km",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
}),
|
|
||||||
Feature::Numeric(FeatureConfig {
|
|
||||||
name: "Distance to nearest cafe (km)",
|
|
||||||
bounds: Bounds::Percentile {
|
|
||||||
low: 2.0,
|
|
||||||
high: 98.0,
|
|
||||||
},
|
|
||||||
step: 0.1,
|
|
||||||
description: "Distance to the closest cafe",
|
|
||||||
detail: "Straight-line distance in kilometres from the postcode to the nearest cafe, ice-cream shop, or internet cafe mapped in OpenStreetMap.",
|
|
||||||
source: "osm-pois",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " km",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
}),
|
|
||||||
Feature::Numeric(FeatureConfig {
|
|
||||||
name: "Distance to nearest pub (km)",
|
|
||||||
bounds: Bounds::Percentile {
|
|
||||||
low: 2.0,
|
|
||||||
high: 98.0,
|
|
||||||
},
|
|
||||||
step: 0.1,
|
|
||||||
description: "Distance to the closest pub",
|
|
||||||
detail: "Straight-line distance in kilometres from the postcode to the nearest pub, social club, brewery, distillery, or winery mapped in OpenStreetMap.",
|
|
||||||
source: "osm-pois",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " km",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
}),
|
|
||||||
Feature::Numeric(FeatureConfig {
|
|
||||||
name: "Distance to nearest restaurant (km)",
|
|
||||||
bounds: Bounds::Percentile {
|
|
||||||
low: 2.0,
|
|
||||||
high: 98.0,
|
|
||||||
},
|
|
||||||
step: 0.1,
|
|
||||||
description: "Distance to the closest restaurant",
|
|
||||||
detail: "Straight-line distance in kilometres from the postcode to the nearest restaurant or food court mapped in OpenStreetMap.",
|
|
||||||
source: "osm-pois",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " km",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
}),
|
|
||||||
Feature::Numeric(FeatureConfig {
|
Feature::Numeric(FeatureConfig {
|
||||||
name: "Noise (dB)",
|
name: "Noise (dB)",
|
||||||
bounds: Bounds::Fixed {
|
bounds: Bounds::Fixed {
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,8 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
|
||||||
match path {
|
match path {
|
||||||
"/" => Some(SeoPage {
|
"/" => Some(SeoPage {
|
||||||
canonical_path: "/",
|
canonical_path: "/",
|
||||||
title: "Find the best postcodes and areas to live in England | Perfect Postcode",
|
title: "Stop searching the wrong places | Perfect Postcode",
|
||||||
description: "Discover where to live by comparing England postcodes by budget, commute, schools, crime, noise, broadband, property prices and local amenities before viewing homes.",
|
description: "Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings.",
|
||||||
indexable: true,
|
indexable: true,
|
||||||
}),
|
}),
|
||||||
"/learn" | "/support" => Some(SeoPage {
|
"/learn" | "/support" => Some(SeoPage {
|
||||||
|
|
|
||||||
|
|
@ -359,9 +359,18 @@ pub fn build_system_prompt(
|
||||||
or \"max\" (at most this value). Never set two filters on the same feature.\n\
|
or \"max\" (at most this value). Never set two filters on the same feature.\n\
|
||||||
- Use EXACT feature names from the list — spelling, capitalisation, and punctuation must match.\n\
|
- Use EXACT feature names from the list — spelling, capitalisation, and punctuation must match.\n\
|
||||||
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
||||||
- \"low crime\" / \"safe\" = low values on Serious crime and Minor crime summary features. \
|
- \"low crime\" / \"safe\" = low values on the Serious crime and Minor crime features. \
|
||||||
\"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of amenities (Park) within 2km.\n\
|
Prefer the per-1k resident crime features for broad area safety; use specific crime \
|
||||||
|
features only when the user names a crime type.\n\
|
||||||
|
- \"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of amenities (Park) within 2km \
|
||||||
|
or low Distance to nearest park (km), depending on wording.\n\
|
||||||
- \"good schools\" = Good+ school features. \"outstanding schools\" = Outstanding school features.\n\
|
- \"good schools\" = Good+ school features. \"outstanding schools\" = Outstanding school features.\n\
|
||||||
|
- Amenities and transport stops are normal filters in the feature catalogue. \
|
||||||
|
For \"near a bus stop\", \"near a station\", \"near shops\", etc., use the exact \
|
||||||
|
Distance to nearest amenity (...) or Number of amenities (...) feature when available.\n\
|
||||||
|
- Politics/elections are normal filters in the Neighbours group. Use exact vote share \
|
||||||
|
features such as % Labour, % Conservative, % Liberal Democrat, % Reform UK, % Green, \
|
||||||
|
% Other parties, or Voter turnout (%) when the user asks for political character.\n\
|
||||||
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
|
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
|
||||||
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
|
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
|
||||||
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
|
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
|
||||||
|
|
@ -393,12 +402,14 @@ pub fn build_system_prompt(
|
||||||
You can add travel time filters when the user mentions commute times, \
|
You can add travel time filters when the user mentions commute times, \
|
||||||
proximity to places, or wanting to be near/within X minutes of somewhere.\n\
|
proximity to places, or wanting to be near/within X minutes of somewhere.\n\
|
||||||
\n\
|
\n\
|
||||||
Available transport modes (only use modes that have destinations):\n\
|
Available travel-time modes (only use modes that have destinations):\n\
|
||||||
{}\n\
|
{}\n\
|
||||||
- \"car\" / \"drive\" / \"driving\" = car mode\n\
|
- \"car\" / \"drive\" / \"driving\" = car mode\n\
|
||||||
- \"cycle\" / \"bike\" / \"cycling\" = bicycle mode\n\
|
- \"cycle\" / \"bike\" / \"cycling\" = bicycle mode\n\
|
||||||
- \"walk\" / \"walking\" / \"on foot\" = walking mode\n\
|
- \"walk\" / \"walking\" / \"on foot\" = walking mode\n\
|
||||||
- \"train\" / \"tube\" / \"bus\" / \"public transport\" / \"commute\" = transit mode\n\
|
- \"train\" / \"tube\" / \"bus\" / \"public transport\" / \"commute\" = transit mode\n\
|
||||||
|
- If a mode appears in the available mode list but is not named above, you may still \
|
||||||
|
use the exact mode string from the list.\n\
|
||||||
\n\
|
\n\
|
||||||
When the user mentions a specific place, you MUST call the search_destinations \
|
When the user mentions a specific place, you MUST call the search_destinations \
|
||||||
tool to find the exact slug. Use the name and slug from the search results.\n\
|
tool to find the exact slug. Use the name and slug from the search results.\n\
|
||||||
|
|
@ -407,10 +418,10 @@ pub fn build_system_prompt(
|
||||||
include a travel_time_filter for it.\n\
|
include a travel_time_filter for it.\n\
|
||||||
\n\
|
\n\
|
||||||
Travel time values are in MINUTES (0-120 range).\n\
|
Travel time values are in MINUTES (0-120 range).\n\
|
||||||
- \"within 30 minutes\" = max 30\n\
|
- \"within 30 minutes\" = set \"max\": 30\n\
|
||||||
- \"at least 10 minutes\" = min 10\n\
|
- \"at least 10 minutes\" = set \"min\": 10\n\
|
||||||
- \"30-45 minute commute\" = min 30, max 45\n\
|
- \"30-45 minute commute\" = set \"min\": 30 and \"max\": 45 on the same travel_time_filter\n\
|
||||||
- If only a max is given, omit min (and vice versa).\n\
|
- If only a max is given, omit min (and vice versa). Do not use bound/value for travel time.\n\
|
||||||
\n\
|
\n\
|
||||||
INFERRING TRANSPORT MODE (when the user does not specify one explicitly):\n\
|
INFERRING TRANSPORT MODE (when the user does not specify one explicitly):\n\
|
||||||
- \"commute\" to a major city centre or station = transit\n\
|
- \"commute\" to a major city centre or station = transit\n\
|
||||||
|
|
@ -437,10 +448,6 @@ pub fn build_system_prompt(
|
||||||
// Feature catalogue
|
// Feature catalogue
|
||||||
parts.push("\n--- AVAILABLE FEATURES ---\n".to_string());
|
parts.push("\n--- AVAILABLE FEATURES ---\n".to_string());
|
||||||
for group in &features.groups {
|
for group in &features.groups {
|
||||||
// Skip individual crime features — only expose "Crime summary" aggregates
|
|
||||||
if group.name == "Crime" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
parts.push(format!("## {}", group.name));
|
parts.push(format!("## {}", group.name));
|
||||||
for feature in &group.features {
|
for feature in &group.features {
|
||||||
match feature {
|
match feature {
|
||||||
|
|
@ -495,8 +502,8 @@ pub fn build_system_prompt(
|
||||||
parts.push(
|
parts.push(
|
||||||
"\nUser: \"safe quiet area with good schools and parks\"\n\
|
"\nUser: \"safe quiet area with good schools and parks\"\n\
|
||||||
Output: {\"numeric_filters\": [\
|
Output: {\"numeric_filters\": [\
|
||||||
{\"name\": \"Serious crime (avg/yr)\", \"bound\": \"max\", \"value\": 20}, \
|
{\"name\": \"Serious crime per 1k residents (avg/yr)\", \"bound\": \"max\", \"value\": 20}, \
|
||||||
{\"name\": \"Minor crime (avg/yr)\", \"bound\": \"max\", \"value\": 50}, \
|
{\"name\": \"Minor crime per 1k residents (avg/yr)\", \"bound\": \"max\", \"value\": 50}, \
|
||||||
{\"name\": \"Noise (dB)\", \"bound\": \"max\", \"value\": 55}, \
|
{\"name\": \"Noise (dB)\", \"bound\": \"max\", \"value\": 55}, \
|
||||||
{\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}, \
|
{\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}, \
|
||||||
{\"name\": \"Good+ secondary schools within 2km\", \"bound\": \"min\", \"value\": 1}, \
|
{\"name\": \"Good+ secondary schools within 2km\", \"bound\": \"min\", \"value\": 1}, \
|
||||||
|
|
@ -535,7 +542,7 @@ pub fn build_system_prompt(
|
||||||
{\"name\": \"Last known price\", \"bound\": \"max\", \"value\": 500000}], \
|
{\"name\": \"Last known price\", \"bound\": \"max\", \"value\": 500000}], \
|
||||||
\"enum_filters\": [], \
|
\"enum_filters\": [], \
|
||||||
\"travel_time_filters\": [{\"mode\": \"transit\", \"slug\": \"kings-cross\", \
|
\"travel_time_filters\": [{\"mode\": \"transit\", \"slug\": \"kings-cross\", \
|
||||||
\"label\": \"Kings Cross\", \"bound\": \"max\", \"value\": 30}], \
|
\"label\": \"Kings Cross\", \"max\": 30}], \
|
||||||
\"notes\": \"\"}"
|
\"notes\": \"\"}"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
|
|
@ -552,11 +559,21 @@ pub fn build_system_prompt(
|
||||||
\"enum_filters\": [{\"name\": \"Property type\", \
|
\"enum_filters\": [{\"name\": \"Property type\", \
|
||||||
\"values\": [\"Detached\", \"Semi-Detached\"]}], \
|
\"values\": [\"Detached\", \"Semi-Detached\"]}], \
|
||||||
\"travel_time_filters\": [{\"mode\": \"car\", \"slug\": \"manchester\", \
|
\"travel_time_filters\": [{\"mode\": \"car\", \"slug\": \"manchester\", \
|
||||||
\"label\": \"Manchester\", \"bound\": \"max\", \"value\": 45}], \
|
\"label\": \"Manchester\", \"max\": 45}], \
|
||||||
\"notes\": \"No filter for: garden\"}"
|
\"notes\": \"No filter for: garden\"}"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
"\nUser: \"Labour-voting area with low burglary and a station nearby\"\n\
|
||||||
|
Output: {\"numeric_filters\": [\
|
||||||
|
{\"name\": \"% Labour\", \"bound\": \"min\", \"value\": 40}, \
|
||||||
|
{\"name\": \"Burglary (avg/yr)\", \"bound\": \"max\", \"value\": 10}, \
|
||||||
|
{\"name\": \"Distance to nearest amenity (Rail station) (km)\", \"bound\": \"max\", \"value\": 1}], \
|
||||||
|
\"enum_filters\": [], \"travel_time_filters\": [], \"notes\": \"\"}"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
// Examples showing rent and price features
|
// Examples showing rent and price features
|
||||||
parts.push(
|
parts.push(
|
||||||
"\nUser: \"2 bed flat with rent under £1500/month\"\n\
|
"\nUser: \"2 bed flat with rent under £1500/month\"\n\
|
||||||
|
|
@ -585,8 +602,9 @@ pub fn build_system_prompt(
|
||||||
"\n--- OUTPUT FORMAT ---\n\
|
"\n--- OUTPUT FORMAT ---\n\
|
||||||
{\"numeric_filters\": [...], \"enum_filters\": [...], \
|
{\"numeric_filters\": [...], \"enum_filters\": [...], \
|
||||||
\"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \
|
\"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \
|
||||||
\"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
|
\"min\": N, \"max\": N}, ...], \"notes\": \"...\"}\n\
|
||||||
- travel_time_filters: use ONLY slugs returned by search_destinations. If a place isn't found, mention it in notes.\n\
|
- travel_time_filters: min and max are both optional, but include at least one. \
|
||||||
|
Use ONLY slugs returned by search_destinations. If a place isn't found, mention it in notes.\n\
|
||||||
Respond with ONLY the JSON object. No explanation."
|
Respond with ONLY the JSON object. No explanation."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
|
|
@ -685,6 +703,22 @@ async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn record_ai_request_usage(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: &str,
|
||||||
|
existing_tokens_used: u64,
|
||||||
|
week: u64,
|
||||||
|
request_tokens_used: u64,
|
||||||
|
status: &'static str,
|
||||||
|
) {
|
||||||
|
if request_tokens_used > 0 {
|
||||||
|
let new_total = existing_tokens_used.saturating_add(request_tokens_used);
|
||||||
|
update_ai_usage(state, user_id, new_total, week).await;
|
||||||
|
counter!("ai_tokens_total").increment(request_tokens_used);
|
||||||
|
}
|
||||||
|
counter!("ai_requests_total", "status" => status).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert validated filter JSON back to the `;;`-separated filter string format
|
/// Convert validated filter JSON back to the `;;`-separated filter string format
|
||||||
/// that `parse_filters` expects.
|
/// that `parse_filters` expects.
|
||||||
///
|
///
|
||||||
|
|
@ -848,7 +882,8 @@ pub async fn post_ai_filters(
|
||||||
let user_text = if let Some(ref ctx) = req.context {
|
let user_text = if let Some(ref ctx) = req.context {
|
||||||
let mut msg = String::new();
|
let mut msg = String::new();
|
||||||
msg.push_str("Currently active filters:\n");
|
msg.push_str("Currently active filters:\n");
|
||||||
msg.push_str(&serde_json::to_string(&ctx.filters).unwrap_or_default());
|
let normalized_filters = normalize_context_filters(&ctx.filters);
|
||||||
|
msg.push_str(&serde_json::to_string(&normalized_filters).unwrap_or_default());
|
||||||
if !ctx.travel_time.is_empty() {
|
if !ctx.travel_time.is_empty() {
|
||||||
msg.push_str("\nCurrently active travel time filters:\n");
|
msg.push_str("\nCurrently active travel time filters:\n");
|
||||||
for tt in &ctx.travel_time {
|
for tt in &ctx.travel_time {
|
||||||
|
|
@ -892,13 +927,28 @@ pub async fn post_ai_filters(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let json_resp = gemini_chat(
|
let json_resp = match gemini_chat(
|
||||||
&state.http_client,
|
&state.http_client,
|
||||||
&state.gemini_api_key,
|
&state.gemini_api_key,
|
||||||
&state.gemini_model,
|
&state.gemini_model,
|
||||||
&body,
|
&body,
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(err) => {
|
||||||
|
record_ai_request_usage(
|
||||||
|
&state,
|
||||||
|
&user.id,
|
||||||
|
tokens_used,
|
||||||
|
current_week,
|
||||||
|
total_tokens_accumulated,
|
||||||
|
"llm_error",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Accumulate token usage
|
// Accumulate token usage
|
||||||
total_tokens_accumulated += json_resp
|
total_tokens_accumulated += json_resp
|
||||||
|
|
@ -907,22 +957,43 @@ pub async fn post_ai_filters(
|
||||||
.and_then(|tc| tc.as_u64())
|
.and_then(|tc| tc.as_u64())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let candidate = json_resp
|
let candidate = match json_resp
|
||||||
.get("candidates")
|
.get("candidates")
|
||||||
.and_then(|cs| cs.get(0))
|
.and_then(|cs| cs.get(0))
|
||||||
.and_then(|c| c.get("content"))
|
.and_then(|c| c.get("content"))
|
||||||
.ok_or_else(|| {
|
{
|
||||||
|
Some(candidate) => candidate,
|
||||||
|
None => {
|
||||||
warn!("Malformed Gemini response: missing candidates[0].content");
|
warn!("Malformed Gemini response: missing candidates[0].content");
|
||||||
(StatusCode::BAD_GATEWAY, "Malformed Gemini response".into())
|
record_ai_request_usage(
|
||||||
})?;
|
&state,
|
||||||
|
&user.id,
|
||||||
|
tokens_used,
|
||||||
|
current_week,
|
||||||
|
total_tokens_accumulated,
|
||||||
|
"malformed_response",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err((StatusCode::BAD_GATEWAY, "Malformed Gemini response".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let parts = candidate
|
let parts = match candidate.get("parts").and_then(|p| p.as_array()) {
|
||||||
.get("parts")
|
Some(parts) => parts,
|
||||||
.and_then(|p| p.as_array())
|
None => {
|
||||||
.ok_or_else(|| {
|
|
||||||
warn!("Malformed Gemini response: missing parts array");
|
warn!("Malformed Gemini response: missing parts array");
|
||||||
(StatusCode::BAD_GATEWAY, "Malformed Gemini response".into())
|
record_ai_request_usage(
|
||||||
})?;
|
&state,
|
||||||
|
&user.id,
|
||||||
|
tokens_used,
|
||||||
|
current_week,
|
||||||
|
total_tokens_accumulated,
|
||||||
|
"malformed_response",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err((StatusCode::BAD_GATEWAY, "Malformed Gemini response".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check if the model made a function call.
|
// Check if the model made a function call.
|
||||||
// Find the full part (includes thoughtSignature required by Gemini 3 models).
|
// Find the full part (includes thoughtSignature required by Gemini 3 models).
|
||||||
|
|
@ -967,7 +1038,7 @@ pub async fn post_ai_filters(
|
||||||
"parts": [{
|
"parts": [{
|
||||||
"functionResponse": {
|
"functionResponse": {
|
||||||
"name": fn_name,
|
"name": fn_name,
|
||||||
"response": { "results": fn_result }
|
"response": fn_result
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}));
|
}));
|
||||||
|
|
@ -991,6 +1062,15 @@ pub async fn post_ai_filters(
|
||||||
round, retry_count
|
round, retry_count
|
||||||
);
|
);
|
||||||
if retry_count > MAX_RETRIES {
|
if retry_count > MAX_RETRIES {
|
||||||
|
record_ai_request_usage(
|
||||||
|
&state,
|
||||||
|
&user.id,
|
||||||
|
tokens_used,
|
||||||
|
current_week,
|
||||||
|
total_tokens_accumulated,
|
||||||
|
"empty_response",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
"AI returned empty responses".into(),
|
"AI returned empty responses".into(),
|
||||||
|
|
@ -1010,6 +1090,15 @@ pub async fn post_ai_filters(
|
||||||
retry_count += 1;
|
retry_count += 1;
|
||||||
warn!(error = %err, round = round, retry = retry_count, "Failed to parse Gemini JSON output");
|
warn!(error = %err, round = round, retry = retry_count, "Failed to parse Gemini JSON output");
|
||||||
if retry_count > MAX_RETRIES {
|
if retry_count > MAX_RETRIES {
|
||||||
|
record_ai_request_usage(
|
||||||
|
&state,
|
||||||
|
&user.id,
|
||||||
|
tokens_used,
|
||||||
|
current_week,
|
||||||
|
total_tokens_accumulated,
|
||||||
|
"invalid_json",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
return Err((StatusCode::BAD_GATEWAY, "AI returned invalid JSON".into()));
|
return Err((StatusCode::BAD_GATEWAY, "AI returned invalid JSON".into()));
|
||||||
}
|
}
|
||||||
contents.push(candidate.clone());
|
contents.push(candidate.clone());
|
||||||
|
|
@ -1047,10 +1136,15 @@ pub async fn post_ai_filters(
|
||||||
|
|
||||||
if refinement_attempts > MAX_REFINEMENTS {
|
if refinement_attempts > MAX_REFINEMENTS {
|
||||||
warn!("Refinement budget exhausted, returning filters with 0 matches");
|
warn!("Refinement budget exhausted, returning filters with 0 matches");
|
||||||
let new_total = tokens_used + total_tokens_accumulated;
|
record_ai_request_usage(
|
||||||
update_ai_usage(&state, &user.id, new_total, current_week).await;
|
&state,
|
||||||
counter!("ai_tokens_total").increment(total_tokens_accumulated);
|
&user.id,
|
||||||
counter!("ai_requests_total", "status" => "zero_matches").increment(1);
|
tokens_used,
|
||||||
|
current_week,
|
||||||
|
total_tokens_accumulated,
|
||||||
|
"zero_matches",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let notes = if notes.is_empty() {
|
let notes = if notes.is_empty() {
|
||||||
"No properties match these filters. Try relaxing some constraints.".to_string()
|
"No properties match these filters. Try relaxing some constraints.".to_string()
|
||||||
|
|
@ -1094,12 +1188,15 @@ pub async fn post_ai_filters(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update usage with total accumulated tokens
|
record_ai_request_usage(
|
||||||
let new_total = tokens_used + total_tokens_accumulated;
|
&state,
|
||||||
update_ai_usage(&state, &user.id, new_total, current_week).await;
|
&user.id,
|
||||||
|
tokens_used,
|
||||||
counter!("ai_tokens_total").increment(total_tokens_accumulated);
|
current_week,
|
||||||
counter!("ai_requests_total", "status" => "success").increment(1);
|
total_tokens_accumulated,
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Log the query to PocketBase (fire-and-forget)
|
// Log the query to PocketBase (fire-and-forget)
|
||||||
let filters_json = serde_json::to_string(&filters).unwrap_or_default();
|
let filters_json = serde_json::to_string(&filters).unwrap_or_default();
|
||||||
|
|
@ -1134,12 +1231,53 @@ pub async fn post_ai_filters(
|
||||||
"AI exhausted {} total rounds without final response (tools={}, retries={}, refinements={})",
|
"AI exhausted {} total rounds without final response (tools={}, retries={}, refinements={})",
|
||||||
MAX_TOTAL_ROUNDS, tool_call_count, retry_count, refinement_attempts
|
MAX_TOTAL_ROUNDS, tool_call_count, retry_count, refinement_attempts
|
||||||
);
|
);
|
||||||
|
record_ai_request_usage(
|
||||||
|
&state,
|
||||||
|
&user.id,
|
||||||
|
tokens_used,
|
||||||
|
current_week,
|
||||||
|
total_tokens_accumulated,
|
||||||
|
"incomplete",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Err((
|
Err((
|
||||||
StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
"AI could not complete the request".into(),
|
"AI could not complete the request".into(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn travel_time_minute_field(item: &Value, key: &str) -> Option<f32> {
|
||||||
|
item.get(key)
|
||||||
|
.and_then(|val| val.as_f64())
|
||||||
|
.filter(|val| val.is_finite())
|
||||||
|
.map(|val| val.clamp(0.0, 120.0) as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_travel_time_bounds(item: &Value) -> (Option<f32>, Option<f32>) {
|
||||||
|
let explicit_min = travel_time_minute_field(item, "min");
|
||||||
|
let explicit_max = travel_time_minute_field(item, "max");
|
||||||
|
|
||||||
|
let (mut min, mut max) = if explicit_min.is_some() || explicit_max.is_some() {
|
||||||
|
(explicit_min, explicit_max)
|
||||||
|
} else {
|
||||||
|
let value = travel_time_minute_field(item, "value");
|
||||||
|
match (item.get("bound").and_then(|val| val.as_str()), value) {
|
||||||
|
(Some("min"), Some(val)) => (Some(val), None),
|
||||||
|
(Some("max"), Some(val)) => (None, Some(val)),
|
||||||
|
_ => (None, None),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let (Some(min_val), Some(max_val)) = (min, max) {
|
||||||
|
if min_val > max_val {
|
||||||
|
min = Some(max_val);
|
||||||
|
max = Some(min_val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(min, max)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate travel time filters from LLM output against available destinations.
|
/// Validate travel time filters from LLM output against available destinations.
|
||||||
fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec<TravelTimeFilter> {
|
fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec<TravelTimeFilter> {
|
||||||
let arr = match raw
|
let arr = match raw
|
||||||
|
|
@ -1177,14 +1315,7 @@ fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec<TravelTime
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bound = item.get("bound").and_then(|val| val.as_str());
|
let (min, max) = parse_travel_time_bounds(item);
|
||||||
let value = item.get("value").and_then(|val| val.as_f64());
|
|
||||||
|
|
||||||
let (min, max) = match (bound, value) {
|
|
||||||
(Some("min"), Some(val)) => (Some(val.clamp(0.0, 120.0) as f32), None),
|
|
||||||
(Some("max"), Some(val)) => (None, Some(val.clamp(0.0, 120.0) as f32)),
|
|
||||||
_ => (None, None),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only include if at least one bound is set
|
// Only include if at least one bound is set
|
||||||
if min.is_some() || max.is_some() {
|
if min.is_some() || max.is_some() {
|
||||||
|
|
@ -1251,11 +1382,13 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
|
||||||
// produces [0, value] rather than [2nd-percentile, value].
|
// produces [0, value] rather than [2nd-percentile, value].
|
||||||
if let Some(arr) = raw.get("numeric_filters").and_then(|val| val.as_array()) {
|
if let Some(arr) = raw.get("numeric_filters").and_then(|val| val.as_array()) {
|
||||||
for item in arr {
|
for item in arr {
|
||||||
let name = match item.get("name").and_then(|val| val.as_str()) {
|
let raw_name = match item.get("name").and_then(|val| val.as_str()) {
|
||||||
Some(name) => name,
|
Some(name) => name,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
let (slider_min, slider_max, data_min, data_max) = match numeric_features.get(name) {
|
let name = canonical_filter_name(raw_name);
|
||||||
|
let (slider_min, slider_max, data_min, data_max) =
|
||||||
|
match numeric_features.get(name.as_str()) {
|
||||||
Some(range) => *range,
|
Some(range) => *range,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
@ -1275,7 +1408,7 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
|
||||||
};
|
};
|
||||||
// Only include if range is narrower than full slider range
|
// Only include if range is narrower than full slider range
|
||||||
if filter_min > slider_min || filter_max < slider_max {
|
if filter_min > slider_min || filter_max < slider_max {
|
||||||
result.insert(name.to_string(), json!([filter_min, filter_max]));
|
result.insert(name, json!([filter_min, filter_max]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1283,11 +1416,12 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
|
||||||
// Process enum filters
|
// Process enum filters
|
||||||
if let Some(arr) = raw.get("enum_filters").and_then(|val| val.as_array()) {
|
if let Some(arr) = raw.get("enum_filters").and_then(|val| val.as_array()) {
|
||||||
for item in arr {
|
for item in arr {
|
||||||
let name = match item.get("name").and_then(|val| val.as_str()) {
|
let raw_name = match item.get("name").and_then(|val| val.as_str()) {
|
||||||
Some(name) => name,
|
Some(name) => name,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
let valid_values = match enum_features.get(name) {
|
let name = canonical_filter_name(raw_name);
|
||||||
|
let valid_values = match enum_features.get(name.as_str()) {
|
||||||
Some(values) => *values,
|
Some(values) => *values,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
@ -1298,7 +1432,7 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
|
||||||
.filter(|str_val| valid_values.iter().any(|known| known == str_val))
|
.filter(|str_val| valid_values.iter().any(|known| known == str_val))
|
||||||
.collect();
|
.collect();
|
||||||
if !valid.is_empty() && valid.len() < valid_values.len() {
|
if !valid.is_empty() && valid.len() < valid_values.len() {
|
||||||
result.insert(name.to_string(), json!(valid));
|
result.insert(name, json!(valid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1334,4 +1468,56 @@ mod tests {
|
||||||
let input = " ```json\n {\"a\": 1} \n``` ";
|
let input = " ```json\n {\"a\": 1} \n``` ";
|
||||||
assert_eq!(strip_markdown_fences(input), "{\"a\": 1}");
|
assert_eq!(strip_markdown_fences(input), "{\"a\": 1}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_filter_keys_are_normalized_to_backend_names() {
|
||||||
|
assert_eq!(
|
||||||
|
canonical_filter_name("Schools:primary:good:2:0"),
|
||||||
|
"Good+ primary schools within 2km"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_filter_name("Specific crimes:Burglary%20%28avg%2Fyr%29:1"),
|
||||||
|
"Burglary (avg/yr)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_filter_name("Political vote share:%25%20Labour:0"),
|
||||||
|
"% Labour"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_filter_name(
|
||||||
|
"Transport distance:Distance%20to%20nearest%20amenity%20%28Bus%20stop%29%20%28km%29:0"
|
||||||
|
),
|
||||||
|
"Distance to nearest amenity (Bus stop) (km)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_filters_are_normalized_before_prompting() {
|
||||||
|
let filters = json!({
|
||||||
|
"Political vote share:%25%20Green:0": [40, 100],
|
||||||
|
"Estimated current price": [0, 500000],
|
||||||
|
});
|
||||||
|
|
||||||
|
let normalized = normalize_context_filters(&filters);
|
||||||
|
assert_eq!(normalized["% Green"], json!([40, 100]));
|
||||||
|
assert_eq!(normalized["Estimated current price"], json!([0, 500000]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn travel_time_bounds_accept_min_max_schema() {
|
||||||
|
let item = json!({ "min": 30, "max": 45 });
|
||||||
|
assert_eq!(parse_travel_time_bounds(&item), (Some(30.0), Some(45.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn travel_time_bounds_accept_legacy_bound_value_schema() {
|
||||||
|
let item = json!({ "bound": "max", "value": 30 });
|
||||||
|
assert_eq!(parse_travel_time_bounds(&item), (None, Some(30.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn travel_time_bounds_clamp_and_order_range() {
|
||||||
|
let item = json!({ "min": 150, "max": -10 });
|
||||||
|
assert_eq!(parse_travel_time_bounds(&item), (Some(0.0), Some(120.0)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,17 +160,30 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
|
||||||
for (feat_idx, name) in data.poi_metrics.feature_names.iter().enumerate() {
|
for (feat_idx, name) in data.poi_metrics.feature_names.iter().enumerate() {
|
||||||
if let Some(category) = features::dynamic_poi_distance_category(name) {
|
if let Some(category) = features::dynamic_poi_distance_category(name) {
|
||||||
let stats = &data.poi_metrics.feature_stats[feat_idx];
|
let stats = &data.poi_metrics.feature_stats[feat_idx];
|
||||||
|
let is_park = category.eq_ignore_ascii_case("park");
|
||||||
dynamic_poi_features.push(FeatureInfo::Numeric {
|
dynamic_poi_features.push(FeatureInfo::Numeric {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
min: stats.slider_min,
|
min: stats.slider_min,
|
||||||
max: stats.slider_max,
|
max: stats.slider_max,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
histogram: stats.histogram.clone(),
|
histogram: stats.histogram.clone(),
|
||||||
description: format!("Distance to the closest {category} amenity"),
|
description: if is_park {
|
||||||
detail: format!(
|
"Distance to the closest park or green space".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Distance to the closest {category} amenity")
|
||||||
|
},
|
||||||
|
detail: if is_park {
|
||||||
|
"Straight-line distance in kilometres from the postcode to the nearest park entrance. Covers public parks, gardens, playing fields, and play spaces. Uses access point locations from the OS Open Greenspace dataset, so properties bordering a large park correctly show a short distance.".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
"Straight-line distance in kilometres from the postcode to the nearest {category} amenity in the amenities dataset."
|
"Straight-line distance in kilometres from the postcode to the nearest {category} amenity in the amenities dataset."
|
||||||
),
|
)
|
||||||
source: "osm-pois".to_string(),
|
},
|
||||||
|
source: if is_park {
|
||||||
|
"os-open-greenspace".to_string()
|
||||||
|
} else {
|
||||||
|
"osm-pois".to_string()
|
||||||
|
},
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: " km",
|
suffix: " km",
|
||||||
raw: false,
|
raw: false,
|
||||||
|
|
|
||||||
|
|
@ -68,20 +68,19 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
'strong Manchester accent.',
|
'strong Manchester accent.',
|
||||||
voiceReferenceText:
|
voiceReferenceText:
|
||||||
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
promptText:
|
promptText: 'Flat under £300k, 35 min to Manchester, good schools, low crime, quiet streets',
|
||||||
'Flats <£300k, 35 min to commute Manchester close to an outstanding school in a quite low crime area',
|
|
||||||
travelTimeLabel: 'Manchester city centre',
|
travelTimeLabel: 'Manchester city centre',
|
||||||
exportButtonTitle: 'Export to Excel',
|
exportButtonTitle: 'Export to Excel',
|
||||||
brand: {
|
brand: {
|
||||||
name: 'Perfect Postcode',
|
name: 'Perfect Postcode',
|
||||||
tagline: 'Your best chance to find your next perfect home.',
|
tagline: 'Know where to look before listings take over.',
|
||||||
url: BRAND_URL,
|
url: BRAND_URL,
|
||||||
},
|
},
|
||||||
cues: {
|
cues: {
|
||||||
describe: "Start by describing the type of place you're looking for",
|
describe: 'Stop starting with places you already know.',
|
||||||
dashboard: 'The dashboard will show you the likeliest places that will meet your expectations',
|
dashboard: 'Tell the map what has to be true.',
|
||||||
filters: 'Adjust the filters to narrow down to the best candidates',
|
filters: 'Adjust the filters to narrow down to the best candidates',
|
||||||
details: "And now it's time to dig into the details. Looks good to me!",
|
details: 'Open a postcode before you book a viewing. Check the evidence.',
|
||||||
shortlist:
|
shortlist:
|
||||||
'Now you can take your shortlist and start looking for your next home in your perfect postcode.',
|
'Now you can take your shortlist and start looking for your next home in your perfect postcode.',
|
||||||
},
|
},
|
||||||
|
|
@ -96,21 +95,22 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
voiceReferenceText:
|
voiceReferenceText:
|
||||||
'Willkommen zur Demonstration. Diese Sprecherstimme hören Sie im gesamten Video.',
|
'Willkommen zur Demonstration. Diese Sprecherstimme hören Sie im gesamten Video.',
|
||||||
promptText:
|
promptText:
|
||||||
'Wohnungen unter £300k, 35 Min. Pendelzeit nach Manchester, nahe einer herausragenden Schule in einer sehr kriminalitätsarmen Gegend',
|
'Wohnung unter £300k, 35 Min. nach Manchester, gute Schulen, niedrige Kriminalität, ruhige Straßen',
|
||||||
travelTimeLabel: 'Stadtzentrum Manchester',
|
travelTimeLabel: 'Stadtzentrum Manchester',
|
||||||
exportButtonTitle: 'Als Excel exportieren',
|
exportButtonTitle: 'Als Excel exportieren',
|
||||||
brand: {
|
brand: {
|
||||||
name: 'Perfect Postcode',
|
name: 'Perfect Postcode',
|
||||||
tagline: 'Ihre beste Chance, Ihr nächstes perfektes Zuhause zu finden.',
|
tagline: 'Wissen, wo Sie suchen sollten, bevor Inserate Ihre Suche bestimmen.',
|
||||||
url: BRAND_URL,
|
url: BRAND_URL,
|
||||||
},
|
},
|
||||||
cues: {
|
cues: {
|
||||||
describe: 'Beschreiben Sie zuerst, wonach Sie suchen.',
|
describe: 'Beginnen Sie nicht mit Orten, die Sie schon kennen.',
|
||||||
dashboard: 'Das Dashboard zeigt die Orte, die Ihre Erwartungen am ehesten erfüllen.',
|
dashboard: 'Sagen Sie der Karte, was wirklich stimmen muss.',
|
||||||
filters: 'Passen Sie die Filter an, um die besten Kandidaten einzugrenzen.',
|
filters: 'Falsche Postleitzahlen verschwinden. Verschärfen Sie die Pendelzeit.',
|
||||||
details: 'Jetzt geht es in die Details. Sieht gut aus!',
|
details:
|
||||||
|
'Öffnen Sie eine Postleitzahl, bevor Sie eine Besichtigung buchen. Prüfen Sie die Fakten.',
|
||||||
shortlist:
|
shortlist:
|
||||||
'Jetzt können Sie Ihre Auswahl nehmen und Ihr nächstes Zuhause in Ihrem perfekten Postcode suchen.',
|
'Nehmen Sie diese Auswahl zu den Inseraten und suchen Sie in den richtigen Gegenden.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
|
|
@ -121,20 +121,20 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
'Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin ' +
|
'Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin ' +
|
||||||
'pronunciation and a friendly, practical delivery.',
|
'pronunciation and a friendly, practical delivery.',
|
||||||
voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。',
|
voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。',
|
||||||
promptText: '30万英镑以内的公寓,35分钟通勤到曼彻斯特,靠近优秀学校,犯罪率很低的区域',
|
promptText: '30万英镑以内的公寓,35分钟到曼彻斯特,学校好,犯罪率低,街道安静',
|
||||||
travelTimeLabel: '曼彻斯特市中心',
|
travelTimeLabel: '曼彻斯特市中心',
|
||||||
exportButtonTitle: '导出为 Excel',
|
exportButtonTitle: '导出为 Excel',
|
||||||
brand: {
|
brand: {
|
||||||
name: 'Perfect Postcode',
|
name: 'Perfect Postcode',
|
||||||
tagline: '帮你更有把握找到下一个理想家。',
|
tagline: '先知道该看哪里,再让房源牵着你走。',
|
||||||
url: BRAND_URL,
|
url: BRAND_URL,
|
||||||
},
|
},
|
||||||
cues: {
|
cues: {
|
||||||
describe: '先描述你想找什么样的地方',
|
describe: '不要从已经熟悉的地方开始。',
|
||||||
dashboard: '仪表板会显示最符合你期望的地点',
|
dashboard: '先告诉地图,哪些条件必须满足。',
|
||||||
filters: '调整筛选条件,缩小到最合适的候选区域',
|
filters: '不合适的邮编会消失。再收紧通勤条件。',
|
||||||
details: '现在深入查看细节。看起来不错!',
|
details: '看房前先打开一个邮编,查看证据。',
|
||||||
shortlist: '现在你可以带着候选清单,开始寻找理想邮编里的下一个家。',
|
shortlist: '带着这份清单去房源网站,在更合适的地方搜索。',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hi: {
|
hi: {
|
||||||
|
|
@ -146,22 +146,20 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
'and a friendly, practical delivery.',
|
'and a friendly, practical delivery.',
|
||||||
voiceReferenceText:
|
voiceReferenceText:
|
||||||
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
promptText:
|
promptText: 'Flat under £300k, 35 min to Manchester, good schools, low crime, quiet streets',
|
||||||
'Flats <£300k, 35 min to commute Manchester close to an outstanding school in a quite low crime area',
|
|
||||||
travelTimeLabel: 'Manchester city centre',
|
travelTimeLabel: 'Manchester city centre',
|
||||||
exportButtonTitle: 'Excel में निर्यात करें',
|
exportButtonTitle: 'Excel में निर्यात करें',
|
||||||
brand: {
|
brand: {
|
||||||
name: 'Perfect Postcode',
|
name: 'Perfect Postcode',
|
||||||
tagline: 'Your best chance to find your next perfect home.',
|
tagline: 'Know where to look before listings take over.',
|
||||||
url: BRAND_URL,
|
url: BRAND_URL,
|
||||||
},
|
},
|
||||||
cues: {
|
cues: {
|
||||||
describe: "Start by describing the type of place you're looking for",
|
describe: 'Stop starting with places you already know.',
|
||||||
dashboard: 'The dashboard will show you the likeliest places that will meet your expectations',
|
dashboard: 'Tell the map what has to be true.',
|
||||||
filters: 'Adjust the filters to narrow down to the best candidates',
|
filters: 'Wrong postcodes disappear. Tighten the commute.',
|
||||||
details: "And now it's time to dig into the details. Looks good to me!",
|
details: 'Open a postcode before you book a viewing. Check the evidence.',
|
||||||
shortlist:
|
shortlist: 'Take that shortlist to the listings and search in the right places.',
|
||||||
'Now you can take your shortlist and start looking for your next home in your perfect postcode.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue