Merge branch 'main' of https://github.com/rubyhrzhang/property-map
This commit is contained in:
commit
afa6934a2d
91 changed files with 2122 additions and 1360 deletions
|
|
@ -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) \
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip
|
|||
|
||||
https://xploria.co.uk/data-sources/
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
- stripe
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 — don'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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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 && (
|
||||
|
|
|
|||
|
|
@ -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 — 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 & property basics
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Your future home isn't a box of cereal you grab because it's on sale.
|
||||
Don'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 & 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's truly available in any area.
|
||||
Add a travel time filter to your workplace — 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 & 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'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 & education
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
The best areas to live don't always have properties listed right now. We help
|
||||
you identify where you should be looking, so when something does come up,
|
||||
you'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's possible
|
||||
5. Lifestyle & amenities
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn'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 & 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 £10k+ in stamp duty, £1,500 in solicitor fees,
|
||||
£500 for a survey. Get the wrong area and you're stuck with a long
|
||||
commute, bad schools, or a road you didn'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 £400k house: £10,000. Solicitor fees: £1,500.
|
||||
Survey: £500. Moving costs: £1,000. And that's just the money. Get the
|
||||
wrong area and you're stuck — 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' : ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 ${
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
23
frontend/src/components/ui/icons/BicycleIcon.tsx
Normal file
23
frontend/src/components/ui/icons/BicycleIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/CarIcon.tsx
Normal file
22
frontend/src/components/ui/icons/CarIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/ChartBarIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ChartBarIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/GraduationCapIcon.tsx
Normal file
20
frontend/src/components/ui/icons/GraduationCapIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/HouseIcon.tsx
Normal file
20
frontend/src/components/ui/icons/HouseIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/ui/icons/ShieldIcon.tsx
Normal file
19
frontend/src/components/ui/icons/ShieldIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/ShoppingBagIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ShoppingBagIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/TagIcon.tsx
Normal file
20
frontend/src/components/ui/icons/TagIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/ui/icons/TransitIcon.tsx
Normal file
25
frontend/src/components/ui/icons/TransitIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/TreeIcon.tsx
Normal file
20
frontend/src/components/ui/icons/TreeIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/UsersIcon.tsx
Normal file
22
frontend/src/components/ui/icons/UsersIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/icons/WalkingIcon.tsx
Normal file
23
frontend/src/components/ui/icons/WalkingIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ plausibleInit({
|
|||
captureOnLocalhost: true,
|
||||
logging: true,
|
||||
fileDownloads: true,
|
||||
outboundLinks: true,
|
||||
hashBasedRouting: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
19
frontend/src/lib/analytics.ts
Normal file
19
frontend/src/lib/analytics.ts
Normal 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' } });
|
||||
}
|
||||
|
|
@ -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(';;');
|
||||
}
|
||||
|
|
|
|||
16
frontend/src/lib/clipboard.ts
Normal file
16
frontend/src/lib/clipboard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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] },
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
31
frontend/src/lib/group-icons.ts
Normal file
31
frontend/src/lib/group-icons.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
16
server-rs/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 1–2.
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ pub async fn get_postcode_stats(
|
|||
numeric_features,
|
||||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
central_postcode: None,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
83
server-rs/src/routes/rightmove_typeahead.rs
Normal file
83
server-rs/src/routes/rightmove_typeahead.rs
Normal 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())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue