This commit is contained in:
Ruby 2026-02-22 23:22:35 +00:00
commit afa6934a2d
91 changed files with 2122 additions and 1360 deletions

View file

@ -28,8 +28,6 @@ MERGE_STAMP := $(DATA_DIR)/.merge_done
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/certificates.csv
JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet
JT_FITZROVIA := $(MANUAL_DATA)/journey_times_fitzrovia.parquet
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
CRIME_DIR := $(MANUAL_DATA)/crime
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
@ -68,8 +66,7 @@ PMTILES_VERSION := 1.22.3
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-pbf download-places \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
transform-school-proximity transform-geosure transform-postcode-boundaries \
generate-postcode-boundaries \
journey-times
generate-postcode-boundaries
prepare: $(PRICES_STAMP)
merge: $(MERGE_STAMP)
@ -185,32 +182,6 @@ $(GREENSPACE): $(PBF)
$(PLACES): $(PBF)
uv run python -m pipeline.download.places --output $@ --pbf $(PBF)
# ── Journey times (requires TFL_API_KEY) ──────────────────────────────────────
$(JT_BANK):
@echo ""
@echo "=== TFL journey times (bank) not found ==="
@echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/"
@echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
@echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank"
@echo ""
@exit 1
$(JT_FITZROVIA):
@echo ""
@echo "=== TFL journey times (fitzrovia) not found ==="
@echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/"
@echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
@echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia"
@echo ""
@exit 1
journey-times: $(ARCGIS)
ifndef DEST
$(error DEST required — e.g. make journey-times DEST=bank)
endif
uv run python -m pipeline.journey_times --destination $(DEST) --output-dir $(DATA_DIR) --postcodes $(ARCGIS)
# ── Transforms ────────────────────────────────────────────────────────────────
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN)
@ -256,15 +227,13 @@ $(PC_BOUNDARIES):
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL)
uv run python -m pipeline.transform.merge \
--epc-pp $(EPC_PP) \
--arcgis $(ARCGIS) \
--iod $(IOD) \
--poi-proximity $(POI_PROXIMITY) \
--journey-times-bank $(JT_BANK) \
--journey-times-fitzrovia $(JT_FITZROVIA) \
--ethnicity $(ETHNICITY) \
--crime $(CRIME) \
--noise $(NOISE) \

View file

@ -25,9 +25,6 @@ rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip
https://xploria.co.uk/data-sources/
---
- stripe

View file

@ -16,11 +16,6 @@ tasks:
cmds:
- uv run python -m pipeline.download.map_assets --output frontend/public/assets
download:places:
desc: Extract place names from OSM PBF
cmds:
- uv run python -m pipeline.download.places --output ./property-data/places.parquet {{.CLI_ARGS}}
test:
desc: Run all tests (Python and Rust)
cmds:
@ -45,10 +40,6 @@ tasks:
cmds:
- docker compose up --build
build:server:
desc: Build server for production
dir: server-rs

View file

@ -12,6 +12,7 @@ import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
import VerificationBanner from './components/ui/VerificationBanner';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
@ -77,7 +78,10 @@ export default function App() {
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
const [initialLoading, setInitialLoading] = useState(true);
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(() => {
const fromPath = pathToPage(window.location.pathname);
return fromPath?.inviteCode ?? null;
});
const [activePage, setActivePage] = useState<Page>(() => {
if (isScreenshotMode) return 'dashboard';
@ -88,16 +92,13 @@ export default function App() {
// Restore from history state (e.g. popstate)
if (window.history.state?.page) return window.history.state.page;
// Unknown path — track as 404
if (window.location.pathname !== '/') {
trackEvent('404', { path: window.location.pathname });
}
return 'home';
});
useEffect(() => {
const fromPath = pathToPage(window.location.pathname);
if (fromPath?.inviteCode) {
setInviteCode(fromPath.inviteCode);
}
}, []);
const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile();
const {
@ -126,6 +127,7 @@ export default function App() {
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
trackEvent('Purchase');
setShowLicenseSuccess(true);
refreshAuth();
}
@ -230,7 +232,7 @@ export default function App() {
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || {}}
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'}
@ -326,7 +328,7 @@ export default function App() {
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || {}}
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'}
@ -366,12 +368,13 @@ export default function App() {
<SaveSearchModal
onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch}
onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }}
saving={savedSearches.saving}
error={savedSearches.error}
/>
)}
{showLicenseSuccess && (
<LicenseSuccessModal onClose={() => setShowLicenseSuccess(false)} />
<LicenseSuccessModal onClose={() => { setShowLicenseSuccess(false); navigateTo('dashboard'); }} />
)}
</div>
);

View file

@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { formatRelativeTime } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -42,37 +43,24 @@ function SavedSearchesContent({
setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]);
const copyToClipboard = useCallback((text: string, id: string) => {
const onSuccess = () => {
const doCopy = useCallback((text: string, id: string) => {
copyToClipboard(text, () => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
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();
}
});
}, []);
const handleShare = useCallback(async (params: string, id: string) => {
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
copyToClipboard(shortUrl, id);
doCopy(shortUrl, id);
} catch {
copyToClipboard(`${window.location.origin}/?${params}`, id);
doCopy(`${window.location.origin}/?${params}`, id);
} finally {
setSharingId(null);
}
}, [copyToClipboard]);
}, [doCopy]);
return (
<>
@ -270,7 +258,7 @@ function SettingsContent({
const handleCopyInvite = () => {
if (!inviteUrl) return;
navigator.clipboard.writeText(inviteUrl).then(() => {
copyToClipboard(inviteUrl, () => {
setInviteCopied(true);
setTimeout(() => setInviteCopied(false), 2000);
});
@ -284,7 +272,7 @@ function SettingsContent({
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
return (
<div className="max-w-lg mx-auto">
<div className="max-w-lg mx-auto space-y-6">
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
{/* Email */}
<div className="px-5 py-4 flex items-center justify-between">
@ -380,7 +368,7 @@ function SettingsContent({
{isLicensed && (
<div className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'}
{user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
</p>
{inviteUrl ? (
<div className="flex items-center gap-2">
@ -409,7 +397,7 @@ function SettingsContent({
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
>
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{user.isAdmin ? 'Generate invite link' : 'Generate referral link'}
{user.isAdmin ? 'Generate free invite link' : 'Generate referral link'}
</button>
)}
{inviteError && (
@ -455,6 +443,20 @@ function SettingsContent({
</div>
)}
</div>
{/* Support */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
<a
href="mailto:support@propertymap.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@propertymap.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
We typically respond within 24 hours.
</p>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
import ScrollStory from './ScrollStory';
@ -6,6 +6,7 @@ import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { trackEvent } from '../../lib/analytics';
import type { FeatureMeta } from '../../types';
export default function HomePage({
@ -30,6 +31,35 @@ export default function HomePage({
const whyRef = useFadeInRef();
const ctaRef = useFadeInRef();
// Scroll depth tracking
const scrolledSections = useRef(new Set<string>());
useEffect(() => {
const ids = ['how-it-works', 'demo'];
const observers: IntersectionObserver[] = [];
ids.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !scrolledSections.current.has(id)) {
scrolledSections.current.add(id);
trackEvent('Scroll Depth', { section: id });
}
},
{ threshold: 0.1 }
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
// 30s time-on-page event
useEffect(() => {
const timer = setTimeout(() => trackEvent('Time on Page', { seconds: '30' }), 30000);
return () => clearTimeout(timer);
}, []);
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}>
@ -54,13 +84,17 @@ export default function HomePage({
</p>
<div className="flex items-center gap-4 mb-10">
<button
onClick={onOpenDashboard}
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
>
Explore the map
</button>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById('comparison');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
@ -106,6 +140,7 @@ export default function HomePage({
<div className="flex-1" />
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_it_in_action' });
const target = document.getElementById('demo');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
@ -256,7 +291,10 @@ export default function HomePage({
This deserves proper tools behind it &mdash; don&apos;t leave it to luck.
</p>
<button
onClick={onOpenDashboard}
onClick={() => {
trackEvent('CTA Click', { location: 'bottom', label: 'explore_map' });
onOpenDashboard();
}}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Explore the map

View file

@ -255,7 +255,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
}
export default function LearnPage() {
const [tab, setTab] = useState<LearnTab>('data-sources');
const [tab, setTab] = useState<LearnTab>('faq');
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@ -299,12 +299,12 @@ export default function LearnPage() {
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
Data Sources
</button>
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
FAQ
</button>
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
Data Sources
</button>
<button className={tabClass('support')} onClick={() => setTab('support')}>
Support
</button>

View file

@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
return (
<div className="px-3 py-2">
<form onSubmit={handleSubmit} className="flex gap-1.5">
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Describe your ideal property..."
className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
placeholder="Describe your ideal property and area..."
className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="shrink-0 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5"
className="w-full px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center gap-1.5"
>
{loading ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
'AI'
'Set filters with AI'
)}
</button>
</form>

View file

@ -109,6 +109,11 @@ export default function AreaPane({
{propertyCount.toLocaleString()} properties
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
Stats for {isPostcode ? 'current and historical' : 'all'} properties
in this {isPostcode ? 'postcode' : 'area'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
</p>
{!isPostcode && stats && (
<button
onClick={onViewProperties}
@ -128,6 +133,7 @@ export default function AreaPane({
<LoadingSkeleton />
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
<HistogramLegend />
{featureGroups.map((group) => {
const hasData = group.features.some(
@ -370,7 +376,6 @@ export default function AreaPane({
</div>
);
})}
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
</div>
) : null}
</div>

View file

@ -1,10 +1,35 @@
import { useMemo } from 'react';
import { useMemo, useState, useEffect } from 'react';
import type { FeatureFilters } from '../../types';
import {
buildPropertySearchUrls,
H3_RADIUS_MILES,
type HexagonLocation,
} from '../../lib/external-search';
import { apiUrl, logNonAbortError } from '../../lib/api';
function useRightmoveLocationId(postcode: string | undefined): string | undefined {
const [locationId, setLocationId] = useState<string | undefined>();
useEffect(() => {
if (!postcode) {
setLocationId(undefined);
return;
}
setLocationId(undefined);
const controller = new AbortController();
fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), {
signal: controller.signal,
})
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.location_identifier) setLocationId(data.location_identifier);
})
.catch((err) => logNonAbortError('rightmove-location', err));
return () => controller.abort();
}, [postcode]);
return locationId;
}
export default function ExternalSearchLinks({
location,
@ -13,29 +38,46 @@ export default function ExternalSearchLinks({
location: HexagonLocation;
filters: FeatureFilters;
}) {
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
const rightmoveLocationId = useRightmoveLocationId(location.postcode);
const urls = useMemo(
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
);
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = `${radiusMiles}mi radius`;
if (!urls) return null;
const linkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium';
const disabledClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 font-medium cursor-default';
return (
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
Search {label} on
</h3>
<div className="flex gap-2">
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
Rightmove
</a>
{urls.rightmove ? (
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
Rightmove
</a>
) : (
<span className={disabledClass} title="Loading...">
Rightmove
</span>
)}
<a
href={urls.onthemarket}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
className={linkClass}
>
OnTheMarket
</a>
@ -43,7 +85,7 @@ export default function ExternalSearchLinks({
href={urls.zoopla}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
className={linkClass}
>
Zoopla
</a>

View file

@ -9,9 +9,17 @@ import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon,
bicycle: BicycleIcon,
walking: WalkingIcon,
transit: TransitIcon,
};
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
@ -24,6 +32,8 @@ interface FeatureBrowserProps {
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
}
export default function FeatureBrowser({
@ -37,6 +47,8 @@ export default function FeatureBrowser({
onClearOpenInfoFeature,
travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
onUpgradeClick,
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -77,23 +89,21 @@ export default function FeatureBrowser({
name="Travel Time"
expanded={isSearching || expandedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{TRANSPORT_MODES.length}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{MODE_LABELS[mode]}
@ -104,16 +114,6 @@ export default function FeatureBrowser({
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{fieldKey && (
<IconButton
onClick={() => onTogglePin(fieldKey)}
active={isPinned}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
size="md"
>
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
)}
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
@ -131,7 +131,7 @@ export default function FeatureBrowser({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
@ -174,10 +174,31 @@ export default function FeatureBrowser({
}
className="px-3 py-4"
/>
) : (
) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Everyone cares about different things. Pick the filters that matter most to you.
</p>
) : (
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
The biggest financial decision of your life deserves proper tools behind it.
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
Don&apos;t leave it to chance.
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
Upgrade to full map
</button>
<svg viewBox="0 120 1600 230" className="w-full mt-4 block shrink-0" preserveAspectRatio="xMidYMax meet">
<path d="M0,350 C400,150 1200,150 1600,350 Z" className="fill-green-500 dark:fill-green-600" />
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)}
</div>
{infoFeature && (

View file

@ -25,27 +25,20 @@ import {
type ListingType = 'historical' | 'buy' | 'rent';
const MODE_RESTRICTED_FEATURES: Record<string, Set<ListingType>> = {
'Bathrooms': new Set(['buy', 'rent']),
};
function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean {
const allowed = MODE_RESTRICTED_FEATURES[featureName];
return !allowed || allowed.has(mode);
}
function SliderLabels({
min,
max,
value,
displayValues,
absoluteMax,
isAtMin,
isAtMax,
}: {
min: number;
max: number;
value: [number, number];
displayValues?: [number, number];
absoluteMax?: boolean;
isAtMin?: boolean;
isAtMax?: boolean;
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
@ -57,13 +50,13 @@ function SliderLabels({
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{formatFilterValue(labels[0])}
{isAtMin ? 'min' : formatFilterValue(labels[0])}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''}
{isAtMax ? 'max' : formatFilterValue(labels[1])}
</span>
</div>
);
@ -87,15 +80,18 @@ interface FiltersProps {
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
travelTimeDataRanges: Map<number, [number, number]>;
onTravelTimeAddEntry: (mode: TransportMode) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeToggleBest: (index: number) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterNotes: string | null;
onAiFilterSubmit: (query: string) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
onResetTutorial?: () => void;
}
export default memo(function Filters({
@ -116,16 +112,49 @@ export default memo(function Filters({
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEntries,
travelTimeDataRanges,
onTravelTimeAddEntry,
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
onTravelTimeToggleBest,
aiFilterLoading,
aiFilterError,
aiFilterNotes,
onAiFilterSubmit,
isLicensed,
onUpgradeClick,
onResetTutorial,
}: FiltersProps) {
const modeRestrictions = useMemo(() => {
const map: Record<string, Set<ListingType>> = {};
for (const f of features) {
if (f.modes && f.modes.length > 0) {
map[f.name] = new Set(f.modes as ListingType[]);
}
}
return map;
}, [features]);
const linkedFeatures = useMemo(() => {
const pairs: [string, string][] = [];
const seen = new Set<string>();
for (const f of features) {
if (f.linked && !seen.has(f.name)) {
pairs.push([f.name, f.linked]);
seen.add(f.linked);
}
}
return pairs;
}, [features]);
const isAllowed = useCallback(
(name: string, mode: ListingType) => {
const allowed = modeRestrictions[name];
return !allowed || allowed.has(mode);
},
[modeRestrictions]
);
const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined;
if (!val || val.length === 0) return 'historical';
@ -135,8 +164,8 @@ export default memo(function Filters({
}, [filters]);
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
[features, enabledFeatures, activeListingType]
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
[features, enabledFeatures, activeListingType, isAllowed]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
@ -145,16 +174,26 @@ export default memo(function Filters({
const handleListingSelect = useCallback(
(type: ListingType) => {
if (type === activeListingType && !filters['Listing status']) return;
for (const name of Object.keys(filters)) {
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
if (name === 'Listing status') continue;
if (isAllowed(name, type)) continue;
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
swapped = true;
break;
}
}
if (!swapped) {
onRemoveFilter(name);
}
}
if (type === 'historical' && !filters['Listing status']) {
onFilterChange('Listing status', ['Historical sale']);
return;
}
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
@ -162,7 +201,7 @@ export default memo(function Filters({
};
onFilterChange('Listing status', [valueMap[type]]);
},
[activeListingType, filters, onFilterChange, onRemoveFilter]
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
);
const containerRef = useRef<HTMLDivElement>(null);
@ -205,7 +244,7 @@ export default memo(function Filters({
<div className="flex items-center gap-2 px-3 pb-2">
<button
onClick={() => setShowPhilosophy(true)}
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
className="flex-1 px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />
Finding the Perfect Postcode
@ -254,7 +293,7 @@ export default memo(function Filters({
name="Travel Time"
expanded={!collapsedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{travelTimeEntries.length}
@ -269,11 +308,12 @@ export default memo(function Filters({
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
dataRange={travelTimeDataRanges.get(index) ?? null}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
))}
@ -296,7 +336,7 @@ export default memo(function Filters({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
@ -344,14 +384,25 @@ export default memo(function Filters({
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
: (filters[feature.name] as [number, number]) || [hist?.min ?? feature.min!, hist?.max ?? feature.max!];
const scale = percentileScales.get(feature.name);
const dataMin = hist?.min ?? feature.min!;
const dataMax = hist?.max ?? feature.max!;
const isAtMin = displayValue[0] <= dataMin;
const isAtMax = displayValue[1] >= dataMax;
const sliderValue: [number, number] = scale
? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))]
: displayValue;
? [
isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
isAtMin ? feature.min! : displayValue[0],
isAtMax ? feature.max! : displayValue[1],
];
return (
<div
@ -375,8 +426,18 @@ export default memo(function Filters({
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)])
: ([min, max]) => onDragChange([min, max])
? ([pMin, pMax]) => {
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)),
]);
}
: ([min, max]) => onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
@ -386,7 +447,8 @@ export default memo(function Filters({
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={scale ? displayValue : undefined}
absoluteMax={feature.absolute}
isAtMin={isAtMin}
isAtMax={isAtMax}
/>
</div>
</div>
@ -416,6 +478,8 @@ export default memo(function Filters({
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onTravelTimeAddEntry}
isLicensed={isLicensed}
onUpgradeClick={onUpgradeClick}
/>
</div>
</div>
@ -423,59 +487,95 @@ export default memo(function Filters({
{showPhilosophy && (
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
<div className="space-y-4 text-sm">
<p className="text-warm-600 dark:text-warm-300">
Start with your must-haves, then layer on nice-to-haves.
The map narrows down as you add filters &mdash; the areas that survive are your best matches.
</p>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Be intentional, not reactive
1. Budget &amp; property basics
</h4>
<p className="text-warm-600 dark:text-warm-300">
Your future home isn&apos;t a box of cereal you grab because it&apos;s on sale.
Don&apos;t let a seemingly good deal turn into lifelong regret. Instead of waiting
for listings to appear, define what you actually want and go find it.
Set your price range, minimum floor area, and property type.
If you need a lease over freehold (or vice versa), filter for that too.
This eliminates most of the map immediately.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
See the full picture
2. Commute &amp; transport
</h4>
<p className="text-warm-600 dark:text-warm-300">
Current listings show only a fraction of the market. There are too few to give you a
complete picture, yet too many to evaluate one by one. We aggregate millions of
historical sales so you can understand what&apos;s truly available in any area.
Add a travel time filter to your workplace &mdash; choose public transport or cycling
and set your maximum tolerable commute. You can also filter by
how many stations are within walking distance.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Your priorities, your filters
3. Safety &amp; environment
</h4>
<p className="text-warm-600 dark:text-warm-300">
We all care about different things. Some want peace and quiet; others want to be
near the action. Use our filters to define exactly what matters to you and discover
postcodes that match.
Use the crime filters to cap serious or minor crime rates.
Check road noise levels if you&apos;re a light sleeper, and
environmental risk filters for ground stability concerns.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Find the right place, not just the right listing
4. Schools &amp; education
</h4>
<p className="text-warm-600 dark:text-warm-300">
The best areas to live don&apos;t always have properties listed right now. We help
you identify where you should be looking, so when something does come up,
you&apos;re ready.
Filter by the number of Ofsted-rated Good or Outstanding primary and
secondary schools nearby. The education deprivation score captures
broader area-level attainment.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Know what&apos;s possible
5. Lifestyle &amp; amenities
</h4>
<p className="text-warm-600 dark:text-warm-300">
We&apos;d rather tell you upfront if your expectations are unrealistic than have you
spend months searching for something that doesn&apos;t exist.
Want restaurants, parks, or grocery shops within walking distance?
Filter by nearby amenity counts. Broadband speed filters help if
you work from home.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
6. Energy &amp; running costs
</h4>
<p className="text-warm-600 dark:text-warm-300">
EPC ratings from A to G indicate energy efficiency.
Filter for better ratings to find homes with lower bills and
fewer upgrade headaches.
</p>
</div>
<div className="pt-1 border-t border-warm-200 dark:border-warm-700">
<p className="text-warm-500 dark:text-warm-400 italic">
Tip: if nothing survives your filters, relax one constraint at a time
to see which compromise unlocks the most options.
</p>
</div>
{onResetTutorial && (
<button
onClick={() => {
setShowPhilosophy(false);
onResetTutorial();
}}
className="w-full px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm"
>
Replay interactive tutorial
</button>
)}
</div>
</InfoPopup>
)}

View file

@ -1,5 +1,5 @@
import { memo } from 'react';
import type { FeatureFilters } from '../../types';
import { memo, useMemo } from 'react';
import type { FeatureFilters, FeatureMeta } from '../../types';
import { formatValue } from '../../lib/format';
interface HoverCardData {
@ -14,11 +14,17 @@ interface HoverCardProps {
isPostcode: boolean;
data: HoverCardData | null;
filters: FeatureFilters;
features: FeatureMeta[];
}
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) {
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) {
const activeFilterNames = Object.keys(filters);
const featureMap = useMemo(
() => new Map(features.map((f) => [f.name, f])),
[features]
);
// Get key stats to show from local data (min_<feature> values)
const getDisplayStats = () => {
if (!data) return [];
@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const val = data[`avg_${name}`] ?? data[`min_${name}`];
if (val != null && typeof val === 'number') {
results.push({ name, value: formatValue(val) });
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(name);
if (meta?.type === 'enum' && meta.values) {
const label = meta.values[Math.round(val)];
if (label) results.push({ name, value: label });
} else {
results.push({ name, value: formatValue(val, meta) });
}
}

View file

@ -170,6 +170,7 @@ export default memo(function Map({
data,
postcodeData,
usePostcodeView,
zoom: viewState.zoom,
pois,
viewFeature,
colorRange,
@ -296,6 +297,7 @@ export default memo(function Map({
: data.find((d) => d.h3 === hoveredHexagonId) || null
}
filters={filters}
features={features}
/>
)}
</>

View file

@ -27,6 +27,7 @@ import {
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
@ -188,24 +189,6 @@ export default function MapPage({
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
const ranges = new globalThis.Map<number, [number, number]>();
for (let i = 0; i < travelTime.entries.length; i++) {
const entry = travelTime.entries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of mapData.data) {
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges.set(i, [vals[0], vals[vals.length - 1]]);
}
return ranges;
}, [travelTime.entries, mapData.data]);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
useEffect(() => {
@ -229,16 +212,21 @@ export default function MapPage({
const isPostcode = selection.selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData
// For postcodes, get centroid from postcodeData; postcode string is the selection id
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
if (!postcodeFeature?.properties.centroid) return null;
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution };
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
} else {
// For hexagons, get lat/lon from hexagon data
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
return {
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
postcode: selection.areaStats?.central_postcode,
};
}
}, [
selection.selectedHexagon?.id,
@ -246,6 +234,7 @@ export default function MapPage({
mapData.data,
mapData.postcodeData,
mapData.resolution,
selection.areaStats?.central_postcode,
]);
const tutorial = useTutorial(initialLoading, isMobile);
@ -273,6 +262,7 @@ export default function MapPage({
link.download = 'perfect-postcode-export.xlsx';
link.click();
URL.revokeObjectURL(link.href);
trackEvent('Export');
})
.catch((err) => logNonAbortError('Export failed', err))
.finally(() => setExporting(false));
@ -282,6 +272,10 @@ export default function MapPage({
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
@ -395,15 +389,18 @@ export default function MapPage({
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
aiFilterNotes={aiFilters.notes}
onAiFilterSubmit={handleAiFilterSubmit}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
/>
);
@ -560,6 +557,7 @@ export default function MapPage({
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
locale={{ last: 'Finish' }}
/>
<div
@ -614,9 +612,10 @@ export default function MapPage({
<button
data-tutorial="poi-button"
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
<span className="text-sm font-medium">Points of interest</span>
</button>
{/* Floating POI panel */}
{poiPaneOpen && (
@ -626,38 +625,40 @@ export default function MapPage({
)}
</div>
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
{selection.selectedHexagon && (
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderAreaPane()}
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderAreaPane()}
</div>
</div>
</div>
</div>
)}
{mapData.licenseRequired && (
<UpgradeModal

View file

@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -31,19 +32,23 @@ export default function POIPane({
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
const wasSelected = newSet.has(category);
if (wasSelected) {
newSet.delete(category);
} else {
newSet.add(category);
}
trackEvent('POI Toggle', { category, selected: String(!wasSelected) });
onCategoriesChange(newSet);
};
const selectAll = () => {
trackEvent('POI Select All');
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
trackEvent('POI Select None');
onCategoriesChange(new Set());
};

View file

@ -5,21 +5,33 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { CarIcon } from '../ui/icons/CarIcon';
import { BicycleIcon } from '../ui/icons/BicycleIcon';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
import { TransitIcon } from '../ui/icons/TransitIcon';
import { formatFilterValue } from '../../lib/format';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
import type { ComponentType } from 'react';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon,
bicycle: BicycleIcon,
walking: WalkingIcon,
transit: TransitIcon,
};
interface TravelTimeCardProps {
mode: TransportMode;
slug: string;
label: string;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
useBest: boolean;
isPinned: boolean;
onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void;
onToggleBest: () => void;
onRemove: () => void;
}
@ -28,11 +40,12 @@ export function TravelTimeCard({
slug,
label,
timeRange,
dataRange,
useBest,
isPinned,
onTogglePin,
onSetDestination,
onTimeRangeChange,
onToggleBest,
onRemove,
}: TravelTimeCardProps) {
const search = useLocationSearch(mode);
@ -59,16 +72,18 @@ export function TravelTimeCard({
[onSetDestination, search.clear],
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
const sliderMin = 0;
const sliderMax = 120;
const displayRange = timeRange ?? [sliderMin, sliderMax];
const ModeIcon = MODE_ICONS[mode];
return (
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time ({MODE_LABELS[mode]})
</span>
@ -106,8 +121,26 @@ export function TravelTimeCard({
)}
</div>
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={useBest}
onChange={onToggleBest}
className="accent-teal-600 rounded"
/>
<span className="text-xs text-warm-600 dark:text-warm-300">
Best case
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
(optimal departure)
</span>
</label>
)}
{/* Time range slider — only show when we have data */}
{slug && dataRange && (
{slug && (
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time

View file

@ -3,6 +3,7 @@ import { CheckIcon } from '../ui/icons/CheckIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { AuthUser } from '../../hooks/useAuth';
import { useLicense } from '../../hooks/useLicense';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const FEATURES = [
@ -59,14 +60,8 @@ export default function PricingPage({
}, []);
useEffect(() => {
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
trackEvent('Pricing View');
}, []);
useEffect(() => {
fetch(apiUrl('pricing'))
@ -98,6 +93,16 @@ export default function PricingPage({
}
}
useEffect(() => {
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
const ctaButton = isLicensed ? (
<button
onClick={onOpenDashboard}
@ -183,13 +188,23 @@ export default function PricingPage({
/>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-12">
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
Early access pricing
</h1>
<p className="text-lg text-warm-300 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime
access to every feature. The earlier you join, the less you pay.
Pay once, access forever. The earlier you join, the less you pay.
</p>
</div>
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
<p className="text-warm-400 text-sm leading-relaxed mb-2">
Buying a home costs &pound;10k+ in stamp duty, &pound;1,500 in solicitor fees,
&pound;500 for a survey. Get the wrong area and you&apos;re stuck with a long
commute, bad schools, or a road you didn&apos;t know about.
</p>
<p className="text-warm-200 font-semibold">
Less than your survey costs. Vastly more useful.
</p>
</div>
@ -203,7 +218,7 @@ export default function PricingPage({
<div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}>
{scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />}
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} />
<div ref={scrollRef} onScroll={onScroll} className="flex gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
<div ref={scrollRef} onScroll={onScroll} className="flex justify-center gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
{pricing.tiers.map((tier, i) => {
const isCurrent = i === currentTierIndex;
const isFilled =
@ -348,17 +363,6 @@ export default function PricingPage({
)}
</div>
<div className="relative z-10 max-w-2xl mx-auto px-6 pb-16 text-center">
<p className="text-warm-400 leading-relaxed mb-3">
Stamp duty on a &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; with a long commute, bad schools, or a street
that looked fine on the listing photos but turns out to be on a motorway.
</p>
<p className="text-warm-200 font-semibold">
One payment. Lifetime access. Less than your survey costs and vastly more useful.
</p>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
type View = 'login' | 'register' | 'forgot';
@ -30,6 +31,10 @@ export default function AuthModal({
const [password, setPassword] = useState('');
const [resetSent, setResetSent] = useState(false);
useEffect(() => {
trackEvent('Auth Modal Open', { tab: initialTab });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const switchView = useCallback(
(newView: View) => {
setView(newView);

View file

@ -1,5 +1,6 @@
import type { FeatureMeta } from '../../types';
import { InfoIcon } from './icons';
import { getGroupIcon } from '../../lib/group-icons';
interface FeatureLabelProps {
feature: FeatureMeta;
@ -15,11 +16,15 @@ export function FeatureLabel({
size = 'xs',
}: FeatureLabelProps) {
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
>
{GroupIcon && (
<GroupIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0" />
)}
<span
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
>

View file

@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { shortenUrl } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { LogoIcon } from './icons/LogoIcon';
@ -63,42 +64,29 @@ export default function Header({
if (!isMobile) setMenuOpen(false);
}, [isMobile]);
const copyToClipboard = useCallback((text: string) => {
const onSuccess = () => {
const doCopy = useCallback((text: string) => {
copyToClipboard(text, () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
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();
}
});
}, []);
const handleShare = useCallback(async () => {
const params = window.location.search.replace(/^\?/, '');
if (!params) {
copyToClipboard(window.location.href);
doCopy(window.location.href);
return;
}
setSharing(true);
try {
const shortUrl = await shortenUrl(params);
copyToClipboard(shortUrl);
doCopy(shortUrl);
} catch {
copyToClipboard(window.location.href);
doCopy(window.location.href);
} finally {
setSharing(false);
}
}, [copyToClipboard]);
}, [doCopy]);
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${

View file

@ -1,19 +1,23 @@
import { useState, useCallback, useEffect } from 'react';
import { CheckIcon } from './icons/CheckIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
export default function SaveSearchModal({
onClose,
onSave,
onViewSearches,
saving,
error,
}: {
onClose: () => void;
onSave: (name: string) => Promise<void>;
onViewSearches: () => void;
saving: boolean;
error: string | null;
}) {
const [name, setName] = useState('');
const [saved, setSaved] = useState(false);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@ -21,12 +25,12 @@ export default function SaveSearchModal({
if (!name.trim() || saving) return;
try {
await onSave(name.trim());
onClose();
setSaved(true);
} catch {
// Error displayed in modal
}
},
[name, saving, onSave, onClose]
[name, saving, onSave]
);
useEffect(() => {
@ -45,7 +49,9 @@ export default function SaveSearchModal({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Save Search</h2>
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
{saved ? 'Search saved' : 'Save Search'}
</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
@ -54,41 +60,68 @@ export default function SaveSearchModal({
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>
{saved ? (
<div className="p-5 pt-2 space-y-4">
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
<CheckIcon className="w-5 h-5" />
<p className="text-sm text-warm-700 dark:text-warm-300">
Your search has been saved successfully.
</p>
</div>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Close
</button>
<button
type="button"
onClick={onViewSearches}
className="px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700"
>
View saved searches
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
)}
</div>
</div>
);

View file

@ -38,11 +38,29 @@ export default function UserMenu({
{open && (
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
{user.email}
</p>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
{user.email}
</p>
<span
className={`shrink-0 text-xs font-medium px-1.5 py-0.5 rounded ${
user.subscription === 'licensed' || user.isAdmin
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-warm-100 text-warm-500 dark:bg-warm-700 dark:text-warm-400'
}`}
>
{user.subscription === 'licensed' || user.isAdmin ? 'Pro' : 'Free'}
</span>
</div>
</div>
<div className="p-1">
<a
href="/account"
onClick={() => setOpen(false)}
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
</a>
<button
onClick={() => {
setOpen(false);

View file

@ -0,0 +1,23 @@
interface IconProps {
className?: string;
}
export function BicycleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="6" cy="17" r="3" />
<circle cx="18" cy="17" r="3" />
<path d="M6 17l3-7h4l3 7" />
<path d="M9 10l3 4h3" />
<circle cx="12" cy="7" r="1.5" />
</svg>
);
}

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function CarIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 17h14v-5l-2-6H7L5 12v5z" />
<circle cx="7.5" cy="17" r="2" />
<circle cx="16.5" cy="17" r="2" />
<path d="M5 12h14" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function ChartBarIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function GraduationCapIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2 3 3 6 3s6-1 6-3v-5" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function HouseIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}

View file

@ -0,0 +1,19 @@
interface IconProps {
className?: string;
}
export function ShieldIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function ShoppingBagIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 01-8 0" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function TagIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
);
}

View file

@ -0,0 +1,25 @@
interface IconProps {
className?: string;
}
export function TransitIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="6" y="3" width="12" height="14" rx="3" />
<path d="M6 12h12" />
<circle cx="9" cy="15" r="1" />
<circle cx="15" cy="15" r="1" />
<path d="M9 20l-2 2" />
<path d="M15 20l2 2" />
<path d="M9 3V1h6v2" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function TreeIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22v-7" />
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />
</svg>
);
}

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function UsersIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
);
}

View file

@ -0,0 +1,23 @@
interface IconProps {
className?: string;
}
export function WalkingIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="4.5" r="2" />
<path d="M13.5 9L15 15l-3 4" />
<path d="M10.5 9L9 15l3 4" />
<path d="M10 9h4l2 4" />
<path d="M8 13l2-4" />
</svg>
);
}

View file

@ -7,3 +7,15 @@ export { FilterIcon } from './FilterIcon';
export { LightbulbIcon } from './LightbulbIcon';
export { MenuIcon } from './MenuIcon';
export { RouteIcon } from './RouteIcon';
export { CarIcon } from './CarIcon';
export { BicycleIcon } from './BicycleIcon';
export { WalkingIcon } from './WalkingIcon';
export { TransitIcon } from './TransitIcon';
export { HouseIcon } from './HouseIcon';
export { GraduationCapIcon } from './GraduationCapIcon';
export { ChartBarIcon } from './ChartBarIcon';
export { ShieldIcon } from './ShieldIcon';
export { UsersIcon } from './UsersIcon';
export { ShoppingBagIcon } from './ShoppingBagIcon';
export { TreeIcon } from './TreeIcon';
export { TagIcon } from './TagIcon';

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { trackEvent } from '../lib/analytics';
export interface AuthUser {
id: string;
@ -52,6 +53,7 @@ export function useAuth() {
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Login failed';
setError(msg);
@ -73,6 +75,7 @@ export function useAuth() {
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Registration failed';
setError(msg);
@ -88,6 +91,7 @@ export function useAuth() {
try {
const result = await pb.collection('users').authWithOAuth2({ provider });
setUser(recordToUser(result.record));
trackEvent('Login', { method: provider });
} catch (err) {
const msg = err instanceof Error ? err.message : 'OAuth login failed';
setError(msg);
@ -98,6 +102,7 @@ export function useAuth() {
}, []);
const logout = useCallback(() => {
trackEvent('Logout');
pb.authStore.clear();
setUser(null);
}, []);

View file

@ -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,
@ -33,6 +34,7 @@ interface UseDeckLayersProps {
data: HexagonData[];
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
zoom: number;
pois: POI[];
viewFeature: string | null;
colorRange: [number, number] | null;
@ -60,6 +62,7 @@ export function useDeckLayers({
data,
postcodeData,
usePostcodeView,
zoom,
pois,
viewFeature,
colorRange,
@ -80,13 +83,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 +335,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 +481,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,17 +509,20 @@ export function useDeckLayers({
marchTime,
extensions: [new MarchingAntsExtension()],
});
}, [selectedPostcodeGeometry, marchTime]);
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
? zoom >= 16
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [postcodeLayer, poiLayer]
: [hexLayer, poiLayer];
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
return baseLayers;
}, [
usePostcodeView,
zoom,
hexLayer,
postcodeLayer,
postcodeLabelsLayer,

View file

@ -1,5 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { trackEvent } from '../lib/analytics';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
@ -29,8 +30,11 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
trackEvent('Filter Add', { feature: name });
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!] }));
}
@ -43,6 +47,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleRemoveFilter = useCallback((name: string) => {
trackEvent('Filter Remove', { feature: name });
setFilters((prev) => {
const next = { ...prev };
delete next[name];
@ -82,6 +87,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleTogglePin = useCallback((name: string) => {
trackEvent('Filter Pin', { feature: name });
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);

View file

@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
FeatureFilters,
@ -107,6 +108,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
setSelectedPostcodeGeometry(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
trackEvent('Hexagon Click', { type });
setSelectedHexagon({ id, type, resolution });
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
setProperties([]);
@ -138,6 +140,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
trackEvent('View Properties');
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
@ -167,6 +170,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {
trackEvent('Postcode Search');
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setProperties([]);

View file

@ -1,11 +1,13 @@
import { useState, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../lib/api';
import { trackEvent } from '../lib/analytics';
export function useLicense() {
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
const startCheckout = useCallback(async (referralCode?: string) => {
trackEvent('Checkout Start', { has_referral: String(!!referralCode) });
setCheckingOut(true);
setError(null);
try {
@ -22,6 +24,7 @@ export function useLicense() {
assertOk(res, 'Checkout');
const data = await res.json();
if (data.url) {
trackEvent('Checkout Redirect');
window.location.href = data.url;
}
} catch (err) {

View file

@ -76,12 +76,13 @@ export function useMapData({
);
// Build the travel param string from entries with destinations
// Format: mode:slug|mode:slug or mode:slug:min:max|mode:slug
// Format: mode:slug|mode:slug:best or mode:slug:min:max|mode:slug:best:min:max
const travelParam = useMemo((): string => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}

View file

@ -7,5 +7,6 @@ plausibleInit({
captureOnLocalhost: true,
logging: true,
fileDownloads: true,
outboundLinks: true,
hashBasedRouting: true,
});

View file

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { apiUrl, authHeaders } from '../lib/api';
import { trackEvent } from '../lib/analytics';
export interface SavedSearch {
id: string;
@ -66,6 +67,7 @@ export function useSavedSearches(userId: string | null) {
formData.append('screenshot', screenshotBlob, 'screenshot.png');
await pb.collection('saved_searches').create(formData);
trackEvent('Search Save');
await fetchSearches();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save search';
@ -82,6 +84,7 @@ export function useSavedSearches(userId: string | null) {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
trackEvent('Search Delete');
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');

View file

@ -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,
};
}

View file

@ -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)',
},
},
},
];

View file

@ -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 {

View file

@ -0,0 +1,19 @@
import { track } from '@plausible-analytics/tracker';
export function trackEvent(name: string, props?: Record<string, string | number | boolean>) {
const stringProps: Record<string, string> | undefined = props
? Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
: undefined;
track(name, { props: stringProps });
}
export function trackRevenue(
name: string,
amountPence: number,
props?: Record<string, string | number | boolean>
) {
const stringProps = props
? Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
: undefined;
track(name, { props: stringProps, revenue: { amount: amountPence / 100, currency: 'GBP' } });
}

View file

@ -81,8 +81,9 @@ 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(',');
.join(';;');
}

View file

@ -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();
}
}

View file

@ -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] },

View file

@ -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<number, number> = {
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 };

View file

@ -0,0 +1,31 @@
import type { ComponentType } from 'react';
import {
HouseIcon,
RouteIcon,
GraduationCapIcon,
ChartBarIcon,
ShieldIcon,
UsersIcon,
ShoppingBagIcon,
TreeIcon,
TagIcon,
} from '../components/ui/icons';
const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'Properties in the area': HouseIcon,
Transport: RouteIcon,
Education: GraduationCapIcon,
Deprivation: ChartBarIcon,
'Crime summary': ShieldIcon,
Crime: ShieldIcon,
Demographics: UsersIcon,
Amenities: ShoppingBagIcon,
Environment: TreeIcon,
Property: TagIcon,
};
export function getGroupIcon(
group: string,
): ComponentType<{ className?: string }> | null {
return GROUP_ICONS[group] ?? null;
}

View file

@ -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]}`;
}

View file

@ -18,6 +18,9 @@ export interface FeatureMeta {
suffix?: string;
raw?: boolean;
absolute?: boolean;
// Mode restriction fields
modes?: string[];
linked?: string;
}
export interface FeatureGroup {
@ -172,4 +175,5 @@ export interface HexagonStatsResponse {
numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[];
price_history?: PricePoint[];
central_postcode?: string;
}

View file

@ -16,16 +16,16 @@ from .pois import UK_BBOX_EAST, UK_BBOX_NORTH, UK_BBOX_SOUTH, UK_BBOX_WEST
PLACE_TYPES = {
"city",
"borough",
"town",
"suburb",
"quarter",
"neighbourhood",
"village",
"hamlet",
"locality",
"island",
"isolated_dwelling",
# "borough",
# "town",
# "suburb",
# "quarter",
# "neighbourhood",
# "village",
# "hamlet",
# "locality",
# "island",
# "isolated_dwelling",
}
# Suffixes to strip from raw station names before appending the typed suffix.
@ -115,11 +115,15 @@ class PlaceHandler(osmium.SimpleHandler):
self._add(name, place_type, lat, lon, population)
return
# railway=station nodes (tube, national rail, DLR, tram, etc.)
# Tube stations only (London Underground)
if n.tags.get("railway") == "station":
display_name = _station_display_name(name, dict(n.tags))
self._add(display_name, "station", lat, lon, population)
return
tags = dict(n.tags)
station_tag = tags.get("station", "")
network = tags.get("network", "").lower()
if station_tag == "subway" or "underground" in network:
display_name = _station_display_name(name, tags)
self._add(display_name, "station", lat, lon, population)
return
def main() -> None:
@ -133,7 +137,7 @@ def main() -> None:
args = parser.parse_args()
pbf_file = args.pbf
print(f"Extracting place nodes: {sorted(PLACE_TYPES)} + railway=station")
print("Extracting place nodes: cities + tube stations")
with tqdm(
unit=" elements",
unit_scale=True,

View file

@ -3,23 +3,27 @@
Downloads:
- England OSM PBF from Geofabrik (~1.5GB)
- BODS GTFS from Bus Open Data Service (~1.5GB, all England bus/tram/ferry)
- TfL TransXChange timetables converted to GTFS
- National Rail CIF timetable converted to GTFS (requires credentials)
Then processes for R5 compatibility:
- Cleans GTFS (fixes stop_times >72h, feed_info year >2100)
- Crops OSM PBF to London bounding box via osmium
- Crops GTFS to London bounding box (keeps only London-touching trips)
- Cleans BODS GTFS (fixes stop_times >72h, feed_info year >2100)
- Converts TfL TransXChange to GTFS via transxchange2gtfs
- Converts National Rail CIF to GTFS via dtd2mysql (requires MariaDB Docker)
Requires: osmium-tool (apt install osmium-tool)
Requires: osmium-tool, Node.js (npx), Docker (for national rail)
Output directory: property-data/transit/
Final files: london.osm.pbf + bods_gtfs.zip (London-only, R5-ready)
raw/england.osm.pbf + bods_gtfs.zip + tfl_gtfs.zip + national_rail_gtfs.zip
"""
import argparse
import csv
import io
import json
import os
import subprocess
import tempfile
import time
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
@ -33,18 +37,30 @@ ENGLAND_PBF_URL = (
# Bus Open Data Service — pre-converted GTFS covering all England bus/tram/ferry
BODS_GTFS_URL = "https://data.bus-data.dft.gov.uk/timetable/download/gtfs-file/all/"
# TfL TransXChange timetables (tube, DLR, tram, buses, river bus, cable car)
TFL_TRANSXCHANGE_URL = (
"https://tfl.gov.uk/cdn/static/cms/documents/journey-planner-timetables.zip"
)
# NaPTAN stops data — needed by transxchange2gtfs (its built-in URL is broken)
NAPTAN_URL = "https://naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv"
# National Rail Open Data API
NR_AUTH_URL = "https://opendata.nationalrail.co.uk/authenticate"
NR_TIMETABLE_URL = "https://opendata.nationalrail.co.uk/api/staticfeeds/3.0/timetable"
USER_AGENT = "property-map-pipeline/1.0 (https://github.com)"
# London + Home Counties bounding box (~50km buffer around Greater London)
LONDON_BBOX = {"min_lat": 51.2, "max_lat": 51.85, "min_lon": -0.65, "max_lon": 0.35}
def _download_http(url: str, dest: Path, *, desc: str) -> None:
def _download_http(url: str, dest: Path, *, desc: str, headers: dict | None = None) -> None:
"""Stream-download a URL to a file with progress bar."""
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".tmp")
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
req_headers = {"User-Agent": USER_AGENT}
if headers:
req_headers.update(headers)
req = urllib.request.Request(url, headers=req_headers)
with (
tqdm(unit="B", unit_scale=True, desc=desc) as bar,
@ -112,8 +128,6 @@ def clean_gtfs(src: Path, dst: Path) -> None:
cols.index("departure_time") if "departure_time" in cols else -1
)
import tempfile
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
@ -170,143 +184,449 @@ def clean_gtfs(src: Path, dst: Path) -> None:
print(f" Saved to {dst}")
def crop_osm_to_london(src: Path, dst: Path) -> None:
"""Extract London bounding box from England OSM PBF using osmium."""
if dst.exists():
print(f"London OSM PBF already exists: {dst}")
def download_tfl_transxchange(raw_dir: Path) -> Path:
"""Download TfL TransXChange timetable bundle."""
dest = raw_dir / "tfl_transxchange.zip"
if dest.exists():
print(f"TfL TransXChange already exists: {dest}")
return dest
print("Downloading TfL TransXChange timetables...")
_download_http(TFL_TRANSXCHANGE_URL, dest, desc="tfl_transxchange.zip")
return dest
def download_naptan() -> None:
"""Download NaPTAN stops to /tmp/Stops.csv (needed by transxchange2gtfs)."""
dest = Path("/tmp/Stops.csv")
if dest.exists():
print(f"NaPTAN Stops.csv already exists: {dest}")
return
bbox = LONDON_BBOX
bbox_str = f"{bbox['min_lon']},{bbox['min_lat']},{bbox['max_lon']},{bbox['max_lat']}"
print("Downloading NaPTAN stops data...")
_download_http(NAPTAN_URL, dest, desc="Stops.csv")
print(f"Cropping OSM PBF to London bbox ({bbox_str})...")
def convert_tfl_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
"""Convert TfL TransXChange to GTFS using transxchange2gtfs."""
dest = output_dir / "tfl_gtfs.zip"
if dest.exists():
print(f"TfL GTFS already exists: {dest}")
return dest
txc_path = raw_dir / "tfl_transxchange.zip"
# Ensure NaPTAN is available (transxchange2gtfs has a broken download URL)
download_naptan()
print("Converting TfL TransXChange → GTFS...")
subprocess.run(
["osmium", "extract", f"--bbox={bbox_str}", str(src), "-o", str(dst), "--overwrite"],
["npx", "--yes", "transxchange2gtfs", str(txc_path), str(dest)],
check=True,
)
size_mb = dst.stat().st_size / (1024 * 1024)
print(f" Saved to {dst} ({size_mb:.0f} MB)")
size_mb = dest.stat().st_size / (1024 * 1024)
print(f" Saved to {dest} ({size_mb:.1f} MB)")
return dest
def crop_gtfs_to_london(src: Path, dst: Path) -> None:
"""Crop GTFS to trips touching the London bounding box."""
def download_national_rail_cif(raw_dir: Path) -> Path | None:
"""Download National Rail CIF timetable (requires credentials)."""
dest = raw_dir / "national_rail_cif.zip"
if dest.exists():
print(f"National Rail CIF already exists: {dest}")
return dest
email = os.environ.get("NATIONAL_RAIL_EMAIL")
password = os.environ.get("NATIONAL_RAIL_PASSWORD")
if not email or not password:
print("Warning: NATIONAL_RAIL_EMAIL/NATIONAL_RAIL_PASSWORD not set, skipping national rail")
return None
print("Authenticating with National Rail Open Data...")
auth_data = urllib.parse.urlencode({"username": email, "password": password}).encode()
auth_req = urllib.request.Request(
NR_AUTH_URL,
data=auth_data,
headers={"User-Agent": USER_AGENT, "Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(auth_req) as resp:
token_data = json.loads(resp.read())
token = token_data["token"]
print(" Authenticated successfully")
print("Downloading National Rail CIF timetable...")
_download_http(
NR_TIMETABLE_URL,
dest,
desc="national_rail_cif.zip",
headers={"X-Auth-Token": token},
)
return dest
def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
"""Fix R5-incompatible entries in dtd2mysql-generated National Rail GTFS.
Fixes:
- Interior pass-through stops (pickup_type=1, drop_off_type=1) normal stops.
R5 builds TripPatterns from the full stop sequence but may build shorter
TripSchedules when stops are non-boarding, causing ArrayIndexOutOfBoundsException.
- Removes stop_times referencing stops not in stops.txt.
- Removes trips with backwards travel times.
- Converts route_type=714 (rail replacement bus) to 3 (bus) for R5 compatibility.
- Removes non-standard links.txt file.
- Renumbers stop_sequence to 0-based (R5/BODS convention).
- Fixes bogus coordinates (lat < 0) on Irish CIE stations.
"""
if dst.exists():
print(f"London GTFS already exists: {dst}")
print(f"Cleaned National Rail GTFS already exists: {dst}")
return
bbox = LONDON_BBOX
print("Cleaning National Rail GTFS for R5 compatibility...")
print("Cropping GTFS to London area...")
# First pass: collect valid stop IDs and find bad trips
stop_ids: set[str] = set()
bad_trip_ids: set[str] = set()
with zipfile.ZipFile(src, "r") as zin:
# Step 1: Find stops in bbox
print(" Finding stops in bbox...")
# Load valid stop IDs
with zin.open("stops.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
stops_in_bbox = set()
all_stops = list(reader)
for row in all_stops:
lat = float(row["stop_lat"])
lon = float(row["stop_lon"])
if bbox["min_lat"] <= lat <= bbox["max_lat"] and bbox["min_lon"] <= lon <= bbox["max_lon"]:
stops_in_bbox.add(row["stop_id"])
print(f" {len(stops_in_bbox):,} / {len(all_stops):,} stops in bbox")
header = f.readline().decode("utf-8").strip()
stop_id_idx = header.split(",").index("stop_id")
lat_idx = header.split(",").index("stop_lat")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
if parts:
stop_ids.add(parts[stop_id_idx])
# Step 2: Find trips touching these stops
print(" Finding trips touching London stops...")
# Find trips with backwards travel times
with zin.open("stop_times.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
st_fieldnames = reader.fieldnames
trips_in_bbox = set()
for row in reader:
if row["stop_id"] in stops_in_bbox:
trips_in_bbox.add(row["trip_id"])
print(f" {len(trips_in_bbox):,} trips touch London")
st_header = f.readline().decode("utf-8").strip()
st_cols = st_header.split(",")
trip_id_idx = st_cols.index("trip_id")
dep_idx = st_cols.index("departure_time")
# Step 3: Collect all stop_times for those trips
print(" Collecting stop_times for London trips...")
stop_times_kept = []
with zin.open("stop_times.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
for row in reader:
if row["trip_id"] in trips_in_bbox:
stop_times_kept.append(row)
stops_needed = {row["stop_id"] for row in stop_times_kept}
print(f" {len(stop_times_kept):,} stop_times kept")
prev_trip = ""
prev_dep_secs = -1
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
if not parts:
continue
trip_id = parts[trip_id_idx].strip('"')
if trip_id != prev_trip:
prev_trip = trip_id
prev_dep_secs = -1
# Step 4: Read trips and find needed routes/services/shapes
print(" Reading trips...")
with zin.open("trips.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
trips_fieldnames = reader.fieldnames
all_trips = list(reader)
trips_kept = [t for t in all_trips if t["trip_id"] in trips_in_bbox]
routes_needed = {t["route_id"] for t in trips_kept}
services_needed = {t["service_id"] for t in trips_kept}
shapes_needed = {t.get("shape_id", "") for t in trips_kept} - {""}
dep_str = parts[dep_idx].strip('"')
if ":" in dep_str:
try:
h, m, s = dep_str.split(":")
dep_secs = int(h) * 3600 + int(m) * 60 + int(s)
if dep_secs < prev_dep_secs:
bad_trip_ids.add(trip_id)
prev_dep_secs = dep_secs
except ValueError:
pass
# Step 5: Write cropped GTFS
print(" Writing cropped GTFS...")
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
# stops
stops_kept = [s for s in all_stops if s["stop_id"] in stops_needed]
_write_csv(zout, "stops.txt", list(all_stops[0].keys()), stops_kept)
print(f" Found {len(bad_trip_ids)} trips with backwards travel times")
# stop_times
_write_csv(zout, "stop_times.txt", st_fieldnames, stop_times_kept)
# Second pass: write cleaned zip
passthrough_fixed = 0
orphan_stops_removed = 0
bad_trips_removed = 0
seqs_renumbered = 0
coords_fixed = 0
route_types_fixed = 0
# trips
_write_csv(zout, "trips.txt", trips_fieldnames, trips_kept)
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
dst, "w", zipfile.ZIP_DEFLATED
) as zout:
for info in zin.infolist():
# Skip non-standard links.txt
if info.filename == "links.txt":
continue
# routes
with zin.open("routes.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
routes_fn = reader.fieldnames
routes_kept = [r for r in reader if r["route_id"] in routes_needed]
_write_csv(zout, "routes.txt", routes_fn, routes_kept)
if info.filename == "stop_times.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
trip_id_idx = cols.index("trip_id")
stop_id_idx = cols.index("stop_id")
seq_idx = cols.index("stop_sequence")
pickup_idx = cols.index("pickup_type") if "pickup_type" in cols else -1
dropoff_idx = cols.index("drop_off_type") if "drop_off_type" in cols else -1
# agency (copy all)
zout.writestr("agency.txt", zin.read("agency.txt"))
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
# calendar
with zin.open("calendar.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
cal_fn = reader.fieldnames
cal_kept = [r for r in reader if r["service_id"] in services_needed]
_write_csv(zout, "calendar.txt", cal_fn, cal_kept)
prev_trip = ""
seq_counter = 0
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
trip_id = parts[trip_id_idx].strip('"')
stop_id = parts[stop_id_idx].strip('"')
# calendar_dates
with zin.open("calendar_dates.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
cd_fn = reader.fieldnames
cd_kept = [r for r in reader if r["service_id"] in services_needed]
_write_csv(zout, "calendar_dates.txt", cd_fn, cd_kept)
# Skip trips with backwards times
if trip_id in bad_trip_ids:
bad_trips_removed += 1
continue
# shapes (stream — can be very large)
print(" Streaming shapes.txt...")
with zin.open("shapes.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
shapes_fn = reader.fieldnames
shapes_rows = [r for r in reader if r["shape_id"] in shapes_needed]
_write_csv(zout, "shapes.txt", shapes_fn, shapes_rows)
# Skip stop_times referencing missing stops
if stop_id not in stop_ids:
orphan_stops_removed += 1
continue
# feed_info + frequencies (copy)
zout.writestr("feed_info.txt", zin.read("feed_info.txt"))
zout.writestr("frequencies.txt", zin.read("frequencies.txt"))
# Fix pass-through stops: set pickup/dropoff to 0 (normal)
if pickup_idx >= 0 and dropoff_idx >= 0:
pickup = parts[pickup_idx].strip('"')
dropoff = parts[dropoff_idx].strip('"')
if pickup == "1" and dropoff == "1":
parts[pickup_idx] = "0"
parts[dropoff_idx] = "0"
passthrough_fixed += 1
size_mb = dst.stat().st_size / (1024 * 1024)
print(f" Saved to {dst} ({size_mb:.0f} MB)")
# Renumber stop_sequence to 0-based
if trip_id != prev_trip:
prev_trip = trip_id
seq_counter = 0
else:
seq_counter += 1
old_seq = parts[seq_idx].strip('"')
parts[seq_idx] = str(seq_counter)
if old_seq != str(seq_counter):
seqs_renumbered += 1
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.close()
zout.write(tmp.name, "stop_times.txt")
os.unlink(tmp.name)
elif info.filename == "stops.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
lat_idx = cols.index("stop_lat")
lon_idx = cols.index("stop_lon")
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
try:
lat = float(parts[lat_idx])
# Fix bogus Irish CIE coordinates (South Atlantic)
if lat < 0:
# Set to a neutral UK coordinate that won't be routed to
parts[lat_idx] = "54.0"
parts[lon_idx] = "-2.0"
coords_fixed += 1
except ValueError:
pass
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.close()
zout.write(tmp.name, "stops.txt")
os.unlink(tmp.name)
elif info.filename == "routes.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
rt_idx = cols.index("route_type")
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
if parts[rt_idx].strip('"') == "714":
parts[rt_idx] = "3"
route_types_fixed += 1
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.close()
zout.write(tmp.name, "routes.txt")
os.unlink(tmp.name)
elif info.filename == "trips.txt":
# Remove trips that have backwards travel times
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
trip_id_idx = cols.index("trip_id")
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
if parts[trip_id_idx].strip('"') not in bad_trip_ids:
tmp.write(line)
tmp.close()
zout.write(tmp.name, "trips.txt")
os.unlink(tmp.name)
elif info.filename == "calendar.txt":
# Cap end_date year to 2099
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
end_idx = cols.index("end_date")
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
date_val = parts[end_idx].strip('"')
if len(date_val) == 8:
try:
year = int(date_val[:4])
if year > 2099:
parts[end_idx] = "20991231"
except ValueError:
pass
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.close()
zout.write(tmp.name, "calendar.txt")
os.unlink(tmp.name)
else:
zout.writestr(info, zin.read(info))
print(f" Pass-through stops fixed: {passthrough_fixed}")
print(f" Orphan stop references removed: {orphan_stops_removed}")
print(f" Bad trip stop_times removed: {bad_trips_removed}")
print(f" Stop sequences renumbered: {seqs_renumbered}")
print(f" Bogus coordinates fixed: {coords_fixed}")
print(f" Route types 714→3 fixed: {route_types_fixed}")
print(f" Saved to {dst}")
def _write_csv(
zout: zipfile.ZipFile, name: str, fieldnames: list[str], rows: list[dict]
def _docker_run_dtd2mysql(
network: str, db_container: str, volumes: list[str], args: list[str]
) -> None:
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=fieldnames)
w.writeheader()
w.writerows(rows)
zout.writestr(name, buf.getvalue())
print(f" {name}: {len(rows):,} rows")
"""Run dtd2mysql in a Node.js container on the same Docker network as MariaDB."""
cmd = [
"docker", "run", "--rm", "--network", network,
"-e", f"DATABASE_HOSTNAME={db_container}",
"-e", "DATABASE_USERNAME=root",
"-e", "DATABASE_PASSWORD=root",
"-e", "DATABASE_NAME=dtd",
]
for v in volumes:
cmd.extend(["-v", v])
# Install zip (needed for --gtfs-zip) then run dtd2mysql
inner = "apt-get update -qq && apt-get install -y -qq zip > /dev/null 2>&1 && npx --yes dtd2mysql " + " ".join(args)
cmd.extend(["node:20", "bash", "-c", inner])
subprocess.run(cmd, check=True)
def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
"""Convert National Rail CIF to GTFS using dtd2mysql + MariaDB Docker.
Runs both MariaDB and dtd2mysql as Docker containers on a shared network,
since Docker port forwarding is not available in all environments.
Then cleans the output for R5 compatibility.
"""
dest = output_dir / "national_rail_gtfs.zip"
if dest.exists():
print(f"National Rail GTFS already exists: {dest}")
return dest
raw_dest = raw_dir / "national_rail_gtfs_raw.zip"
if not raw_dest.exists():
db_container = "propertymap-mariadb-temp"
network = "propertymap-dtd-net"
print("Creating Docker network and starting MariaDB...")
subprocess.run(["docker", "network", "create", network], capture_output=True)
subprocess.run(
[
"docker", "run", "-d",
"--name", db_container,
"--network", network,
"-e", "MARIADB_ROOT_PASSWORD=root",
"-e", "MARIADB_DATABASE=dtd",
"mariadb:latest",
],
check=True,
)
try:
# Wait for MariaDB to be ready
print(" Waiting for MariaDB to be ready...")
for attempt in range(30):
result = subprocess.run(
["docker", "exec", db_container, "mariadb", "-uroot", "-proot", "-e", "SELECT 1"],
capture_output=True,
)
if result.returncode == 0:
break
time.sleep(2)
else:
raise RuntimeError("MariaDB did not become ready in time")
raw_abs = str(raw_dir.resolve())
print("Importing CIF timetable into MariaDB...")
_docker_run_dtd2mysql(
network, db_container,
volumes=[f"{raw_abs}:/data:ro"],
args=["--timetable", "/data/national_rail_cif.zip"],
)
print("Exporting GTFS from MariaDB...")
_docker_run_dtd2mysql(
network, db_container,
volumes=[f"{raw_abs}:/output"],
args=["--gtfs-zip", "/output/national_rail_gtfs_raw.zip"],
)
finally:
print("Cleaning up Docker resources...")
subprocess.run(["docker", "stop", db_container], capture_output=True)
subprocess.run(["docker", "rm", db_container], capture_output=True)
subprocess.run(["docker", "network", "rm", network], capture_output=True)
# Clean the raw GTFS for R5 compatibility
clean_national_rail_gtfs(raw_dest, dest)
return dest
def main() -> None:
@ -319,26 +639,43 @@ def main() -> None:
required=True,
help="Output directory for transit data",
)
parser.add_argument(
"--skip-tfl",
action="store_true",
help="Skip TfL TransXChange download and conversion",
)
parser.add_argument(
"--skip-national-rail",
action="store_true",
help="Skip National Rail CIF download and conversion",
)
args = parser.parse_args()
output_dir: Path = args.output
raw_dir = output_dir / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
# Download raw data
england_pbf = download_osm_pbf(raw_dir)
# 1. Download and clean BODS GTFS
download_osm_pbf(raw_dir)
bods_raw = download_bods_gtfs(raw_dir)
# Clean GTFS (fix R5 incompatibilities)
bods_clean = raw_dir / "bods_gtfs_clean.zip"
bods_clean = output_dir / "bods_gtfs.zip"
clean_gtfs(bods_raw, bods_clean)
# Crop to London area for R5 (full England requires >30GB RAM)
london_pbf = output_dir / "london.osm.pbf"
crop_osm_to_london(england_pbf, london_pbf)
# 2. TfL TransXChange → GTFS
if args.skip_tfl:
print("Skipping TfL (--skip-tfl)")
else:
download_tfl_transxchange(raw_dir)
convert_tfl_to_gtfs(raw_dir, output_dir)
london_gtfs = output_dir / "bods_gtfs.zip"
crop_gtfs_to_london(bods_clean, london_gtfs)
# 3. National Rail CIF → GTFS
if args.skip_national_rail:
print("Skipping National Rail (--skip-national-rail)")
else:
cif = download_national_rail_cif(raw_dir)
if cif is not None:
convert_national_rail_to_gtfs(raw_dir, output_dir)
# Summary
print()
@ -349,6 +686,11 @@ def main() -> None:
size_mb = f.stat().st_size / (1024 * 1024)
print(f" {f.name}: {size_mb:.1f} MB")
print()
print("IMPORTANT: If you previously built a network from London-only data,")
print("delete the stale cache before running R5:")
print(" rm -f property-data/r5-network/network.dat")
if __name__ == "__main__":
main()

View file

@ -1,25 +0,0 @@
"""Journey times calculation module for TfL transit data."""
from .config import (
DESTINATIONS,
MAX_CONCURRENT,
MAX_DELAY,
MAX_POSTCODES,
REQUESTS_PER_MIN,
)
from .models import Destination, JourneyResult
from .results import results_to_dataframe, save_results
from .tfl_client import fetch_journey_times
__all__ = [
"MAX_DELAY",
"REQUESTS_PER_MIN",
"MAX_POSTCODES",
"MAX_CONCURRENT",
"DESTINATIONS",
"Destination",
"JourneyResult",
"fetch_journey_times",
"results_to_dataframe",
"save_results",
]

View file

@ -1,167 +0,0 @@
import argparse
import asyncio
import random
from datetime import date, timedelta
from pathlib import Path
import polars as pl
from tqdm import tqdm
from .config import (
DESTINATIONS,
MAX_CONCURRENT,
MAX_POSTCODES,
MAX_DISTANCE_KM,
)
from .models import JourneyResult
from .results import CheckpointSaver, results_to_dataframe, save_results
from .tfl_client import fetch_journey_times
from pipeline.utils import haversine_km_expr
def main():
parser = argparse.ArgumentParser(description="Fetch TfL journey times")
parser.add_argument(
"--destination",
required=True,
choices=list(DESTINATIONS.keys()),
help="Destination key",
)
parser.add_argument(
"--output-dir",
required=True,
type=Path,
help="Directory for output and checkpoint files",
)
parser.add_argument(
"--postcodes",
required=True,
type=Path,
help="ArcGIS postcode parquet file",
)
args = parser.parse_args()
destination = DESTINATIONS[args.destination]
output_dir = args.output_dir
# Calculate next Monday at 8am
today = date.today()
days_until_monday = (7 - today.weekday()) % 7 or 7
journey_date = today + timedelta(days=days_until_monday)
journey_time = "0845"
print(f"Destination: {destination.name}")
print(
f"Journey: {journey_date.strftime('%A %Y-%m-%d')} "
f"at {journey_time[:2]}:{journey_time[2:]}"
)
postcodes_df = pl.read_parquet(args.postcodes).select(
pl.col("pcds").alias("postcode"),
"lat",
"long",
)
print(f"Loaded {postcodes_df.height:,} postcodes")
# Filter to postcodes within range of destination
postcodes_df = postcodes_df.with_columns(
haversine_km_expr("lat", "long", destination.lat, destination.lon).alias(
"distance_km"
)
).filter(pl.col("distance_km") <= MAX_DISTANCE_KM)
print(f"Filtered to {postcodes_df.height:,} postcodes within {MAX_DISTANCE_KM}km")
postcode_data = list(
zip(
postcodes_df["postcode"].to_list(),
postcodes_df["lat"].to_list(),
postcodes_df["long"].to_list(),
)
)
if MAX_POSTCODES is not None and len(postcode_data) > MAX_POSTCODES:
postcode_data = random.sample(postcode_data, MAX_POSTCODES)
print(f"Randomly sampled {MAX_POSTCODES} postcodes")
checkpoint_saver = CheckpointSaver(
destination_name=destination.name,
output_dir=output_dir,
on_save=lambda path, count: print(
f"Checkpoint saved: {count:,} results to {path}"
),
)
# Resume from checkpoint if one exists
checkpoint_path = checkpoint_saver._checkpoint_path()
prior_results: list[JourneyResult] = []
if checkpoint_path.exists():
checkpoint_df = pl.read_parquet(checkpoint_path)
# Deduplicate checkpoint rows per postcode, preferring rows with data
checkpoint_df = checkpoint_df.sort(
"public_transport_quick_minutes", nulls_last=True
).unique(subset=["postcode"], keep="first")
completed_postcodes = set(checkpoint_df["postcode"].to_list())
prior_results = [
JourneyResult(
postcode=row["postcode"],
public_transport_easy_minutes=row["public_transport_easy_minutes"],
public_transport_quick_minutes=row["public_transport_quick_minutes"],
cycling_minutes=row["cycling_minutes"],
error=row["error"],
)
for row in checkpoint_df.iter_rows(named=True)
]
checkpoint_saver.results = prior_results
checkpoint_saver._last_save_count = len(prior_results)
postcode_data = [
(pc, lat, lon)
for pc, lat, lon in postcode_data
if pc not in completed_postcodes
]
print(
f"Resumed from checkpoint: {len(prior_results):,} already done, "
f"{len(postcode_data):,} remaining"
)
def on_result(result):
pbar.update(1)
checkpoint_saver.add_result(result)
with tqdm(total=len(postcode_data), desc="Fetching journeys") as pbar:
new_results = asyncio.run(
fetch_journey_times(
postcode_data,
destination,
journey_date.strftime("%Y%m%d"),
journey_time,
MAX_CONCURRENT,
progress_callback=on_result,
)
)
all_results = prior_results + new_results
results_df = results_to_dataframe(all_results)
all_postcodes = {r.postcode for r in all_results}
coords_df = postcodes_df.filter(pl.col("postcode").is_in(all_postcodes)).select(
["postcode", "lat", "long"]
)
results_df = coords_df.join(results_df, on="postcode", how="left")
results_df = results_df.with_columns(
pl.lit(destination.name).alias("destination"),
pl.lit(journey_date.strftime("%Y-%m-%d")).alias("journey_date"),
pl.lit(f"{journey_time[:2]}:{journey_time[2:]}").alias("journey_time"),
)
successful = results_df.filter(pl.col("cycling_minutes").is_not_null()).height
print(f"Completed: {successful}/{len(all_results)} successful")
parquet_path = save_results(results_df, destination.name, output_dir)
checkpoint_saver.cleanup_checkpoint()
print(f"Saved to {parquet_path}")
if __name__ == "__main__":
main()

View file

@ -1,23 +0,0 @@
"""Configuration constants for journey times processing."""
from .models import Destination
MAX_DELAY = 10
REQUESTS_PER_MIN = 500
MAX_POSTCODES = None
MAX_CONCURRENT = 80
MAX_DISTANCE_KM = 110
CHECKPOINT_INTERVAL = 10000
DESTINATIONS = {
"bank": Destination(51.5133, -0.0886, "Bank", "940GZZLUBNK"),
"waterloo": Destination(51.5031, -0.1132, "Waterloo", "940GZZLUWLO"),
"kings-cross": Destination(51.5308, -0.1238, "King's Cross", "940GZZLUKSX"),
"liverpool-street": Destination(
51.5178, -0.0823, "Liverpool Street", "940GZZLULVS"
),
"paddington": Destination(51.5154, -0.1755, "Paddington", "940GZZLUPAC"),
"victoria": Destination(51.4965, -0.1447, "Victoria", "940GZZLUVIC"),
"fitzrovia": Destination(51.5165, -0.1310, "Fitzrovia", "940GZZLUTCR"),
}

View file

@ -1,30 +0,0 @@
"""Data models for journey times processing."""
from dataclasses import dataclass
@dataclass
class Destination:
"""A destination point for journey planning."""
lat: float
lon: float
name: str
naptan_id: str | None = None
def to_tfl_location(self) -> str:
"""Convert to TfL API location string."""
if self.naptan_id:
return self.naptan_id
return f"{self.lat},{self.lon}"
@dataclass
class JourneyResult:
"""Result of a journey time calculation for a postcode."""
postcode: str
public_transport_easy_minutes: int | None = None
cycling_minutes: int | None = None
public_transport_quick_minutes: int | None = None
error: str | None = None

View file

@ -1,35 +0,0 @@
"""Rate limiting for TfL API requests."""
import asyncio
import warnings
from .config import REQUESTS_PER_MIN
class RateLimiter:
"""Rate limiter enforcing max requests per minute."""
def __init__(self):
self.request_times: list[float] = []
self._lock = asyncio.Lock()
async def acquire(self):
"""Wait until we can make a request within rate limits."""
async with self._lock:
now = asyncio.get_event_loop().time()
cutoff = now - 10.0 # 10 seconds
self.request_times = [t for t in self.request_times if t > cutoff]
if (
len(self.request_times) >= REQUESTS_PER_MIN // 6
): # we look at it every 10 seconds instead of minutes
wait_time = self.request_times[0] - cutoff
if wait_time > 0:
warnings.warn(
f"Rate limit reached ({REQUESTS_PER_MIN}/min), "
f"waiting {wait_time:.1f}s",
stacklevel=1,
)
await asyncio.sleep(wait_time)
self.request_times.append(asyncio.get_event_loop().time())

View file

@ -1,82 +0,0 @@
from pathlib import Path
from typing import Callable
import polars as pl
from .config import CHECKPOINT_INTERVAL
from .models import JourneyResult
def results_to_dataframe(results: list[JourneyResult]) -> pl.DataFrame:
return pl.DataFrame(
[
{
"postcode": r.postcode,
"public_transport_easy_minutes": r.public_transport_easy_minutes,
"public_transport_quick_minutes": r.public_transport_quick_minutes,
"cycling_minutes": r.cycling_minutes,
"error": r.error,
}
for r in results
]
)
class CheckpointSaver:
"""Collects results and saves checkpoints at regular intervals."""
def __init__(
self,
destination_name: str,
output_dir: Path,
interval: int = CHECKPOINT_INTERVAL,
on_save: Callable[[Path, int], None] | None = None,
):
self.destination_name = destination_name
self.output_dir = output_dir
self.interval = interval
self.on_save = on_save
self.results: list[JourneyResult] = []
self._last_save_count = 0
def add_result(self, result: JourneyResult) -> None:
"""Add a result and save checkpoint if interval is reached."""
self.results.append(result)
if len(self.results) - self._last_save_count >= self.interval:
self.save_checkpoint()
def save_checkpoint(self) -> Path:
"""Save current results to checkpoint file."""
df = results_to_dataframe(self.results)
path = self._checkpoint_path()
df.write_parquet(path)
self._last_save_count = len(self.results)
if self.on_save:
self.on_save(path, len(self.results))
return path
def _checkpoint_path(self) -> Path:
safe_name = self.destination_name.lower().replace(" ", "-")
return self.output_dir / f"journey_times_{safe_name}_checkpoint.parquet"
def get_results(self) -> list[JourneyResult]:
"""Return all collected results."""
return self.results
def cleanup_checkpoint(self) -> None:
"""Remove the checkpoint file after successful completion."""
path = self._checkpoint_path()
if path.exists():
path.unlink()
def save_results(
results: pl.DataFrame,
destination_name: str,
output_dir: Path,
) -> Path:
safe_name = destination_name.lower().replace(" ", "-")
parquet_path = output_dir / f"journey_times_{safe_name}.parquet"
results.write_parquet(parquet_path)
return parquet_path

View file

@ -1,254 +0,0 @@
import asyncio
import os
from typing import Literal
import warnings
from collections.abc import Callable
from http import HTTPStatus
import httpx
from .config import MAX_DELAY
from .models import Destination, JourneyResult
from .rate_limiter import RateLimiter
BASE_URL = "https://api.tfl.gov.uk"
async def fetch_journey_for_mode(
client: httpx.AsyncClient,
rate_limiter: RateLimiter,
from_location: str,
to_location: str,
journey_date: str,
journey_time: str,
journey_type: Literal["quick"] | Literal["easy"] | Literal["cycle"],
retry_count: int = 5,
) -> int | None:
"""Fetch journey time for a specific mode with rate limiting."""
backoff = 1.0
for attempt in range(retry_count):
try:
await rate_limiter.acquire()
journey_preference = {
"quick": "LeastTime",
"easy": "LeastInterchange",
"cycle": None,
}[journey_type]
cycle_preference = {
"quick": None,
"easy": None,
"cycle": "AllTheWay",
}[journey_type]
# curl -s "https://api.tfl.gov.uk/Journey/Meta/Modes" | jq '.[].modeName'
mode = {
"quick": [
"bus",
"overground",
"national-rail",
"international-rail",
"elizabeth-line",
"tube",
"coach",
"dlr",
"cable-car",
"replacement-bus",
"tram",
"river-bus",
"walking",
"cycle",
],
"easy": [
"bus",
"overground",
"national-rail",
"international-rail",
"elizabeth-line",
"replacement-bus",
"tube",
"coach",
"dlr",
"cable-car",
"tram",
"river-bus",
],
"cycle": ["cycle"],
}[journey_type]
params: dict = {
"date": journey_date,
"time": journey_time,
"nationalSearch": "true",
"timeIs": "Arriving",
"cyclePreference": cycle_preference,
"bikeProficiency": "Fast",
"walkingOptimization": str(journey_type == "quick").lower(),
"mode": ",".join(mode),
}
if journey_preference:
params["journeyPreference"] = journey_preference
url = f"/Journey/JourneyResults/{from_location}/to/{to_location}"
response = await client.get(url, params=params)
if response.status_code == HTTPStatus.OK:
data = response.json()
journeys = data.get("journeys", [])
if journeys:
durations = [
j["duration"] for j in journeys if j.get("duration") is not None
]
if durations:
return min(durations)
return None
elif response.status_code in (
HTTPStatus.TOO_MANY_REQUESTS,
HTTPStatus.INTERNAL_SERVER_ERROR,
HTTPStatus.BAD_GATEWAY,
HTTPStatus.SERVICE_UNAVAILABLE,
HTTPStatus.GATEWAY_TIMEOUT,
):
warnings.warn(
f"HTTP {response.status_code} for {journey_type} from {from_location}, "
f"retrying in {backoff:.1f}s (attempt {attempt + 1}/{retry_count})",
stacklevel=2,
)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, MAX_DELAY)
continue
else:
return None
except Exception as e:
warnings.warn(
f"Network error for {journey_type} from {from_location}: {e}, "
f"retrying in {backoff:.1f}s (attempt {attempt + 1}/{retry_count})",
stacklevel=2,
)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, MAX_DELAY)
continue
warnings.warn(
f"Failed to fetch {journey_type} from {from_location} after {retry_count} attempts",
stacklevel=2,
)
return None
async def fetch_all_modes(
client: httpx.AsyncClient,
rate_limiter: RateLimiter,
postcode: str,
lat: float,
lon: float,
to_location: str,
journey_date: str,
journey_time: str,
semaphore: asyncio.Semaphore,
) -> JourneyResult:
"""Fetch journey times for all transport modes using coordinates."""
async with semaphore:
try:
from_location = f"{lat},{lon}"
easy = await fetch_journey_for_mode(
client,
rate_limiter,
from_location,
to_location,
journey_date,
journey_time,
journey_type="easy",
)
quick = await fetch_journey_for_mode(
client,
rate_limiter,
from_location,
to_location,
journey_date,
journey_time,
journey_type="quick",
)
cycling = await fetch_journey_for_mode(
client,
rate_limiter,
from_location,
to_location,
journey_date,
journey_time,
journey_type="cycle",
)
return JourneyResult(
postcode=postcode,
public_transport_easy_minutes=easy,
public_transport_quick_minutes=quick,
cycling_minutes=cycling,
)
except Exception as e:
print(f"Error: {e}")
return JourneyResult(postcode=postcode, error=str(e))
async def fetch_journey_times(
postcode_data: list[tuple[str, float, float]],
dest: Destination,
journey_date: str,
journey_time: str,
max_concurrent: int = 2,
progress_callback: Callable[[JourneyResult], None] | None = None,
) -> list[JourneyResult]:
"""Fetch journey times for all postcodes with rate limiting.
Args:
postcode_data: List of (postcode, lat, lon) tuples
dest: Destination for journey planning
journey_date: Date in YYYYMMDD format
journey_time: Time in HHMM format
max_concurrent: Maximum concurrent API requests
progress_callback: Optional callback called with each result
Returns:
List of JourneyResult objects in the same order as postcode_data
"""
semaphore = asyncio.Semaphore(max_concurrent)
to_location = dest.to_tfl_location()
rate_limiter = RateLimiter()
# TFL API authentication via app_key query parameter
tfl_token = os.environ.get("TFL_TOKEN")
if not tfl_token:
raise RuntimeError("TFL_TOKEN environment variable not set")
params = {"app_key": tfl_token}
async with httpx.AsyncClient(
base_url=BASE_URL,
params=params,
timeout=httpx.Timeout(30),
) as client:
tasks = [
fetch_all_modes(
client,
rate_limiter,
pc,
lat,
lon,
to_location,
journey_date,
journey_time,
semaphore,
)
for pc, lat, lon in postcode_data
]
results = []
for coro in asyncio.as_completed(tasks):
result = await coro
results.append(result)
if progress_callback:
progress_callback(result)
postcode_to_result = {r.postcode: r for r in results}
return [postcode_to_result[pc] for pc, _, _ in postcode_data]

View file

@ -8,37 +8,10 @@ from pipeline.utils.postcode_mapping import build_postcode_mapping
MIN_FLOOR_AREA_M2 = 10
def _join_journey_times(
wide: pl.LazyFrame,
journey_times_path: Path,
destination_name: str,
) -> pl.LazyFrame:
"""Join journey times for a single destination, renaming columns appropriately."""
journey_times = (
pl.scan_parquet(journey_times_path)
.select(
"postcode",
pl.col("public_transport_quick_minutes").alias(
f"Public transport to {destination_name} (mins)"
),
pl.col("cycling_minutes").alias(f"Cycling to {destination_name} (mins)"),
)
.sort(f"Public transport to {destination_name} (mins)", nulls_last=True)
.group_by("postcode")
.first()
)
return wide.join(journey_times, on="postcode", how="left")
_AREA_COLUMNS = [
"Postcode",
"lat",
"lon",
# Transport
"Public transport to Bank (mins)",
"Cycling to Bank (mins)",
"Public transport to Fitzrovia (mins)",
"Cycling to Fitzrovia (mins)",
# Deprivation
"Income Score (rate)",
"Employment Score (rate)",
@ -97,8 +70,6 @@ def _build(
arcgis_path: Path,
iod_path: Path,
poi_proximity_path: Path,
journey_times_bank_path: Path,
journey_times_fitzrovia_path: Path,
ethnicity_path: Path,
crime_path: Path,
noise_path: Path,
@ -138,9 +109,6 @@ def _build(
)
wide = wide.join(arcgis, on="postcode", how="left")
wide = _join_journey_times(wide, journey_times_bank_path, "Bank")
wide = _join_journey_times(wide, journey_times_fitzrovia_path, "Fitzrovia")
iod = pl.scan_parquet(iod_path)
wide = wide.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left")
@ -382,18 +350,6 @@ def main():
type=Path,
help="POI proximity counts parquet file (optional)",
)
parser.add_argument(
"--journey-times-bank",
type=Path,
default=None,
help="Journey times to Bank parquet file",
)
parser.add_argument(
"--journey-times-fitzrovia",
type=Path,
default=None,
help="Journey times to Fitzrovia parquet file",
)
parser.add_argument(
"--ethnicity",
type=Path,
@ -446,8 +402,6 @@ def main():
arcgis_path=args.arcgis,
iod_path=args.iod,
poi_proximity_path=args.poi_proximity,
journey_times_bank_path=args.journey_times_bank,
journey_times_fitzrovia_path=args.journey_times_fitzrovia,
ethnicity_path=args.ethnicity,
crime_path=args.crime,
noise_path=args.noise,

View file

@ -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
# --- 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
@ -30,6 +32,7 @@ while [[ $# -gt 0 ]]; do
--threads) THREADS="$2"; shift 2 ;;
--heap) HEAP="$2"; shift 2 ;;
--network-dir) NETWORK_DIR="$2"; shift 2 ;;
--output-dir) OUTPUT_BASE="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
@ -75,7 +78,7 @@ fi
if [ ! -f "$DUCKDB_JAR" ]; then
echo "--- Downloading DuckDB JDBC ---"
curl -fL -o "$DUCKDB_JAR" https://repo1.maven.org/maven2/org/duckdb/duckdb_jdbc/1.0.0/duckdb_jdbc-1.0.0.jar
curl -fL -o "$DUCKDB_JAR" https://repo1.maven.org/maven2/org/duckdb/duckdb_jdbc/1.4.4.0/duckdb_jdbc-1.4.4.0.jar
fi
# --- Step 3: Compile Java source ---
@ -101,21 +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"
cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null || true
cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null || true
DATA_DIR="$BUILD_DIR"
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 "$TRANSIT_SRC"/*.zip "$BUILD_DIR/" 2>/dev/null; then
echo "Warning: no .zip files found in $TRANSIT_SRC/"
fi
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 \

View file

@ -9,6 +9,7 @@ import java.nio.file.Paths;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@ -24,7 +25,8 @@ import java.util.concurrent.atomic.AtomicInteger;
* postcodes are written (unreachable = absent from file).
*
* Output per mode: one parquet file per origin in {output-dir}/{mode}/{name}.parquet
* with columns (pcds VARCHAR, travel_minutes SMALLINT).
* with columns (pcds VARCHAR, travel_minutes SMALLINT). Transit mode additionally
* includes a best_minutes SMALLINT column (5th percentile = best-case departure timing).
*/
public class App {
@ -117,15 +119,14 @@ public class App {
c, total, rate, etaH, failed.get());
}, 2, 2, TimeUnit.SECONDS);
// Submit all work, wait for completion via CountDownLatch-like pattern
java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(remaining.size());
CountDownLatch latch = new CountDownLatch(remaining.size());
for (int idx : remaining) {
pool.submit(() -> {
try {
processOrigin(network, postcodes, postcodeLats, postcodeLons,
originLats[idx], originLons[idx],
modeDir, mode, date, originNames[idx], threadConn.get());
modeDir, mode, date, idx, originNames[idx], threadConn.get());
completed.incrementAndGet();
} catch (Exception e) {
failed.incrementAndGet();
@ -138,6 +139,7 @@ public class App {
latch.await();
reporter.shutdown();
reporter.awaitTermination(5, TimeUnit.SECONDS);
double elapsedH = (System.currentTimeMillis() - startMs) / 3_600_000.0;
int n = completed.get();
@ -150,10 +152,10 @@ public class App {
TransportNetwork network,
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
double originLat, double originLon,
Path modeDir, String mode, LocalDate date, String name,
Path modeDir, String mode, LocalDate date, int index, String name,
DuckDBConnection conn) throws Exception {
Path outPath = modeDir.resolve(sanitizeFilename(name) + ".parquet");
Path outPath = modeDir.resolve(originFilename(index, name));
Exception lastError = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@ -168,22 +170,31 @@ public class App {
String[] codes = new String[reachable];
short[] times = new short[reachable];
short[] bestTimes = result.bestTimes() != null ? new short[reachable] : null;
int j = 0;
for (int i = 0; i < result.times().length; i++) {
if (result.times()[i] >= 0) {
codes[j] = postcodes[result.originalIndices()[i]];
times[j] = result.times()[i];
if (bestTimes != null) bestTimes[j] = result.bestTimes()[i];
j++;
}
}
Parquet.writeTravelTimes(conn, outPath, codes, times);
if (bestTimes != null) {
Parquet.writeTransitTravelTimes(conn, outPath, codes, times, bestTimes);
} else {
Parquet.writeTravelTimes(conn, outPath, codes, times);
}
return;
} catch (Exception e) {
lastError = e;
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);
}
}
}
@ -194,7 +205,7 @@ public class App {
private static List<Integer> findRemaining(Path modeDir, String[] names) throws Exception {
List<Integer> remaining = new ArrayList<>();
for (int i = 0; i < names.length; i++) {
Path f = modeDir.resolve(sanitizeFilename(names[i]) + ".parquet");
Path f = modeDir.resolve(originFilename(i, names[i]));
if (!Files.exists(f) || Files.size(f) == 0) {
remaining.add(i);
}
@ -202,11 +213,12 @@ public class App {
return remaining;
}
/** Sanitize a place name into a safe filename (lowercase, spaces to hyphens, strip non-alphanumeric). */
private static String sanitizeFilename(String name) {
return name.toLowerCase()
/** Build a filename from index + place name (index prefix prevents collisions after sanitization). */
private static String originFilename(int index, String name) {
String safe = name.toLowerCase()
.replaceAll("[^a-z0-9 -]", "")
.replaceAll("\\s+", "-");
return String.format("%06d-%s.parquet", index, safe);
}
private static String requiredArg(String[] args, String name) {

View file

@ -23,11 +23,16 @@ public class Parquet {
catch (ClassNotFoundException e) { throw new RuntimeException(e); }
}
/** Escape a file path for safe interpolation into DuckDB SQL (double single quotes). */
private static String escapePath(String path) {
return path.replace("'", "''");
}
/** Load England postcodes, write reference parquet, return codes + flat lat/lon arrays. */
static Postcodes loadEnglandPostcodes(String parquetPath, Path refOut) throws Exception {
try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) {
stmt.execute("CREATE TABLE postcodes AS SELECT pcds, lat, \"long\" FROM read_parquet('"
+ parquetPath + "') WHERE ctry = 'E92000001' AND doterm IS NULL");
+ escapePath(parquetPath) + "') WHERE ctry = 'E92000001' AND doterm IS NULL");
copyToParquet(stmt, "SELECT * FROM postcodes", refOut);
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM postcodes")) {
@ -56,7 +61,7 @@ public class Parquet {
try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) {
stmt.execute("CREATE TABLE places AS SELECT * EXCLUDE (rn) FROM ("
+ "SELECT *, ROW_NUMBER() OVER (PARTITION BY lat, lon) AS rn "
+ "FROM read_parquet('" + parquetPath + "')) WHERE rn = 1");
+ "FROM read_parquet('" + escapePath(parquetPath) + "')) WHERE rn = 1");
copyToParquet(stmt, "SELECT * FROM places", refOut);
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM places")) {
@ -97,7 +102,30 @@ public class Parquet {
}
}
try (Statement stmt = conn.createStatement()) {
stmt.execute("COPY t TO '" + tmp.toAbsolutePath() + "' (FORMAT PARQUET, COMPRESSION ZSTD)");
stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)");
}
Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
/** Write transit travel times with both median and best-case columns. */
static void writeTransitTravelTimes(DuckDBConnection conn, Path outPath,
String[] postcodes, short[] times, short[] bestTimes) throws Exception {
Path tmp = outPath.resolveSibling(outPath.getFileName() + ".tmp");
try (Statement stmt = conn.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS t");
stmt.execute("CREATE TABLE t (pcds VARCHAR, travel_minutes SMALLINT, best_minutes SMALLINT)");
}
try (DuckDBAppender appender = conn.createAppender("main", "t")) {
for (int i = 0; i < postcodes.length; i++) {
appender.beginRow();
appender.append(postcodes[i]);
appender.append(times[i]);
appender.append(bestTimes[i]);
appender.endRow();
}
}
try (Statement stmt = conn.createStatement()) {
stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)");
}
Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
@ -108,7 +136,7 @@ public class Parquet {
}
private static void copyToParquet(Statement stmt, String query, Path outPath) throws Exception {
stmt.execute("COPY (" + query + ") TO '" + outPath.toAbsolutePath()
stmt.execute("COPY (" + query + ") TO '" + escapePath(outPath.toAbsolutePath().toString())
+ "' (FORMAT PARQUET, COMPRESSION ZSTD)");
}
}

View file

@ -25,11 +25,18 @@ public class Router {
private static final int ZOOM = 9; // R5 enforces range 9-12
private static final int MAX_GRID_CELLS = 4_900_000; // under R5's 5M limit
private static final int DEPARTURE_FROM_TIME = 7 * 3600; // 07:00
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) {}
record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {}
/** Max plausible travel radius in km for 120-minute trips. */
/** Max plausible travel radius in km for {@link #MAX_TRIP_DURATION_MINUTES}-minute trips. */
static double maxRadiusKm(String mode) {
return switch (mode) {
case "car" -> 150;
@ -40,7 +47,10 @@ public class Router {
};
}
/** Load or build the transport network with Kryo caching. */
/**
* Load or build the transport network with Kryo caching.
* The returned network is read-only after buildDistanceTables safe for concurrent use.
*/
static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception {
System.err.println("Loading transport network...");
File cacheFile = new File(cacheDir, "network.dat");
@ -78,7 +88,7 @@ public class Router {
// 1. Filter destinations by bounding box
int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius);
if (filtered.length == 0) {
return new FilteredResult(new int[0], new short[0]);
return new FilteredResult(new int[0], new short[0], null);
}
// 2. Extract filtered coordinate arrays
@ -93,9 +103,13 @@ public class Router {
List<DestinationChunk> chunks = buildDestinationChunks(fLats, fLons);
// 4. Compute travel times
short[] times = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date);
boolean isTransit = mode.equals("transit");
short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date);
return new FilteredResult(filtered, times);
// 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);
}
/**
@ -175,13 +189,18 @@ public class Router {
return chunks;
}
/** Compute travel times from one origin to all destinations across all chunks. */
private static short[] computeTravelTimes(
/**
* Compute travel times from one origin to all destinations across all chunks.
* Returns one short[] per requested percentile (transit gets 2: best + median, others get 1: median).
*/
private static short[][] computeTravelTimes(
TransportNetwork network, List<DestinationChunk> chunks,
double originLat, double originLon, String mode, int nDest, LocalDate date) {
short[] times = new short[nDest];
Arrays.fill(times, (short) -1);
boolean isTransit = mode.equals("transit");
int nPercentiles = isTransit ? 2 : 1;
short[][] allTimes = new short[nPercentiles][nDest];
for (short[] arr : allTimes) Arrays.fill(arr, (short) -1);
for (DestinationChunk chunk : chunks) {
RegionalTask task = buildTask(chunk, originLat, originLon, mode, date);
@ -189,16 +208,29 @@ public class Router {
OneOriginResult result = computer.computeTravelTimes();
TravelTimeResult tt = result.travelTimes;
if (tt != null) {
int[][] values = tt.getValues();
for (int i = 0; i < chunk.originalIndices.length && i < values[0].length; i++) {
if (values[0][i] != Integer.MAX_VALUE) {
times[chunk.originalIndices[i]] = (short) values[0][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];
}
}
}
}
return times;
return allTimes;
}
// --- Private helpers ---
@ -241,7 +273,7 @@ public class Router {
task.fromLat = originLat;
task.fromLon = originLon;
task.date = date;
task.percentiles = new int[]{50};
task.percentiles = mode.equals("transit") ? new int[]{5, 50} : new int[]{50};
task.recordTimes = true;
task.destinationPointSets = new PointSet[]{chunk.pointSet};
task.zoom = chunk.extents.zoom;
@ -249,9 +281,9 @@ public class Router {
task.north = chunk.extents.north;
task.width = chunk.extents.width;
task.height = chunk.extents.height;
task.fromTime = 8 * 3600;
task.toTime = 8 * 3600 + 60;
task.maxTripDurationMinutes = 120;
task.fromTime = DEPARTURE_FROM_TIME;
task.toTime = DEPARTURE_TO_TIME;
task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES;
configureMode(task, mode);
return task;
@ -267,13 +299,14 @@ public class Router {
task.accessModes = EnumSet.of(LegMode.WALK);
task.egressModes = EnumSet.of(LegMode.WALK);
task.directModes = EnumSet.of(LegMode.WALK);
task.transitModes = EnumSet.of(TransitModes.TRANSIT);
task.transitModes = EnumSet.allOf(TransitModes.class);
}
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
}
}
private static void setDirectMode(RegionalTask task, LegMode legMode) {
task.maxRides = 0;
task.accessModes = EnumSet.of(legMode);
task.egressModes = EnumSet.of(legMode);
task.directModes = EnumSet.of(legMode);

16
server-rs/Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -95,6 +95,9 @@ async fn validate_token(
.ok()?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
warn!("PocketBase auth-refresh returned {status}: {body}");
return None;
}

View file

@ -17,6 +17,9 @@ pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
/// Timeout for outbound HTTP service calls (seconds).
pub const SERVICE_CALL_TIMEOUT: u64 = 120;
/// Inner London free zone bounds (south, west, north, east) — roughly zones 12.
/// Users without a license can only query data within these bounds.
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.42, -0.34, 51.60, 0.14);
@ -24,5 +27,5 @@ pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.42, -0.34, 51.60, 0.14);
/// Homepage demo center (lat, lng). Unlicensed hexagon requests are allowed
/// when the center of the requested bounds is within DEMO_CENTER_TOLERANCE of this point.
/// Must match DEMO_VIEW_START in ScrollStory.tsx.
pub const DEMO_CENTER: (f64, f64) = (52.2, -1.9);
pub const DEMO_CENTER: (f64, f64) = (51.51, -0.12);
pub const DEMO_CENTER_TOLERANCE: f64 = 1.0;

View file

@ -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"));
}
}

View file

@ -656,7 +656,7 @@ impl PropertyData {
tracing::info!("Extracting numeric feature columns");
let numeric_col_major: Vec<Vec<f32>> = numeric_names
.iter()
.par_iter()
.map(|name| {
let column = df
.column(name)
@ -733,12 +733,11 @@ impl PropertyData {
tracing::info!("Building enum features");
// enum_col_major: Vec<(values_list, encoded_as_f32)>
let mut enum_col_major: Vec<(Vec<String>, Vec<f32>)> = Vec::new();
for &name in &enum_names {
if let Ok(column_data) = df.column(name) {
let string_column = column_data
.str()
.with_context(|| format!("Enum column '{name}' is not a string column"))?;
let enum_col_major: Vec<(Vec<String>, Vec<f32>)> = enum_names
.par_iter()
.filter_map(|&name| {
let column_data = df.column(name).ok()?;
let string_column = column_data.str().ok()?;
let unique_set: std::collections::HashSet<String> = string_column
.into_iter()
.filter_map(|value| {
@ -795,9 +794,9 @@ impl PropertyData {
.collect();
tracing::debug!(column = %name, unique_values = unique.len(), "Enum feature encoded as f32");
enum_col_major.push((unique, encoded));
}
}
Some((unique, encoded))
})
.collect();
// Extract is_approx_build_date: 0.0 = exact, anything else (1.0/NaN) = approximate
let is_approx_build_date_raw: Vec<bool> = if has_approx_col {
@ -920,7 +919,7 @@ impl PropertyData {
let grid_cols = ((max_lon_val - min_lon_val) / grid_cell_size).ceil() as u64 + 1;
let mut perm: Vec<u32> = (0..row_count as u32).collect();
perm.sort_unstable_by_key(|&perm_index| {
perm.par_sort_unstable_by_key(|&perm_index| {
let grid_row = ((lat[perm_index as usize] - min_lat_val) / grid_cell_size) as u64;
let grid_col = ((lon[perm_index as usize] - min_lon_val) / grid_cell_size) as u64;
grid_row * grid_cols + grid_col
@ -1036,18 +1035,20 @@ impl PropertyData {
// Combines numeric and enum features into a single feature_data array.
tracing::info!("Transposing to row-major layout (spatially sorted)");
let mut feature_data = vec![f32::NAN; row_count * num_features];
for (new_row, &old_row) in perm.iter().enumerate() {
let old_index = old_row as usize;
let dst_base = new_row * num_features;
// Numeric features
for (feat_idx, col_vec) in numeric_col_major.iter().enumerate() {
feature_data[dst_base + feat_idx] = col_vec[old_index];
}
// Enum features (stored as f32 indices)
for (enum_idx, (_, encoded)) in enum_col_major.iter().enumerate() {
feature_data[dst_base + num_numeric + enum_idx] = encoded[old_index];
}
}
feature_data
.par_chunks_mut(num_features)
.enumerate()
.for_each(|(new_row, row_slice)| {
let old_index = perm[new_row] as usize;
// Numeric features
for (feat_idx, col_vec) in numeric_col_major.iter().enumerate() {
row_slice[feat_idx] = col_vec[old_index];
}
// Enum features (stored as f32 indices)
for (enum_idx, (_, encoded)) in enum_col_major.iter().enumerate() {
row_slice[num_numeric + enum_idx] = encoded[old_index];
}
});
tracing::info!("Data loading complete");

View file

@ -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<FxHashMap<String, i16>>;
/// 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<i16>,
}
/// Cached postcode → travel time data for a single destination file.
pub type TravelData = Arc<FxHashMap<String, TravelDataRow>>;
/// Simple LRU cache for travel time data, limited to `capacity` entries.
struct LruCache {
@ -59,24 +66,41 @@ impl LruCache {
}
}
/// Strip a numeric prefix like "000000-" from a filename stem.
/// "000000-bank-tube-station" → "bank-tube-station"
fn strip_numeric_prefix(stem: &str) -> &str {
if let Some(pos) = stem.find('-') {
if stem[..pos].chars().all(|ch| ch.is_ascii_digit()) {
return &stem[pos + 1..];
}
}
stem
}
/// Manages on-demand loading and caching of precomputed travel time parquet files.
///
/// Directory structure: `{base_dir}/{mode}/{slug}.parquet`
/// Directory structure: `{base_dir}/{mode}/{NNNNNN-slug}.parquet`
/// Files have a numeric prefix for uniqueness; lookups use the stripped slug.
/// Each parquet file has columns: `pcds` (String), `travel_minutes` (Int16).
pub struct TravelTimeStore {
base_dir: PathBuf,
/// Available transport modes (subdirectory names, e.g., "bicycle")
pub available_modes: Vec<String>,
/// mode → set of destination slugs (filenames without .parquet)
/// mode → set of destination slugs (numeric prefix stripped)
pub destinations: FxHashMap<String, FxHashSet<String>>,
/// (mode, stripped_slug) → full filename stem (with numeric prefix)
slug_to_file: FxHashMap<(String, String), String>,
cache: Mutex<LruCache>,
}
impl TravelTimeStore {
/// Scan the travel-times directory to discover available modes and destinations.
/// Filename stems have a numeric prefix (e.g., "000000-bank-tube-station") which
/// is stripped for slug lookups but preserved for file loading.
pub fn load(base_dir: &Path, cache_capacity: usize) -> anyhow::Result<Self> {
let mut available_modes = Vec::new();
let mut destinations: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
let mut slug_to_file: FxHashMap<(String, String), String> = FxHashMap::default();
for entry in std::fs::read_dir(base_dir)
.with_context(|| format!("Failed to read travel-times dir: {}", base_dir.display()))?
@ -96,7 +120,12 @@ impl TravelTimeStore {
let file_name = file_entry.file_name();
let file_name = file_name.to_string_lossy();
if file_name.ends_with(".parquet") {
let slug = file_name.trim_end_matches(".parquet").to_string();
let file_stem = file_name.trim_end_matches(".parquet");
let slug = strip_numeric_prefix(file_stem).to_string();
slug_to_file.insert(
(mode.clone(), slug.clone()),
file_stem.to_string(),
);
slugs.insert(slug);
}
}
@ -118,6 +147,7 @@ impl TravelTimeStore {
base_dir: base_dir.to_path_buf(),
available_modes,
destinations,
slug_to_file,
cache: Mutex::new(LruCache::new(cache_capacity)),
})
}
@ -135,11 +165,16 @@ impl TravelTimeStore {
}
}
// Load from file (no lock held — harmless if two threads load the same file)
// Resolve slug to actual filename (may have numeric prefix)
let file_stem = self
.slug_to_file
.get(&key)
.map(|val| val.as_str())
.unwrap_or(slug);
let path = self
.base_dir
.join(mode)
.join(format!("{}.parquet", slug));
.join(format!("{}.parquet", file_stem));
if !path.exists() {
bail!("Travel time file not found: {}", path.display());
}
@ -159,12 +194,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,
},
);
}
}
@ -215,18 +261,15 @@ mod tests {
#[test]
fn slugify_basic() {
assert_eq!(slugify("Abbey Hey"), "abbey-hey");
assert_eq!(slugify("Abbots Bickington"), "abbots-bickington");
assert_eq!(slugify("London"), "london");
}
#[test]
fn slugify_special_chars() {
assert_eq!(slugify("A'Bhuaile Ghlas"), "a-bhuaile-ghlas");
}
#[test]
fn slugify_edges() {
assert_eq!(slugify(" Hello "), "hello");
assert_eq!(slugify("Abbey"), "abbey");
fn strip_numeric_prefix_basic() {
assert_eq!(strip_numeric_prefix("000000-bank-tube-station"), "bank-tube-station");
assert_eq!(strip_numeric_prefix("000123-abbey-hey"), "abbey-hey");
assert_eq!(strip_numeric_prefix("bank-tube-station"), "bank-tube-station");
assert_eq!(strip_numeric_prefix("london"), "london");
}
}

View file

@ -28,6 +28,10 @@ pub struct FeatureConfig {
pub raw: bool,
/// If true, the slider uses absolute min/max/step instead of percentile scaling
pub absolute: bool,
/// Listing modes this feature is available in (empty = all modes)
pub modes: &'static [&'static str],
/// Name of the linked feature that swaps when switching modes (empty = no link)
pub linked: &'static str,
}
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
@ -61,7 +65,7 @@ pub struct EnumFeatureGroup {
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup {
name: "Property",
name: "Properties in the area",
features: &[
FeatureConfig {
name: "Last known price",
@ -77,6 +81,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: true,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Estimated current price",
@ -92,6 +98,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: true,
modes: &["historical"],
linked: "Asking price",
},
FeatureConfig {
name: "Price per sqm",
@ -107,6 +115,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Est. price per sqm",
@ -122,6 +132,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Total floor area (sqm)",
@ -137,6 +149,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " sqm",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Interior height (m)",
@ -152,6 +166,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " m",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Number of bedrooms & living rooms",
@ -167,6 +183,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " rooms",
raw: false,
absolute: true,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Estimated monthly rent",
@ -179,6 +197,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/mo",
raw: false,
absolute: false,
modes: &["historical"],
linked: "Asking rent (monthly)",
},
FeatureConfig {
name: "Date of last transaction",
@ -194,6 +214,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: true,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Construction age",
@ -209,6 +231,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: true,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Asking price",
@ -224,6 +248,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: true,
modes: &["buy"],
linked: "Estimated current price",
},
FeatureConfig {
name: "Asking rent (monthly)",
@ -239,6 +265,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/mo",
raw: false,
absolute: true,
modes: &["rent"],
linked: "Estimated monthly rent",
},
FeatureConfig {
name: "Bedrooms",
@ -254,6 +282,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: true,
modes: &["buy", "rent"],
linked: "",
},
FeatureConfig {
name: "Bathrooms",
@ -269,6 +299,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: true,
modes: &["buy", "rent"],
linked: "",
},
FeatureConfig {
name: "Listing date",
@ -284,6 +316,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: true,
absolute: false,
modes: &["buy", "rent"],
linked: "",
},
],
},
@ -304,6 +338,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " mins",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Public transport to Fitzrovia (mins)",
@ -319,6 +355,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " mins",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Cycling to Bank (mins)",
@ -334,6 +372,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " mins",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Cycling to Fitzrovia (mins)",
@ -349,6 +389,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " mins",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Number of public transport stations within 2km",
@ -364,6 +406,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
],
},
@ -384,6 +428,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Good+ primary schools within 5km",
@ -399,6 +445,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Good+ secondary schools within 5km",
@ -414,6 +462,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
],
},
@ -431,6 +481,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Employment Score (rate)",
@ -443,6 +495,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Health Deprivation and Disability Score",
@ -458,6 +512,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Living Environment Score",
@ -473,6 +529,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Indoors Sub-domain Score",
@ -488,6 +546,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Outdoors Sub-domain Score",
@ -503,6 +563,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
],
},
@ -524,6 +586,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Minor crime (avg/yr)",
@ -539,6 +603,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
],
},
@ -559,6 +625,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Violence and sexual offences (avg/yr)",
@ -574,6 +642,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Criminal damage and arson (avg/yr)",
@ -589,6 +659,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Burglary (avg/yr)",
@ -604,6 +676,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Vehicle crime (avg/yr)",
@ -619,6 +693,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Robbery (avg/yr)",
@ -634,6 +710,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Other theft (avg/yr)",
@ -649,6 +727,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Shoplifting (avg/yr)",
@ -664,6 +744,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Drugs (avg/yr)",
@ -679,6 +761,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Possession of weapons (avg/yr)",
@ -694,6 +778,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Public order (avg/yr)",
@ -709,6 +795,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Bicycle theft (avg/yr)",
@ -724,6 +812,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Theft from the person (avg/yr)",
@ -739,6 +829,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Other crime (avg/yr)",
@ -754,6 +846,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
],
},
@ -774,6 +868,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "% Asian",
@ -789,6 +885,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "% Black",
@ -804,6 +902,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "% Mixed",
@ -819,6 +919,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "% Other",
@ -834,6 +936,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
],
},
@ -854,6 +958,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Number of grocery shops and supermarkets within 2km",
@ -869,6 +975,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Number of parks within 2km",
@ -884,6 +992,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
],
},
@ -904,6 +1014,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " dB",
raw: false,
absolute: false,
modes: &[],
linked: "",
},
FeatureConfig {
name: "Max available download speed (Mbps)",
@ -919,6 +1031,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " Mbps",
raw: true,
absolute: false,
modes: &[],
linked: "",
},
],
},

View file

@ -17,6 +17,7 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context};
use consts::SERVICE_CALL_TIMEOUT;
use axum::middleware;
use axum::routing::{any, get, patch, post};
use axum::Router;
@ -286,7 +287,7 @@ async fn main() -> anyhow::Result<()> {
};
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(SERVICE_CALL_TIMEOUT))
.connect_timeout(Duration::from_secs(5))
.build()
.context("Failed to build HTTP client")?;
@ -423,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(
@ -494,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| {
@ -568,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

View file

@ -17,7 +17,7 @@ pub struct ParsedEnumFilter {
pub allowed: FxHashSet<u32>,
}
/// Parse comma-separated filter string into numeric and enum filters.
/// Parse `;;`-separated filter string into numeric and enum filters.
/// Numeric format: `name:min:max`
/// Enum format: `name:val1|val2|val3` (pipe-separated string values)
///
@ -35,7 +35,7 @@ pub fn parse_filters(
None => return Ok((numeric, enums)),
};
for entry in input.split(',') {
for entry in input.split(";;") {
let parts: Vec<&str> = entry.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(format!("Malformed filter entry (missing ':'): '{entry}'"));
@ -234,7 +234,7 @@ mod tests {
#[test]
fn parse_multiple_numeric_filters() {
let (numeric, _enums) = parse_filters(
Some("Price:100000:500000,Area:50:200"),
Some("Price:100000:500000;;Area:50:200"),
&extended_feature_map(),
&extended_enum_values(),
)
@ -248,7 +248,7 @@ mod tests {
#[test]
fn parse_mixed_filters() {
let (numeric, enums) = parse_filters(
Some("Price:100000:500000,Type:Semi|Terraced"),
Some("Price:100000:500000;;Type:Semi|Terraced"),
&extended_feature_map(),
&extended_enum_values(),
)
@ -288,7 +288,7 @@ mod tests {
#[test]
fn parse_filter_with_whitespace() {
let (numeric, enums) = parse_filters(
Some("Price : 100000 : 500000 , Type : Detached | Flats/Maisonettes"),
Some("Price : 100000 : 500000 ;; Type : Detached | Flats/Maisonettes"),
&extended_feature_map(),
&extended_enum_values(),
)

View file

@ -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(())
}

View file

@ -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;

View file

@ -84,7 +84,7 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec<String> {
None => return Vec::new(),
};
let mut names = Vec::new();
for entry in input.split(',') {
for entry in input.split(";;") {
let parts: Vec<&str> = entry.splitn(2, ':').collect();
if parts.len() == 2 {
let name = parts[0].trim().to_string();
@ -110,7 +110,7 @@ fn build_frontend_params(
];
if let Some(fs) = filters_str {
if !fs.is_empty() {
for entry in fs.split(',') {
for entry in fs.split(";;") {
if !entry.is_empty() {
parts.push(format!("filter={}", urlencoding::encode(entry.trim())));
}

View file

@ -16,6 +16,10 @@ fn is_false(val: &bool) -> bool {
!val
}
fn is_empty_slice(val: &&[&str]) -> bool {
val.is_empty()
}
#[derive(Clone, Serialize)]
#[serde(tag = "type")]
pub enum FeatureInfo {
@ -37,6 +41,10 @@ pub enum FeatureInfo {
raw: bool,
#[serde(skip_serializing_if = "is_false")]
absolute: bool,
#[serde(skip_serializing_if = "is_empty_slice")]
modes: &'static [&'static str],
#[serde(skip_serializing_if = "is_empty")]
linked: &'static str,
},
#[serde(rename = "enum")]
Enum {
@ -102,6 +110,8 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
suffix: feature_config.suffix,
raw: feature_config.raw,
absolute: feature_config.absolute,
modes: feature_config.modes,
linked: feature_config.linked,
});
}
}

View file

@ -59,6 +59,8 @@ pub struct HexagonStatsResponse {
pub enum_features: Vec<EnumFeatureStats>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub price_history: Vec<PricePoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub central_postcode: Option<String>,
}
#[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

View file

@ -30,7 +30,7 @@ pub struct HexagonsResponse {
pub struct HexagonParams {
resolution: u8,
bounds: Option<String>,
/// Comma-separated filters: `name:min:max,...`
/// `;;`-separated filters: `name:min:max;;...`
filters: Option<String>,
/// Comma-separated feature names to include in min/max aggregation.
fields: Option<String>,
@ -43,12 +43,13 @@ pub struct HexagonParams {
struct TravelEntry {
mode: String,
slug: String,
use_best: bool,
filter_min: Option<f32>,
filter_max: Option<f32>,
}
/// 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<Vec<TravelEntry>, String> {
let mut entries = Vec::new();
let mut seen_keys = Vec::new();
@ -63,12 +64,15 @@ fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, 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<Vec<TravelEntry>, String> {
entries.push(TravelEntry {
mode,
slug,
use_best,
filter_min,
filter_max,
});
@ -191,7 +196,7 @@ pub async fn get_hexagons(
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
// Allow the homepage demo: check if the center of the requested bounds
// is near the demo view center (52.2, -1.9).
// is near the demo view center (51.51, -0.12).
let center_lat = (south + north) / 2.0;
let center_lng = (west + east) / 2.0;
let is_demo_view = (center_lat - DEMO_CENTER.0).abs() <= DEMO_CENTER_TOLERANCE
@ -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 {

View file

@ -138,16 +138,13 @@ pub async fn post_invites(
}
}
/// Validate an invite code. Requires authentication to prevent enumeration.
/// Validate an invite code. Public endpoint — codes are 12-char random alphanumeric
/// so enumeration is impractical, and the response only reveals valid/invalid + type.
pub async fn get_invite(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Extension(_user): Extension<OptionalUser>,
Path(code): Path<String>,
) -> Response {
if user.0.is_none() {
return StatusCode::UNAUTHORIZED.into_response();
}
if let Err(msg) = validate_invite_code(&code) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}

View file

@ -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<reqwest::Client> = 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<AppState>, 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}");

View file

@ -135,6 +135,7 @@ pub async fn get_postcode_stats(
numeric_features,
enum_features: enum_features_out,
price_history,
central_postcode: None,
})
})
.await

View file

@ -27,7 +27,7 @@ pub struct PostcodesResponse {
#[derive(Deserialize)]
pub struct PostcodeParams {
bounds: Option<String>,
/// Comma-separated filters: `name:min:max,...`
/// `;;`-separated filters: `name:min:max;;...`
filters: Option<String>,
/// Comma-separated feature names to include in min/max aggregation.
fields: Option<String>,

View file

@ -11,7 +11,7 @@ use crate::state::AppState;
/// Pricing tiers: (cumulative user cap, price in pence).
const TIERS: &[(u64, u64)] = &[
(10, 0), // First 10 users: free
(1, 0), // First 10 users: free
(20, 1000), // Next 10: £10
(45, 2500), // Next 25: £25
(95, 5000), // Next 50: £50

View file

@ -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<RightmoveMatch>,
}
pub async fn get_rightmove_typeahead(
state: Arc<AppState>,
Query(params): Query<TypeaheadParams>,
) -> Result<Json<TypeaheadResponse>, 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())
}