+ {/* Best-case toggle — transit only, shown when destination is set */}
+ {slug && mode === 'transit' && (
+
+ )}
+
{/* Time range slider — only show when we have data */}
{slug && dataRange && (
diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts
index e1a057a..87a8d70 100644
--- a/frontend/src/hooks/useDeckLayers.ts
+++ b/frontend/src/hooks/useDeckLayers.ts
@@ -1,6 +1,7 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
+import { cellToBoundary } from 'h3-js';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
@@ -80,13 +81,13 @@ export function useDeckLayers({
// Marching ants animation
const [marchTime, setMarchTime] = useState(0);
- const hasPostcodeGeometry = selectedPostcodeGeometry != null;
+ const hasSelection = selectedPostcodeGeometry != null || selectedHexagonId != null;
useEffect(() => {
- if (!hasPostcodeGeometry) return;
+ if (!hasSelection) return;
setMarchTime(0);
const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
return () => clearInterval(id);
- }, [hasPostcodeGeometry]);
+ }, [hasSelection]);
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
@@ -332,14 +333,11 @@ export function useDeckLayers({
);
},
getLineColor: (d) => {
- if (d.h3 === selectedHexagonIdRef.current)
- return [255, 255, 255, 255] as [number, number, number, number];
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
- if (d.h3 === selectedHexagonIdRef.current) return 3;
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
@@ -481,15 +479,22 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
- // Marching ants highlight layer for selected postcode
+ // Marching ants highlight layer for selected hexagon or postcode
const marchingAntsLayer = useMemo(() => {
- if (!selectedPostcodeGeometry) return null;
+ let geometry: PostcodeGeometry | null = null;
+ if (selectedPostcodeGeometry) {
+ geometry = selectedPostcodeGeometry;
+ } else if (selectedHexagonId) {
+ const boundary = cellToBoundary(selectedHexagonId, true);
+ geometry = { type: 'Polygon', coordinates: [boundary] };
+ }
+ if (!geometry) return null;
return new GeoJsonLayer({
id: 'marching-ants',
data: [
{
type: 'Feature' as const,
- geometry: selectedPostcodeGeometry,
+ geometry,
properties: {},
},
],
@@ -502,7 +507,7 @@ export function useDeckLayers({
marchTime,
extensions: [new MarchingAntsExtension()],
});
- }, [selectedPostcodeGeometry, marchTime]);
+ }, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts
index c23ede1..f12d975 100644
--- a/frontend/src/hooks/useFilters.ts
+++ b/frontend/src/hooks/useFilters.ts
@@ -31,6 +31,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (!meta) return;
if (meta.type === 'enum' && meta.values) {
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
+ } else if (meta.type === 'numeric' && meta.histogram) {
+ setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] }));
} else if (meta.min != null && meta.max != null) {
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
}
diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts
index d916435..09f4797 100644
--- a/frontend/src/hooks/useTravelTime.ts
+++ b/frontend/src/hooks/useTravelTime.ts
@@ -16,6 +16,8 @@ export interface TravelTimeEntry {
slug: string;
label: string;
timeRange: [number, number] | null;
+ /** Use best-case (5th percentile) travel time instead of median. Transit only. */
+ useBest: boolean;
}
/** Field key matching the backend response: tt_{mode}_{slug} */
@@ -33,7 +35,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
- { mode, slug: '', label: '', timeRange: null },
+ { mode, slug: '', label: '', timeRange: null, useBest: false },
]);
}, []);
@@ -63,6 +65,17 @@ export function useTravelTime(initial?: TravelTimeInitial) {
[]
);
+ const handleToggleBest = useCallback(
+ (index: number) => {
+ setEntries((prev) =>
+ prev.map((entry, i) =>
+ i === index ? { ...entry, useBest: !entry.useBest, timeRange: null } : entry
+ )
+ );
+ },
+ []
+ );
+
/** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''),
@@ -76,5 +89,6 @@ export function useTravelTime(initial?: TravelTimeInitial) {
handleRemoveEntry,
handleSetDestination,
handleTimeRangeChange,
+ handleToggleBest,
};
}
diff --git a/frontend/src/hooks/useTutorial.ts b/frontend/src/hooks/useTutorial.ts
index 32180d4..b6ac7fc 100644
--- a/frontend/src/hooks/useTutorial.ts
+++ b/frontend/src/hooks/useTutorial.ts
@@ -9,7 +9,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="filters"]',
title: 'Filter Properties',
content:
- 'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.',
+ 'Use filters to narrow down to areas which contain matching properties. Filter by crime rate, number of schools around, or filter to an area with detached houses. Pin a filter with the eye icon to colour the map by that feature.',
placement: 'right',
disableBeacon: true,
},
@@ -17,7 +17,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="map"]',
title: 'Explore the Map',
content:
- 'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.',
+ 'Pan and zoom to explore property data across England. Click any area (hexagon or postcode boundary) to see detailed stats of historical or currently sold properties matching your filters.',
placement: 'bottom',
disableBeacon: true,
},
@@ -44,6 +44,11 @@ const STEPS: Step[] = [
'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
placement: 'left',
disableBeacon: true,
+ styles: {
+ tooltip: {
+ transform: 'translateY(-50px)',
+ },
+ },
},
];
diff --git a/frontend/src/lib/MarchingAntsExtension.ts b/frontend/src/lib/MarchingAntsExtension.ts
index b462f48..232f008 100644
--- a/frontend/src/lib/MarchingAntsExtension.ts
+++ b/frontend/src/lib/MarchingAntsExtension.ts
@@ -26,7 +26,7 @@ uniform marchingAntsUniforms {
} marchingAnts;`,
'fs:DECKGL_FILTER_COLOR': `\
float marchSegLen = 4.0;
-float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0);
+float marchPos = mod(geometry.uv.y - marchingAnts.marchTime, marchSegLen * 2.0);
if (marchPos < marchSegLen) {
color = vec4(1.0, 1.0, 1.0, color.a);
} else {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index ca53331..9edbb09 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -81,7 +81,8 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
- const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max);
+ const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max;
+ const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max);
return `${name}:${min}:${maxStr}`;
})
.join(';;');
diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts
new file mode 100644
index 0000000..c0a8b5d
--- /dev/null
+++ b/frontend/src/lib/clipboard.ts
@@ -0,0 +1,16 @@
+/** Copy text to clipboard with execCommand fallback for older browsers. */
+export function copyToClipboard(text: string, onSuccess: () => void): void {
+ if (navigator.clipboard?.writeText) {
+ navigator.clipboard.writeText(text).then(onSuccess);
+ } else {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ onSuccess();
+ }
+}
diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts
index d5f25ee..df17cd7 100644
--- a/frontend/src/lib/consts.ts
+++ b/frontend/src/lib/consts.ts
@@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.42, west: -0.34, north: 51.60, east:
export const INITIAL_VIEW_STATE: ViewState = {
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
- zoom: 14,
+ zoom: 15,
pitch: 0,
};
@@ -33,10 +33,9 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 10.5, resolution: 7 },
{ maxZoom: 11.5, resolution: 8 },
{ maxZoom: 13, resolution: 9 },
- { maxZoom: Infinity, resolution: 10 },
] as const;
-export const POSTCODE_ZOOM_THRESHOLD = 16;
+export const POSTCODE_ZOOM_THRESHOLD = 14.5;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },
diff --git a/frontend/src/lib/external-search.ts b/frontend/src/lib/external-search.ts
index 6c08787..4a4dc79 100644
--- a/frontend/src/lib/external-search.ts
+++ b/frontend/src/lib/external-search.ts
@@ -4,6 +4,8 @@ export interface HexagonLocation {
lat: number;
lon: number;
resolution: number;
+ postcode?: string;
+ isPostcode?: boolean;
}
const PROPERTY_TYPE_MAP: Record<
@@ -32,10 +34,10 @@ export const H3_RADIUS_MILES: Record
= {
6: 3,
7: 1,
8: 0.5,
- 9: 0.25,
- 10: 0.25,
- 11: 0.25,
- 12: 0.25,
+ 9: 1,
+ 10: 1,
+ 11: 1,
+ 12: 1,
};
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
@@ -46,13 +48,21 @@ function nearestRadius(target: number, allowed: number[]): number {
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
}
-export function buildPropertySearchUrls(
- location: HexagonLocation,
- filters: FeatureFilters
-): { rightmove: string; onthemarket: string; zoopla: string } {
- const { lat, lon, resolution } = location;
- const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
- const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
+interface SearchUrlOptions {
+ location: HexagonLocation;
+ filters: FeatureFilters;
+ rightmoveLocationId?: string;
+}
+
+export function buildPropertySearchUrls({
+ location,
+ filters,
+ rightmoveLocationId,
+}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
+ const { postcode, resolution, isPostcode } = location;
+ if (!postcode) return null;
+
+ const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
const priceFilter = filters['Last known price'];
const minPrice =
@@ -66,43 +76,51 @@ export function buildPropertySearchUrls(
? (propertyTypes as string[])
: [];
- const rmParams = new URLSearchParams();
- rmParams.set('searchLocation', coordStr);
- rmParams.set('channel', 'BUY');
- rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
- if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
- if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
- if (selectedTypes.length > 0) {
- const rmTypes = [
- ...new Set(
- selectedTypes.flatMap((t) => {
- const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
- return mapped ? mapped.split(',') : [];
- })
- ),
- ];
- if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
+ // Rightmove — requires locationIdentifier from typeahead API
+ let rightmove: string | null = null;
+ if (rightmoveLocationId) {
+ const rmParams = new URLSearchParams();
+ rmParams.set('searchLocation', postcode);
+ rmParams.set('useLocationIdentifier', 'true');
+ rmParams.set('locationIdentifier', rightmoveLocationId);
+ rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
+ if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
+ if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
+ if (selectedTypes.length > 0) {
+ const rmTypes = [
+ ...new Set(
+ selectedTypes.flatMap((t) => {
+ const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
+ return mapped ? mapped.split(',') : [];
+ })
+ ),
+ ];
+ if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
+ }
+ rmParams.set('_includeSSTC', 'on');
+ rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
}
- const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
- let otmType = 'property';
- if (selectedTypes.length > 0) {
- const otmTypes = [
- ...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
- ];
- if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
- }
+ // OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
+ const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
- otmParams.set('search-site', 'geo');
- otmParams.set('geo-lat', String(lat));
- otmParams.set('geo-lng', String(lon));
- const onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
+ if (selectedTypes.length > 0) {
+ const otmTypes = [
+ ...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
+ ];
+ for (const ot of otmTypes) {
+ otmParams.append('prop-types', ot!);
+ }
+ }
+ otmParams.set('view', 'map-list');
+ const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
+ // Zoopla
const zParams = new URLSearchParams();
- zParams.set('q', coordStr);
+ zParams.set('q', postcode);
zParams.set('search_source', 'for-sale');
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
@@ -115,7 +133,6 @@ export function buildPropertySearchUrls(
zParams.append('property_sub_type', zt!);
}
}
- zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
return { rightmove, onthemarket, zoopla };
diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts
index 3f97a18..2bf811b 100644
--- a/frontend/src/lib/url-state.ts
+++ b/frontend/src/lib/url-state.ts
@@ -71,7 +71,7 @@ export function parseUrlState(): {
}
// Travel time: repeated `tt` params
- // Format: mode:slug:label or mode:slug:label:min:max
+ // Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const entries: TravelTimeEntry[] = [];
@@ -82,15 +82,17 @@ export function parseUrlState(): {
if (!TRANSPORT_MODES.includes(mode)) continue;
const slug = parts[1];
const label = decodeURIComponent(parts[2]);
+ const useBest = parts.length >= 4 && parts[3] === 'b';
+ const rangeOffset = useBest ? 1 : 0;
let timeRange: [number, number] | null = null;
- if (parts.length >= 5) {
- const min = Number(parts[3]);
- const max = Number(parts[4]);
+ if (parts.length >= 5 + rangeOffset) {
+ const min = Number(parts[3 + rangeOffset]);
+ const max = Number(parts[4 + rangeOffset]);
if (!isNaN(min) && !isNaN(max)) {
timeRange = [min, max];
}
}
- entries.push({ mode, slug, label, timeRange });
+ entries.push({ mode, slug, label, timeRange, useBest });
}
if (entries.length > 0) {
result.travelTime = { entries };
@@ -139,6 +141,7 @@ export function stateToParams(
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
+ if (entry.useBest) val += ':b';
if (entry.timeRange) {
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index ff61a57..7a39379 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -172,4 +172,5 @@ export interface HexagonStatsResponse {
numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[];
price_history?: PricePoint[];
+ central_postcode?: string;
}
diff --git a/r5-java/run.sh b/r5-java/run.sh
index 968977a..ab19ac1 100755
--- a/r5-java/run.sh
+++ b/r5-java/run.sh
@@ -4,6 +4,9 @@ set -euo pipefail
# Batch-compute travel times from all places to all England postcodes
# for all transport modes (car, bicycle, walking, transit).
#
+# Uses full England OSM + 2 GTFS feeds (BODS buses, National Rail).
+# R5's TransportNetwork.fromDirectory() picks up all .osm.pbf and .zip files.
+#
# Uses each place as origin with all postcodes as destinations — R5 does one
# routing computation per place, then reads off travel times to all postcodes.
# For car/bicycle/walking this is symmetric (place->postcode = postcode->place).
@@ -15,11 +18,10 @@ set -euo pipefail
#
# Usage:
# ./r5-java/run.sh
-# ./r5-java/run.sh --threads 8 --heap 24g --output-dir property-data/travel-times
# --- Defaults ---
-THREADS=16
-HEAP=16g
+THREADS=4
+HEAP=12g
NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times
R5_DIR=r5-java
@@ -102,25 +104,26 @@ fi
# R5 writes .mapdb temp files next to OSM/GTFS files during network construction.
# Copy source data to a writable build dir to avoid polluting the originals.
mkdir -p "$NETWORK_DIR"
-DATA_DIR="property-data/transit"
+TRANSIT_SRC="property-data/transit"
+NETWORK_DATA_DIR="$TRANSIT_SRC"
if [ ! -f "$NETWORK_DIR/network.dat" ]; then
BUILD_DIR="$NETWORK_DIR/build"
echo "--- No cached network — copying transit data to build dir ---"
mkdir -p "$BUILD_DIR"
- if ! cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then
- echo "Warning: no .osm.pbf files found in property-data/transit/raw/"
+ if ! cp "$TRANSIT_SRC"/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then
+ echo "Warning: no .osm.pbf files found in $TRANSIT_SRC/raw/"
fi
- if ! cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null; then
- echo "Warning: no .zip files found in property-data/transit/"
+ if ! cp "$TRANSIT_SRC"/*.zip "$BUILD_DIR/" 2>/dev/null; then
+ echo "Warning: no .zip files found in $TRANSIT_SRC/"
fi
- DATA_DIR="$BUILD_DIR"
+ NETWORK_DATA_DIR="$BUILD_DIR"
fi
# --- Step 5: Run batch ---
echo ""
echo "--- Starting batch computation ---"
-DATA_DIR="$DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
+DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
--postcodes property-data/arcgis_data.parquet \
--places property-data/places.parquet \
diff --git a/r5-java/src/main/java/propertymap/App.java b/r5-java/src/main/java/propertymap/App.java
index f00935a..afdc6d8 100644
--- a/r5-java/src/main/java/propertymap/App.java
+++ b/r5-java/src/main/java/propertymap/App.java
@@ -192,6 +192,9 @@ public class App {
if (attempt < MAX_RETRIES) {
System.err.printf("%n [RETRY %d/%d] %s: %s%n",
attempt + 1, MAX_RETRIES, name, e.getMessage());
+ } else {
+ System.err.printf("%n [FAIL TRACE] %s:%n", name);
+ e.printStackTrace(System.err);
}
}
}
@@ -215,7 +218,7 @@ public class App {
String safe = name.toLowerCase()
.replaceAll("[^a-z0-9 -]", "")
.replaceAll("\\s+", "-");
- return String.format("%04d-%s.parquet", index, safe);
+ return String.format("%06d-%s.parquet", index, safe);
}
private static String requiredArg(String[] args, String name) {
diff --git a/r5-java/src/main/java/propertymap/Router.java b/r5-java/src/main/java/propertymap/Router.java
index 108f4ae..bff77a8 100644
--- a/r5-java/src/main/java/propertymap/Router.java
+++ b/r5-java/src/main/java/propertymap/Router.java
@@ -29,6 +29,10 @@ public class Router {
private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00
private static final int MAX_TRIP_DURATION_MINUTES = 120;
+ // Percentile indices in R5 result arrays (order must match task.percentiles in buildTask)
+ private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only)
+ private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0)
+
/** Result of computing travel times for a single origin with spatial pre-filtering. */
record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {}
@@ -102,10 +106,9 @@ public class Router {
boolean isTransit = mode.equals("transit");
short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date);
- // For transit: allTimes[0]=best (5th percentile), allTimes[1]=median (50th)
- // For others: allTimes[0]=median (50th), no best
- short[] medianTimes = isTransit ? allTimes[1] : allTimes[0];
- short[] bestTimes = isTransit ? allTimes[0] : null;
+ // Transit requests [5th, 50th] percentiles; others request [50th] only
+ short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0];
+ short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null;
return new FilteredResult(filtered, medianTimes, bestTimes);
}
@@ -205,13 +208,24 @@ public class Router {
OneOriginResult result = computer.computeTravelTimes();
TravelTimeResult tt = result.travelTimes;
- if (tt != null) {
- int[][] values = tt.getValues();
- for (int p = 0; p < nPercentiles && p < values.length; p++) {
- for (int i = 0; i < chunk.originalIndices.length && i < values[p].length; i++) {
- if (values[p][i] != Integer.MAX_VALUE) {
- allTimes[p][chunk.originalIndices[i]] = (short) values[p][i];
- }
+ if (tt == null) {
+ throw new RuntimeException("R5 returned null travelTimes for chunk with "
+ + chunk.originalIndices.length + " destinations");
+ }
+ int[][] values = tt.getValues();
+ if (values.length < nPercentiles) {
+ throw new RuntimeException("R5 returned " + values.length + " percentiles, expected "
+ + nPercentiles);
+ }
+ for (int p = 0; p < nPercentiles; p++) {
+ if (values[p].length < chunk.originalIndices.length) {
+ throw new RuntimeException("R5 returned " + values[p].length
+ + " travel times for percentile " + p + ", expected "
+ + chunk.originalIndices.length);
+ }
+ for (int i = 0; i < chunk.originalIndices.length; i++) {
+ if (values[p][i] != Integer.MAX_VALUE) {
+ allTimes[p][chunk.originalIndices[i]] = (short) values[p][i];
}
}
}
diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock
index d0c8390..6f8a242 100644
--- a/server-rs/Cargo.lock
+++ b/server-rs/Cargo.lock
@@ -2743,6 +2743,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
+ "futures-util",
"h2",
"http",
"http-body",
@@ -2767,12 +2768,14 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-rustls",
+ "tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "wasm-streams",
"web-sys",
"webpki-roots",
]
@@ -3803,6 +3806,19 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "web-sys"
version = "0.3.85"
diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml
index fa897bd..72ad1e9 100644
--- a/server-rs/Cargo.toml
+++ b/server-rs/Cargo.toml
@@ -22,7 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tracing-appender = "0.2"
metrics = "0.24"
metrics-exporter-prometheus = "0.16"
-reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
+reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
urlencoding = "2"
rust_xlsxwriter = "0.79"
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
diff --git a/server-rs/src/data/places.rs b/server-rs/src/data/places.rs
index d3abb0a..25f4f6c 100644
--- a/server-rs/src/data/places.rs
+++ b/server-rs/src/data/places.rs
@@ -22,18 +22,8 @@ pub struct PlaceData {
fn type_rank(place_type: &str) -> u8 {
match place_type {
"city" => 0,
- "borough" => 1,
- "town" => 2,
- "suburb" => 3,
- "quarter" => 4,
- "neighbourhood" => 5,
- "village" => 6,
- "station" => 7,
- "island" => 8,
- "hamlet" => 9,
- "locality" => 10,
- "isolated_dwelling" => 11,
- _ => 12,
+ "station" => 1,
+ _ => 2,
}
}
@@ -159,10 +149,7 @@ mod tests {
#[test]
fn type_rank_ordering() {
- assert!(type_rank("city") < type_rank("town"));
- assert!(type_rank("town") < type_rank("suburb"));
- assert!(type_rank("suburb") < type_rank("village"));
- assert!(type_rank("village") < type_rank("hamlet"));
- assert!(type_rank("hamlet") < type_rank("isolated_dwelling"));
+ assert!(type_rank("city") < type_rank("station"));
+ assert!(type_rank("station") < type_rank("unknown"));
}
}
diff --git a/server-rs/src/data/travel_time.rs b/server-rs/src/data/travel_time.rs
index fe5814b..d8c6709 100644
--- a/server-rs/src/data/travel_time.rs
+++ b/server-rs/src/data/travel_time.rs
@@ -8,8 +8,15 @@ use polars::lazy::frame::LazyFrame;
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::info;
-/// Cached postcode → travel_minutes mapping for a single destination file.
-pub type TravelData = Arc>;
+/// Per-postcode travel time data: median and optional best-case (transit only).
+#[derive(Clone, Copy)]
+pub struct TravelDataRow {
+ pub minutes: i16,
+ pub best_minutes: Option,
+}
+
+/// Cached postcode → travel time data for a single destination file.
+pub type TravelData = Arc>;
/// Simple LRU cache for travel time data, limited to `capacity` entries.
struct LruCache {
@@ -159,12 +166,23 @@ impl TravelTimeStore {
.context("Missing 'travel_minutes' column")?
.i16()
.context("'travel_minutes' is not i16")?;
+ let best = df
+ .column("best_minutes")
+ .ok()
+ .map(|col| col.i16().expect("'best_minutes' is not i16"));
let mut map = FxHashMap::default();
map.reserve(df.height());
- for (pc, min) in postcodes.into_iter().zip(minutes.into_iter()) {
+ for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
if let (Some(pc), Some(min)) = (pc, min) {
- map.insert(pc.to_string(), min);
+ let best_min = best.as_ref().and_then(|b| b.get(i));
+ map.insert(
+ pc.to_string(),
+ TravelDataRow {
+ minutes: min,
+ best_minutes: best_min,
+ },
+ );
}
}
diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs
index 997c81f..0104e8d 100644
--- a/server-rs/src/main.rs
+++ b/server-rs/src/main.rs
@@ -424,6 +424,7 @@ async fn main() -> anyhow::Result<()> {
let state_invites_create = state.clone();
let state_invite_get = state.clone();
let state_redeem_invite = state.clone();
+ let state_rightmove = state.clone();
let api = Router::new()
.route(
@@ -495,6 +496,10 @@ async fn main() -> anyhow::Result<()> {
"/api/streetview",
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
)
+ .route(
+ "/api/rightmove-location",
+ get(move |query| routes::get_rightmove_typeahead(state_rightmove.clone(), query)),
+ )
.route(
"/api/subscription",
patch(move |ext, body| {
@@ -569,7 +574,7 @@ async fn main() -> anyhow::Result<()> {
let app = if let Some(ref dist) = cli.dist {
api.fallback_service(
- ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))),
+ ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))),
)
} else {
api
diff --git a/server-rs/src/pocketbase.rs b/server-rs/src/pocketbase.rs
index 7afb7d1..7a81246 100644
--- a/server-rs/src/pocketbase.rs
+++ b/server-rs/src/pocketbase.rs
@@ -405,35 +405,49 @@ pub async fn ensure_oauth_providers(
let base_url = base_url.trim_end_matches('/');
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
- // GET current settings
+ // Set meta.appURL in global settings for OAuth redirects
+ let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
let settings_url = format!("{base_url}/api/settings");
+ let patch_resp = client
+ .patch(&settings_url)
+ .header("Authorization", format!("Bearer {token}"))
+ .json(&serde_json::json!({ "meta": { "appURL": app_url } }))
+ .send()
+ .await?;
+ if !patch_resp.status().is_success() {
+ let status = patch_resp.status();
+ let text = patch_resp.text().await.unwrap_or_default();
+ anyhow::bail!("Failed to update PocketBase meta.appURL ({status}): {text}");
+ }
+ info!("PocketBase meta.appURL set to {app_url}");
+
+ // PocketBase 0.23+: OAuth providers are configured per-collection, not in global settings.
+ // GET the users collection to update its oauth2 config.
+ let collection_url = format!("{base_url}/api/collections/users");
let resp = client
- .get(&settings_url)
+ .get(&collection_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
-
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
- anyhow::bail!("Failed to fetch PocketBase settings ({status}): {text}");
+ anyhow::bail!("Failed to fetch users collection ({status}): {text}");
}
- let mut settings: serde_json::Value = resp.json().await?;
+ let mut collection: serde_json::Value = resp.json().await?;
- // Set meta.appUrl for OAuth redirect
- let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
- if let Some(meta) = settings.get_mut("meta") {
- meta["appUrl"] = serde_json::json!(app_url);
- } else {
- settings["meta"] = serde_json::json!({ "appUrl": app_url });
- }
+ let oauth2 = collection
+ .get_mut("oauth2")
+ .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2 field"))?;
- // Update OAuth2 providers
- let providers = settings
- .pointer_mut("/oauth2/providers")
+ // Ensure enabled
+ oauth2["enabled"] = serde_json::json!(true);
+
+ let providers = oauth2
+ .get_mut("providers")
.and_then(|v| v.as_array_mut())
- .ok_or_else(|| anyhow::anyhow!("PocketBase settings missing oauth2.providers array — cannot configure OAuth"))?;
+ .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2.providers array"))?;
let google = match providers
.iter()
@@ -441,7 +455,7 @@ pub async fn ensure_oauth_providers(
{
Some(idx) => &mut providers[idx],
None => {
- info!("Google provider not found in PocketBase settings — adding it");
+ info!("Google provider not found — adding it");
providers.push(serde_json::json!({"name": "google"}));
providers.last_mut().expect("just pushed")
}
@@ -449,23 +463,20 @@ pub async fn ensure_oauth_providers(
google["clientId"] = serde_json::json!(google_client_id);
google["clientSecret"] = serde_json::json!(google_client_secret);
- google["enabled"] = serde_json::json!(true);
- info!("Configured Google OAuth provider");
- // PATCH settings back
+ // PATCH the collection
let patch_resp = client
- .patch(&settings_url)
+ .patch(&collection_url)
.header("Authorization", format!("Bearer {token}"))
- .json(&settings)
+ .json(&serde_json::json!({ "oauth2": oauth2 }))
.send()
.await?;
-
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
- anyhow::bail!("Failed to update PocketBase settings ({status}): {text}");
+ anyhow::bail!("Failed to update users collection OAuth ({status}): {text}");
}
- info!("PocketBase OAuth settings updated (appUrl: {app_url})");
+ info!("PocketBase OAuth configured on users collection");
Ok(())
}
diff --git a/server-rs/src/routes.rs b/server-rs/src/routes.rs
index 6089973..f04e226 100644
--- a/server-rs/src/routes.rs
+++ b/server-rs/src/routes.rs
@@ -19,6 +19,7 @@ mod streetview;
mod stripe_webhook;
mod newsletter;
pub(crate) mod pricing;
+mod rightmove_typeahead;
mod subscription;
mod tiles;
pub(crate) mod travel_time;
@@ -46,4 +47,5 @@ pub use pricing::get_pricing;
pub use stripe_webhook::post_stripe_webhook;
pub use subscription::patch_subscription;
pub use tiles::{get_style, get_tile, init_tile_reader};
+pub use rightmove_typeahead::get_rightmove_typeahead;
pub use travel_modes::get_travel_modes;
diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs
index 26a9428..c36785b 100644
--- a/server-rs/src/routes/hexagon_stats.rs
+++ b/server-rs/src/routes/hexagon_stats.rs
@@ -59,6 +59,8 @@ pub struct HexagonStatsResponse {
pub enum_features: Vec,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub price_history: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub central_postcode: Option,
}
#[derive(Deserialize)]
@@ -136,6 +138,31 @@ pub async fn get_hexagon_stats(
let total_count = matching_rows.len();
+ // Find the postcode of the property closest to the hexagon center
+ let central_postcode = if !matching_rows.is_empty() {
+ let center: h3o::LatLng = cell.into();
+ let center_lat = center.lat() as f32;
+ let center_lon = center.lng() as f32;
+ let closest_row = matching_rows
+ .iter()
+ .copied()
+ .min_by(|&a, &b| {
+ let da_lat = state.data.lat[a] - center_lat;
+ let da_lon = state.data.lon[a] - center_lon;
+ let db_lat = state.data.lat[b] - center_lat;
+ let db_lon = state.data.lon[b] - center_lon;
+ let dist_a = da_lat * da_lat + da_lon * da_lon;
+ let dist_b = db_lat * db_lat + db_lon * db_lon;
+ dist_a
+ .partial_cmp(&dist_b)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ })
+ .expect("matching_rows is non-empty");
+ Some(state.data.postcode(closest_row).to_string())
+ } else {
+ None
+ };
+
let price_history = stats::extract_price_history(
&matching_rows,
feature_data,
@@ -170,6 +197,7 @@ pub async fn get_hexagon_stats(
numeric_features,
enum_features: enum_features_out,
price_history,
+ central_postcode,
})
})
.await
diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs
index 2ee26b0..3eef756 100644
--- a/server-rs/src/routes/hexagons.rs
+++ b/server-rs/src/routes/hexagons.rs
@@ -43,12 +43,13 @@ pub struct HexagonParams {
struct TravelEntry {
mode: String,
slug: String,
+ use_best: bool,
filter_min: Option,
filter_max: Option,
}
/// Parse `travel` param into a list of travel entries.
-/// Format: `mode:slug` or `mode:slug:min:max`
+/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
fn parse_travel_entries(travel_str: &str) -> Result, String> {
let mut entries = Vec::new();
let mut seen_keys = Vec::new();
@@ -63,12 +64,15 @@ fn parse_travel_entries(travel_str: &str) -> Result, String> {
let mode = parts[0].trim().to_string();
let slug = parts[1].trim().to_string();
- let (filter_min, filter_max) = if parts.len() >= 4 {
- let min: f32 = parts[2]
+ let use_best = parts.len() >= 3 && parts[2].trim() == "best";
+ let filter_offset = if use_best { 1 } else { 0 };
+
+ let (filter_min, filter_max) = if parts.len() >= 4 + filter_offset {
+ let min: f32 = parts[2 + filter_offset]
.trim()
.parse()
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
- let max: f32 = parts[3]
+ let max: f32 = parts[3 + filter_offset]
.trim()
.parse()
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
@@ -85,6 +89,7 @@ fn parse_travel_entries(travel_str: &str) -> Result, String> {
entries.push(TravelEntry {
mode,
slug,
+ use_best,
filter_min,
filter_max,
});
@@ -286,7 +291,14 @@ pub async fn get_hexagons(
let postcode = pc_interner.resolve(&pc_keys[row]);
travel_minutes.reserve(travel_entries.len());
for (ti, entry) in travel_entries.iter().enumerate() {
- let minutes = travel_data[ti].get(postcode).copied();
+ let row_data = travel_data[ti].get(postcode);
+ let minutes = row_data.map(|r| {
+ if entry.use_best {
+ r.best_minutes.unwrap_or(r.minutes)
+ } else {
+ r.minutes
+ }
+ });
travel_minutes.push(minutes);
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
match minutes {
diff --git a/server-rs/src/routes/pb_proxy.rs b/server-rs/src/routes/pb_proxy.rs
index 122507d..06fc2db 100644
--- a/server-rs/src/routes/pb_proxy.rs
+++ b/server-rs/src/routes/pb_proxy.rs
@@ -11,10 +11,11 @@ use crate::state::AppState;
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
/// responses are passed through to the browser (needed for OAuth flows).
+/// No overall timeout because SSE (Server-Sent Events) connections used by
+/// PocketBase realtime/OAuth2 are long-lived streams.
static PROXY_CLIENT: LazyLock = LazyLock::new(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
- .timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.build()
.expect("Failed to build proxy HTTP client")
@@ -97,16 +98,12 @@ pub async fn proxy_to_pocketbase(state: Arc, req: Request) -> impl Int
}
}
- match upstream.bytes().await {
- Ok(bytes) => response.body(Body::from(bytes)).unwrap(),
- Err(err) => {
- warn!("Failed to read upstream response: {err}");
- Response::builder()
- .status(StatusCode::BAD_GATEWAY)
- .body(Body::from("Failed to read upstream response"))
- .unwrap()
- }
- }
+ // Stream the response body instead of buffering it entirely.
+ // This is critical for SSE (Server-Sent Events) used by PocketBase's
+ // realtime system and OAuth2 flow — buffering would hang forever
+ // since SSE responses never complete.
+ let body = Body::from_stream(upstream.bytes_stream());
+ response.body(body).unwrap()
}
Err(err) => {
warn!("PocketBase proxy error: {err}");
diff --git a/server-rs/src/routes/postcode_stats.rs b/server-rs/src/routes/postcode_stats.rs
index 8e6aed1..6b96c4a 100644
--- a/server-rs/src/routes/postcode_stats.rs
+++ b/server-rs/src/routes/postcode_stats.rs
@@ -135,6 +135,7 @@ pub async fn get_postcode_stats(
numeric_features,
enum_features: enum_features_out,
price_history,
+ central_postcode: None,
})
})
.await
diff --git a/server-rs/src/routes/rightmove_typeahead.rs b/server-rs/src/routes/rightmove_typeahead.rs
new file mode 100644
index 0000000..a241290
--- /dev/null
+++ b/server-rs/src/routes/rightmove_typeahead.rs
@@ -0,0 +1,83 @@
+use std::sync::Arc;
+
+use axum::extract::Query;
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Json};
+use serde::{Deserialize, Serialize};
+use tracing::warn;
+
+use crate::state::AppState;
+
+const TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
+
+#[derive(Deserialize)]
+pub struct TypeaheadParams {
+ pub postcode: String,
+}
+
+#[derive(Serialize)]
+pub struct TypeaheadResponse {
+ pub location_identifier: String,
+}
+
+#[derive(Deserialize)]
+struct RightmoveMatch {
+ #[serde(rename = "type")]
+ match_type: String,
+ #[serde(rename = "displayName")]
+ display_name: String,
+ id: serde_json::Value,
+}
+
+#[derive(Deserialize)]
+struct RightmoveTypeaheadResponse {
+ matches: Vec,
+}
+
+pub async fn get_rightmove_typeahead(
+ state: Arc,
+ Query(params): Query,
+) -> Result, axum::response::Response> {
+ let postcode = params.postcode.trim().to_uppercase();
+
+ let resp = state
+ .http_client
+ .get(TYPEAHEAD_URL)
+ .query(&[("query", &postcode), ("limit", &"10".to_string())])
+ .send()
+ .await
+ .map_err(|err| {
+ warn!(error = %err, "Rightmove typeahead request failed");
+ (StatusCode::BAD_GATEWAY, "Rightmove typeahead unavailable").into_response()
+ })?;
+
+ let data: RightmoveTypeaheadResponse = resp.json().await.map_err(|err| {
+ warn!(error = %err, "Failed to parse Rightmove typeahead response");
+ (StatusCode::BAD_GATEWAY, "Invalid typeahead response").into_response()
+ })?;
+
+ // Look for POSTCODE match first, then OUTCODE
+ for match_type in &["POSTCODE", "OUTCODE"] {
+ for m in &data.matches {
+ if m.match_type == *match_type
+ && m.display_name.to_uppercase().replace(' ', "")
+ == postcode.replace(' ', "")
+ {
+ let id = match &m.id {
+ serde_json::Value::Number(n) => n.to_string(),
+ serde_json::Value::String(s) => s.clone(),
+ other => other.to_string(),
+ };
+ return Ok(Json(TypeaheadResponse {
+ location_identifier: format!("{}^{}", match_type, id),
+ }));
+ }
+ }
+ }
+
+ Err((
+ StatusCode::NOT_FOUND,
+ format!("No Rightmove location found for: {}", postcode),
+ )
+ .into_response())
+}