This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

2
.gitignore vendored
View file

@ -1,4 +1,3 @@
data_sources
.venv
.claude
tfl_journey_client
@ -7,5 +6,4 @@ tfl_journey_client
**/dist
server-rs/target
.task
data
frontend/public/assets

View file

@ -16,6 +16,7 @@ RUN cargo build --release
# Stage 3: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser
WORKDIR /app
COPY --from=server /app/server-rs/target/release/property-map-server ./
COPY --from=frontend /app/frontend/dist ./frontend/dist/
@ -25,9 +26,12 @@ COPY property-data/filtered_uk_pois.parquet ./data/
COPY property-data/places.parquet ./data/
COPY property-data/uk.pmtiles ./data/
COPY manual-data/postcode_boundaries ./data/postcode_boundaries/
COPY property-data/travel-times ./data/travel-times/
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8001
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
CMD curl -f http://localhost:8001/health || exit 1
ENTRYPOINT ["./property-map-server"]
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--dist", "/app/frontend/dist"]
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--dist", "/app/frontend/dist"]

View file

@ -24,8 +24,6 @@ POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
EPC_PP := $(DATA_DIR)/epc_pp.parquet
WIDE := $(DATA_DIR)/wide.parquet
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
RENO_PREMIUM := $(DATA_DIR)/renovation_premium.parquet
HEDONIC_MODEL := $(DATA_DIR)/hedonic_model.json
PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/certificates.csv
JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet
@ -48,6 +46,11 @@ PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries
TRANSIT_DIR := $(DATA_DIR)/transit
TRANSIT_STAMP := $(TRANSIT_DIR)/.done
GREENSPACE := $(DATA_DIR)/greenspace_water.parquet
PBF := $(DATA_DIR)/great-britain-latest.osm.pbf
PLACES := $(DATA_DIR)/places.parquet
RIGHTMOVE_BUY := $(DATA_DIR)/rightmove_buy.parquet
RIGHTMOVE_RENT := $(DATA_DIR)/rightmove_rent.parquet
ONLINE_STAMP := $(DATA_DIR)/.online_done
# Sentinel files for directory targets (Make doesn't track directories well)
GEOSURE_STAMP := $(GEOSURE_DIR)/.done
@ -61,7 +64,7 @@ PMTILES_VERSION := 1.22.3
download-arcgis download-price-paid download-deprivation download-ethnicity \
download-naptan download-pois download-ofsted download-broadband download-rental-prices \
download-postcodes download-geosure download-noise download-inspire \
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace \
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 \
@ -87,6 +90,8 @@ download-oa-boundaries: $(OA_BOUNDARIES)
download-uprn-lookup: $(UPRN_LOOKUP)
download-transit-network: $(TRANSIT_STAMP)
download-greenspace: $(GREENSPACE)
download-pbf: $(PBF)
download-places: $(PLACES)
transform-pois: $(POIS_FILTERED)
transform-epc-pp: $(EPC_PP)
transform-crime: $(CRIME)
@ -132,8 +137,13 @@ $(ETHNICITY):
$(NAPTAN):
uv run python -m pipeline.download.naptan --output $@
$(POIS_RAW):
uv run python -m pipeline.download.pois --output $@
$(PBF):
@mkdir -p $(DATA_DIR)
curl -L -o $@.tmp https://download.geofabrik.de/europe/great-britain-latest.osm.pbf
mv $@.tmp $@
$(POIS_RAW): $(PBF)
uv run python -m pipeline.download.pois --output $@ --pbf $(PBF)
$(OFSTED):
uv run python -m pipeline.download.ofsted --output $@
@ -168,8 +178,11 @@ $(TRANSIT_STAMP):
$(RENTAL):
uv run python -m pipeline.download.rental_prices --output $@
$(GREENSPACE):
uv run python -m pipeline.download.greenspace_water --output $@
$(GREENSPACE): $(PBF)
uv run python -m pipeline.download.greenspace_water --output $@ --pbf $(PBF)
$(PLACES): $(PBF)
uv run python -m pipeline.download.places --output $@ --pbf $(PBF)
# ── Journey times (requires TFL_API_KEY) ──────────────────────────────────────
@ -260,18 +273,20 @@ $(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA)
--rental-prices $(RENTAL) \
--output $@
# ── Price estimation (post-merge) ────────────────────────────────────────────
# ── Online listings (post-merge, pre-pricing) ───────────────────────────────
$(PRICE_INDEX): $(WIDE)
uv run python -m pipeline.transform.price_index --input $(WIDE) --output $@
$(RENO_PREMIUM): $(WIDE) $(PRICE_INDEX)
uv run python -m pipeline.transform.renovation_premium --input $(WIDE) --index $(PRICE_INDEX) --output $@
$(HEDONIC_MODEL): $(WIDE)
uv run python -m pipeline.transform.hedonic_quality --input $(WIDE) --output $@
$(PRICES_STAMP): $(WIDE) $(PRICE_INDEX) $(RENO_PREMIUM) $(HEDONIC_MODEL)
uv run python -m pipeline.transform.price_estimate --input $(WIDE) --index $(PRICE_INDEX) \
--renovation-premium $(RENO_PREMIUM) --hedonic-model $(HEDONIC_MODEL)
$(ONLINE_STAMP): $(WIDE) $(RIGHTMOVE_BUY) $(RIGHTMOVE_RENT)
uv run python -m pipeline.transform.add_online_listings \
--input $(WIDE) \
--buy $(RIGHTMOVE_BUY) \
--rent $(RIGHTMOVE_RENT)
@touch $@
# ── Price estimation (post-merge + online) ──────────────────────────────────
$(PRICE_INDEX): $(ONLINE_STAMP)
uv run python -m pipeline.transform.price_estimation.index --input $(WIDE) --output $@
$(PRICES_STAMP): $(ONLINE_STAMP) $(PRICE_INDEX)
uv run python -m pipeline.transform.price_estimation.estimate --input $(WIDE) --index $(PRICE_INDEX)
@touch $@

View file

@ -1,15 +1,7 @@
# Property Map
## Dev setup
```sh
curl -1sLf 'https://dl.cloudsmith.io/public/task/task/setup.deb.sh' | sudo -E bash
apt install task
task prepare
```
## Area
uv run python scripts/remove_bg.py house-og.png 200 house.png
4. ambiance
- nature / greenery within 5 mins walk
@ -36,16 +28,6 @@ https://xploria.co.uk/data-sources/
We all care about different things in our homes and living environments. Some of us are weary of noise and would like to avoid living next to a loud airfield as much as possible. And some of us are avid plane spotters.
We will help you find the best places to live within your budget regardless if theres a property listed there right now. The best things come to those whore patient. We will justify your patience. But we will also show you if your expectations are impossible to meet. Id much rather be told upfront then spend months of my life looking for something that cant possibly exist.
We give you all the data and tools to become an Well-informed Buyer through the click of a button.
[button]
---
- fix frontend
@ -68,6 +50,9 @@ FAQ:
make -f Makefile.data prepare
make -f Makefile.data tiles
## outstadning prompts
@ -79,4 +64,62 @@ Add licensing to the app. By default, anonymous users can use the map but only
uv run python scripts/remove_bg.py house-og.png 200 house.png
- fully remove the AI summary
- show all the info about listings
- put the travel times into categories
- move the support page under Learn and merge the FAQs
- add an account page and merge it with Saved
- padding between email and resend verification
- rename historical/bu/rent
- the hero page with the floating card is ugly, make them better integrated
- make the active filters much bigger on the demo page
- make the pricing tiers different cards next to each other
- hide the pricing if you're logged in
- make the loading more obvious and in the middle of the map
- the typeahead no longer works. I see the requests but nothing shows up
- make hero 100% height
- unsquish hexagons
- Get lifetime access
- "let's see an example with arrow down"
- make demo filters adjustable
- add next button to cards
- make filters half-page or interleaved
- fix dev redirect
- start epxloring should bring to dashboard
- get lifetime access should say you have it already
- density -> number properties
- make price history a rolling avg
- zoom in at the end of the demo
- referal link is broken
- remove random wales/ir/scotland areas from rightmove
- load test
- saved search Only superusers can perform this action.

View file

@ -123,4 +123,3 @@ tasks:
- task: build:server
- task: build:frontend
- task: test
- task: test:server

View file

@ -10,7 +10,7 @@ services:
command: >
bash -c "
cargo install cargo-watch &&
cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries'
cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times'
"
ports:
- "8001:8001"
@ -23,6 +23,7 @@ services:
- cargo-registry:/usr/local/cargo/registry
- cargo-target:/app/server-rs/target
- ./property-data:/app/data:ro
- ./property-data/travel-times:/app/data/travel-times:ro
environment:
POCKETBASE_URL: http://pocketbase:8090
POCKETBASE_ADMIN_EMAIL: *pb-email
@ -31,8 +32,14 @@ services:
OLLAMA_URL: http://host.docker.internal:11434
OLLAMA_MODEL: gpt-oss:20b
PUBLIC_URL: https://perfectpostcodes.schmelczer.dev
GOOGLE_MAPS_API_KEY: "AIzaSyBgBn9LjrxHCjb9j1LZbLYpEdCJj-NkHPY"
STRIPE_SECRET_KEY: sk_test_51SyVcePRjj2bdyn1HLkatQ5onwp8kamm41tjMcRdxXnJYWVPsVd9usMTOSNtNdGhrjbsrtNbgTdKXICg2qBiocEn00PvNDC0d3
STRIPE_WEBHOOK_SECRET: whsec_pIkGZblYlcN2VesTxq4pk1cDqdxOQ1y0
STRIPE_REFERRAL_COUPON_ID: L5uQqagl
GOOGLE_OAUTH_CLIENT_ID: 536485512604-740bbn3tf027ogrdcr5sqor4ntorkaqv.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET: 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
APPLE_OAUTH_CLIENT_ID: ${APPLE_OAUTH_CLIENT_ID}
APPLE_OAUTH_CLIENT_SECRET: ${APPLE_OAUTH_CLIENT_SECRET}
depends_on:
pocketbase:
condition: service_healthy

28331
finder/data/rightmove.log Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

View file

@ -5,9 +5,13 @@ import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import LearnPage from './components/learn/LearnPage';
import AccountPage from './components/account/AccountPage';
import InvitePage from './components/invite/InvitePage';
import SupportPage from './components/support/SupportPage';
import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
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 { parseUrlState } from './lib/url-state';
@ -23,11 +27,11 @@ declare global {
}
}
function pageToPath(page: Page): string {
function pageToPath(page: Page, inviteCode?: string): string {
switch (page) {
case 'dashboard':
return '/dashboard';
case 'saved-searches':
case 'saved-searches':
return '/saved';
case 'learn':
return '/learn';
@ -35,18 +39,27 @@ case 'saved-searches':
return '/pricing';
case 'account':
return '/account';
case 'invite':
return `/invite/${inviteCode || ''}`;
case 'support':
return '/support';
default:
return '/';
}
}
function pathToPage(pathname: string): Page | null {
if (pathname === '/dashboard') return 'dashboard';
if (pathname === '/saved') return 'saved-searches';
if (pathname === '/learn') return 'learn';
if (pathname === '/pricing') return 'pricing';
if (pathname === '/account') return 'account';
if (pathname === '/') return 'home';
function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null {
if (pathname === '/dashboard') return { page: 'dashboard' };
if (pathname === '/saved') return { page: 'saved-searches' };
if (pathname === '/learn') return { page: 'learn' };
if (pathname === '/pricing') return { page: 'pricing' };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'support' };
if (pathname.startsWith('/invite/')) {
const code = pathname.slice('/invite/'.length);
return { page: 'invite', inviteCode: code };
}
if (pathname === '/') return { page: 'home' };
return null;
}
@ -73,12 +86,13 @@ export default function App() {
// UI state
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(null);
const [activePage, setActivePage] = useState<Page>(() => {
if (isScreenshotMode) return 'dashboard';
// Derive page from URL pathname
const fromPath = pathToPage(window.location.pathname);
if (fromPath) return fromPath;
if (fromPath) return fromPath.page;
// Restore from history state (e.g. popstate)
if (window.history.state?.page) return window.history.state.page;
@ -86,6 +100,14 @@ export default function App() {
return 'home';
});
// Initialize invite code from URL
useEffect(() => {
const fromPath = pathToPage(window.location.pathname);
if (fromPath?.inviteCode) {
setInviteCode(fromPath.inviteCode);
}
}, []);
const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile();
const {
@ -94,13 +116,31 @@ export default function App() {
error: authError,
login,
register,
loginWithOAuth,
logout,
requestPasswordReset,
requestVerification,
refreshAuth,
clearError,
} = useAuth();
const [showAuthModal, setShowAuthModal] = useState(false);
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
const [verificationDismissed, setVerificationDismissed] = useState(false);
// Handle license_success query param (redirect from Stripe)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('license_success') === '1') {
params.delete('license_success');
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
setShowLicenseSuccess(true);
refreshAuth();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
@ -148,11 +188,11 @@ export default function App() {
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
}
const path = pageToPath(page);
const path = pageToPath(page, inviteCode ?? undefined);
const url = hash ? `${path}#${hash}` : path;
window.history.pushState({ page }, '', url);
setActivePage(page);
}, []);
}, [inviteCode]);
useEffect(() => {
if (!window.history.state?.page) {
@ -170,8 +210,9 @@ export default function App() {
}
} else {
// Fall back to deriving page from pathname
const page = pathToPage(window.location.pathname);
setActivePage(page || 'home');
const parsed = pathToPage(window.location.pathname);
setActivePage(parsed?.page || 'home');
if (parsed?.inviteCode) setInviteCode(parsed.inviteCode);
}
};
window.addEventListener('popstate', handlePopState);
@ -232,14 +273,51 @@ export default function App() {
onLogout={logout}
isMobile={isMobile}
/>
{user && !user.verified && !verificationDismissed && (
<VerificationBanner
email={user.email}
onRequestVerification={requestVerification}
onDismiss={() => setVerificationDismissed(true)}
/>
)}
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'pricing' ? (
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
<PricingPage
onOpenDashboard={() => navigateTo('dashboard')}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
/>
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'account' && user ? (
<AccountPage user={user} onRefreshAuth={refreshAuth} />
<AccountPage user={user} onRefreshAuth={refreshAuth} onRequestVerification={requestVerification} />
) : activePage === 'support' ? (
<SupportPage />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
code={inviteCode}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onLicenseGranted={() => {
setShowLicenseSuccess(true);
refreshAuth();
}}
/>
) : activePage === 'saved-searches' ? (
<SavedSearchesPage
searches={savedSearches.searches}
@ -265,6 +343,15 @@ export default function App() {
onExportStateChange={setExportState}
isMobile={isMobile}
initialTravelTime={urlState.travelTime}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
/>
)}
{showAuthModal && (
@ -272,6 +359,7 @@ export default function App() {
onClose={() => setShowAuthModal(false)}
onLogin={login}
onRegister={register}
onOAuthLogin={loginWithOAuth}
onForgotPassword={requestPasswordReset}
loading={authLoading}
error={authError}
@ -287,6 +375,9 @@ export default function App() {
error={savedSearches.error}
/>
)}
{showLicenseSuccess && (
<LicenseSuccessModal onClose={() => setShowLicenseSuccess(false)} />
)}
</div>
);
}

View file

@ -3,26 +3,40 @@ import type { AuthUser } from '../../hooks/useAuth';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
const SUBSCRIPTION_OPTIONS = ['free', 'rental', 'buyer'] as const;
const SUBSCRIPTION_OPTIONS = ['free', 'licensed'] as const;
const SUBSCRIPTION_LABELS: Record<string, string> = {
free: 'Free',
rental: 'Rental',
buyer: 'Buyer',
licensed: 'Licensed',
};
export default function AccountPage({
user,
onRefreshAuth,
onRequestVerification,
}: {
user: AuthUser;
onRefreshAuth: () => Promise<void>;
onRequestVerification: (email: string) => Promise<void>;
}) {
const [selectedSubscription, setSelectedSubscription] = useState(user.subscription || 'free');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [newsletterSaving, setNewsletterSaving] = useState(false);
const [newsletterError, setNewsletterError] = useState<string | null>(null);
// Verification state
const [verificationSending, setVerificationSending] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
// Invite state
const [creatingInvite, setCreatingInvite] = useState(false);
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [inviteError, setInviteError] = useState<string | null>(null);
const [inviteCopied, setInviteCopied] = useState(false);
const handleSave = async () => {
setSaving(true);
@ -48,12 +62,44 @@ export default function AccountPage({
}
};
const handleCreateInvite = async () => {
setCreatingInvite(true);
setInviteError(null);
setInviteUrl(null);
setInviteCopied(false);
try {
const res = await fetch(apiUrl('invites'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
}),
});
assertOk(res, 'Create invite');
const data = await res.json();
setInviteUrl(data.url);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create invite';
setInviteError(msg);
} finally {
setCreatingInvite(false);
}
};
const handleCopyInvite = () => {
if (!inviteUrl) return;
navigator.clipboard.writeText(inviteUrl).then(() => {
setInviteCopied(true);
setTimeout(() => setInviteCopied(false), 2000);
});
};
const badgeColor =
user.subscription === 'buyer'
user.subscription === 'licensed'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: user.subscription === 'rental'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
@ -67,15 +113,38 @@ export default function AccountPage({
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
</div>
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
user.verified
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{user.verified ? 'Verified' : 'Unverified'}
</span>
<div className="flex items-center gap-2">
{!user.verified && (
<button
onClick={async () => {
setVerificationSending(true);
try {
await onRequestVerification(user.email);
setVerificationSent(true);
setTimeout(() => setVerificationSent(false), 3000);
} catch {
// Error handled by hook
} finally {
setVerificationSending(false);
}
}}
disabled={verificationSending || verificationSent}
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 disabled:opacity-50 flex items-center gap-1"
>
{verificationSending && <SpinnerIcon className="w-3 h-3 animate-spin" />}
{verificationSent ? 'Sent!' : 'Resend verification'}
</button>
)}
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
user.verified
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{user.verified ? 'Verified' : 'Unverified'}
</span>
</div>
</div>
{/* Subscription */}
@ -88,6 +157,88 @@ export default function AccountPage({
</div>
</div>
{/* Newsletter */}
<div className="px-5 py-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={user.newsletter}
disabled={newsletterSaving}
onChange={async (e) => {
const checked = e.target.checked;
setNewsletterSaving(true);
setNewsletterError(null);
try {
const res = await fetch(apiUrl('newsletter'), {
method: 'PATCH',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newsletter: checked }),
}),
});
assertOk(res, 'Update newsletter');
await onRefreshAuth();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update newsletter';
setNewsletterError(msg);
} finally {
setNewsletterSaving(false);
}
}}
className="w-4 h-4 accent-teal-600 rounded"
/>
<span className="text-navy-950 dark:text-warm-100 text-sm">
Receive newsletter emails
</span>
{newsletterSaving && <SpinnerIcon className="w-4 h-4 animate-spin text-warm-400" />}
</label>
{newsletterError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{newsletterError}</p>
)}
</div>
{/* Invite friends */}
{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 license)' : 'Invite friends (30% off)'}
</p>
{inviteUrl ? (
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={inviteUrl}
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
/>
<button
onClick={handleCopyInvite}
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
>
{inviteCopied ? (
<CheckIcon className="w-4 h-4" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
{inviteCopied ? 'Copied' : 'Copy'}
</button>
</div>
) : (
<button
onClick={handleCreateInvite}
disabled={creatingInvite}
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'}
</button>
)}
{inviteError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError}</p>
)}
</div>
)}
{/* Admin section */}
{user.isAdmin && (
<div className="px-5 py-4">

View file

@ -1,9 +1,8 @@
import { useRef, useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
import HomeDemo from './HomeDemo';
import ScrollStory from './ScrollStory';
import BottomIllustration from './BottomIllustration';
import CategoryArt from './CategoryArt';
import { TickerValue } from '../ui/TickerValue';
import type { FeatureMeta } from '../../types';
@ -18,46 +17,36 @@ export default function HomePage({
theme?: 'light' | 'dark';
features?: FeatureMeta[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [statsActive, setStatsActive] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setStatsActive(true), 300);
return () => clearTimeout(timer);
}, []);
const heroRef = useFadeInRef();
const demoRef = useFadeInRef();
const scaleRef = useFadeInRef();
const problemRef = useFadeInRef();
const whyRef = useFadeInRef();
const howRef = useFadeInRef();
const ctaRef = useFadeInRef();
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero — full-bleed */}
<div
ref={heroRef}
className="fade-in-section relative overflow-hidden bg-gradient-to-br from-navy-950 via-navy-900 to-teal-900 dark:from-navy-950 dark:via-navy-900 dark:to-teal-900/60 pt-16 pb-20 md:pt-24 md:pb-28 shadow-[0_12px_50px_0px_rgba(13,148,136,0.5)] dark:shadow-[0_12px_50px_0px_rgba(13,148,136,0.4)]"
>
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-br from-navy-950 via-navy-900 to-teal-900 dark:from-navy-950 dark:via-navy-900 dark:to-teal-900/60 pt-16 pb-20 md:pt-24 md:pb-28 shadow-[0_12px_50px_0px_rgba(13,148,136,0.5)] dark:shadow-[0_12px_50px_0px_rgba(13,148,136,0.4)]">
<HexCanvas isDark={theme === 'dark'} />
{/* Radial teal glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 py-6 backdrop-blur-sm bg-navy-950/30 rounded-2xl">
<p className="text-teal-400 font-semibold tracking-wide uppercase text-sm mb-4">
Browsing listings is not a strategy. Knowing what you want is.
</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-6 leading-[1.1] tracking-tight">
Find your{' '}
<span className="text-teal-400">perfect postcode</span>
<br />
<span className="text-warm-300">before you find your&nbsp;property.</span>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
Get more <span className="text-teal-400">home</span> for your money.
</h1>
<p className="text-xl text-warm-300 mb-8 leading-relaxed max-w-xl">
Set the sliders to your expectations and the map highlights the areas that actually
match. Instantly.
<p className="text-xl text-warm-300 mb-6 leading-relaxed max-w-xl">
Buying a home may be your most important decision. Why not ensure you make your
best-ever decision?
</p>
<div className="flex items-center gap-4 mb-12">
<p className="text-lg text-warm-400 mb-8 max-w-lg">
You have so many options. Picking the best one is daunting and stressful. It
won&apos;t be anymore when looking at the property landscape through our dashboard.
</p>
<div className="flex items-center gap-4 mb-10">
<button
onClick={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"
@ -92,124 +81,85 @@ export default function HomePage({
</div>
</div>
{/* Map + Slider demo */}
<div className="max-w-4xl mx-auto px-6 pt-16 pb-20">
<div ref={demoRef} className="fade-in-section">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2">
See it in action
{/* Scrollytelling: Problem + Solution + Demo map */}
<ScrollStory features={features} theme={theme} />
{/* Why existing tools don't cut it */}
<div className="max-w-4xl mx-auto px-6 py-20">
<div ref={whyRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
Why existing tools don&apos;t cut it
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-5 max-w-lg">
Drag the sliders and watch the map respond. Every postcode scored, every filter instant.
</p>
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-4 md:p-6">
<HomeDemo features={features} theme={theme} />
<div className="grid md:grid-cols-3 gap-6">
{WHY_CARDS.map((card) => (
<div
key={card.title}
className="rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-6 shadow-sm"
>
<div className="text-2xl mb-3">{card.icon}</div>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-2">{card.title}</h3>
<p className="text-warm-600 dark:text-warm-400 text-sm leading-relaxed">
{card.description}
</p>
</div>
))}
</div>
<p className="text-center mt-10 text-lg text-warm-700 dark:text-warm-300 max-w-2xl mx-auto leading-relaxed">
We do. 13 million historical transactions. 56 data layers. Real travel-time routing to
any destination. Every postcode in England, scored and filterable, on a single map.
</p>
</div>
</div>
{/* Scale — "That's just two" + category cards */}
<div className="max-w-4xl mx-auto px-6 pb-20">
<div ref={scaleRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2 text-center">
That&apos;s just three. We&apos;ve built&nbsp;43.
{/* How to use it */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={howRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
How to use it
</h2>
<p className="text-warm-500 dark:text-warm-400 text-center mb-10 max-w-lg mx-auto">
Spanning transport links, amenities, demographics, environment risk, broadband speeds,
crime, and more.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CATEGORIES.map((c) => (
<div
key={c.label}
className={`rounded-xl border-l-4 border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-4 shadow-sm hover:shadow-md transition-shadow ${c.borderClass} ${c.hoverBgClass}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`shrink-0 flex items-center justify-center rounded-lg w-8 h-8 text-base ${c.iconBgClass}`}
>
{c.icon}
</div>
<span className="font-semibold text-navy-950 dark:text-warm-100 text-sm">
{c.label}
</span>
</div>
<CategoryArt
category={c.group === 'Environment' && c.label === 'Broadband' ? 'Broadband' : c.group}
className={`shrink-0 ${c.artColorClass} opacity-40`}
/>
</div>
<div className="space-y-8">
{HOW_STEPS.map((step, i) => (
<div key={i} className="flex gap-5">
<div className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-lg">
{i + 1}
</div>
<div>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-1">
{step.title}
</h3>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
{step.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Problem / solution / philosophy */}
<div className="max-w-4xl mx-auto px-6 pb-20 relative">
{/* Cereal box — quirky margin note, hidden on narrow screens */}
<div className="hidden lg:block group absolute -right-44 top-8 cursor-pointer">
<div className="cereal-wobble">
<img src="/cereal.png" alt="Discounted cereal box" className="w-36 h-auto" />
</div>
<p className="cereal-text text-sm italic text-warm-500 dark:text-warm-400 mt-2 w-[9rem] leading-snug">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong
property distract you from finding the right one.
</p>
</div>
<div ref={problemRef} className="fade-in-section">
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
Here&apos;s the problem with property search: listings only show you what&apos;s on
the market{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
&mdash; a thin slice of what an area is actually like. And even if you could look
beyond them, there are{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
millions of postcodes
</strong>{' '}
across England. You can&apos;t research them all yourself.
</p>
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
We built this for you &mdash; years of historical transactions and public records,
extended with proprietary algorithms so the map doesn&apos;t just show raw data, it{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
surfaces the patterns that matter
</strong>
.
</p>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 leading-relaxed">
Understand areas first. Then find the right property within them, with expectations
you&apos;ve set &mdash; not ones the market set for you.
</p>
</div>
</div>
{/* Final CTA */}
{/* The real cost CTA */}
<div className="max-w-3xl mx-auto px-6 pb-12">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
The biggest financial decision of your life
<br />
deserves proper tools behind&nbsp;it.
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-8 max-w-md mx-auto">
One payment, lifetime access. Set your filters and go.
<p className="text-warm-600 dark:text-warm-400 mb-3 max-w-xl mx-auto leading-relaxed">
Stamp duty on a &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; with a long commute, bad schools, or a street
that looked fine on the listing photos but turns out to be on a motorway.
</p>
<div className="flex items-center justify-center gap-4">
<button
onClick={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"
>
Give your journey a headstart
</button>
<button
onClick={onOpenPricing}
className="px-[30px] py-[14px] border-2 border-navy-950 dark:border-warm-300 text-navy-950 dark:text-warm-300 rounded-lg font-semibold hover:bg-navy-950/5 dark:hover:bg-warm-300/5 transition-colors text-lg"
>
See pricing
</button>
</div>
<p className="text-warm-700 dark:text-warm-300 mb-8 font-semibold">
One payment. Lifetime access. Less than your survey costs and vastly more useful.
</p>
<button
onClick={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
</button>
</div>
</div>
@ -220,107 +170,46 @@ export default function HomePage({
);
}
interface Category {
icon: string;
label: string;
group: string;
borderClass: string;
hoverBgClass: string;
iconBgClass: string;
artColorClass: string;
}
const CATEGORIES: Category[] = [
const WHY_CARDS = [
{
icon: '\u{1F3E0}',
label: 'Property',
group: 'Property',
borderClass: 'border-l-teal-400 dark:border-l-teal-500',
hoverBgClass: 'hover:bg-teal-50/50 dark:hover:bg-teal-900/20',
iconBgClass: 'bg-teal-100 dark:bg-teal-900/40',
artColorClass: 'text-teal-400 dark:text-teal-600',
icon: '\u{1F3D8}\uFE0F',
title: 'Listing portals',
description:
"Show you what's for sale today. That's a snapshot, not a strategy. You can filter by price and bedrooms \u2014 that's about it. They tell you nothing about the area.",
},
{
icon: '\u{1F686}',
label: 'Transport',
group: 'Transport',
borderClass: 'border-l-blue-400 dark:border-l-blue-500',
hoverBgClass: 'hover:bg-blue-50/50 dark:hover:bg-blue-900/20',
iconBgClass: 'bg-blue-100 dark:bg-blue-900/40',
artColorClass: 'text-blue-400 dark:text-blue-600',
icon: '\u{1F4CD}',
title: '\u201CCheck my postcode\u201D sites',
description:
"Give you stats for one postcode at a time. Useful if you already know where to look. Useless if you don't \u2014 and there are 1.5 million postcodes in England.",
},
{
icon: '\u{1F3EB}',
label: 'Schools',
group: 'Education',
borderClass: 'border-l-amber-400 dark:border-l-amber-500',
hoverBgClass: 'hover:bg-amber-50/50 dark:hover:bg-amber-900/20',
iconBgClass: 'bg-amber-100 dark:bg-amber-900/40',
artColorClass: 'text-amber-400 dark:text-amber-600',
},
{
icon: '\u{1F6A8}',
label: 'Crime',
group: 'Crime',
borderClass: 'border-l-rose-400 dark:border-l-rose-500',
hoverBgClass: 'hover:bg-rose-50/50 dark:hover:bg-rose-900/20',
iconBgClass: 'bg-rose-100 dark:bg-rose-900/40',
artColorClass: 'text-rose-400 dark:text-rose-600',
},
{
icon: '\u{1F465}',
label: 'Demographics',
group: 'Demographics',
borderClass: 'border-l-violet-400 dark:border-l-violet-500',
hoverBgClass: 'hover:bg-violet-50/50 dark:hover:bg-violet-900/20',
iconBgClass: 'bg-violet-100 dark:bg-violet-900/40',
artColorClass: 'text-violet-400 dark:text-violet-600',
},
{
icon: '\u{1F3EA}',
label: 'Amenities',
group: 'Amenities',
borderClass: 'border-l-emerald-400 dark:border-l-emerald-500',
hoverBgClass: 'hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20',
iconBgClass: 'bg-emerald-100 dark:bg-emerald-900/40',
artColorClass: 'text-emerald-400 dark:text-emerald-600',
},
{
icon: '\u{1F30D}',
label: 'Environment',
group: 'Environment',
borderClass: 'border-l-orange-400 dark:border-l-orange-500',
hoverBgClass: 'hover:bg-orange-50/50 dark:hover:bg-orange-900/20',
iconBgClass: 'bg-orange-100 dark:bg-orange-900/40',
artColorClass: 'text-orange-400 dark:text-orange-600',
},
{
icon: '\u{1F4E1}',
label: 'Broadband',
group: 'Environment',
borderClass: 'border-l-sky-400 dark:border-l-sky-500',
hoverBgClass: 'hover:bg-sky-50/50 dark:hover:bg-sky-900/20',
iconBgClass: 'bg-sky-100 dark:bg-sky-900/40',
artColorClass: 'text-sky-400 dark:text-sky-600',
},
{
icon: '\u{1F4CA}',
label: 'Deprivation',
group: 'Deprivation',
borderClass: 'border-l-fuchsia-400 dark:border-l-fuchsia-500',
hoverBgClass: 'hover:bg-fuchsia-50/50 dark:hover:bg-fuchsia-900/20',
iconBgClass: 'bg-fuchsia-100 dark:bg-fuchsia-900/40',
artColorClass: 'text-fuchsia-400 dark:text-fuchsia-600',
icon: '\u{1F5FA}\uFE0F',
title: 'Area guides',
description:
"Show one statistic on a map \u2014 crime, or school ratings, or prices. But you care about the intersection: affordable AND safe AND good schools AND short commute. Nobody else shows you that.",
},
];
const HOW_STEPS = [
{
title: 'Set your non-negotiables',
description:
'Budget, commute, bedrooms, whatever matters most. The map narrows to only the areas that qualify.',
},
{
title: "Explore what\u2019s left",
description:
"Zoom in. Toggle layers. See crime, schools, noise, amenities. Discover areas you didn\u2019t know existed.",
},
{
title: 'Drill into postcodes',
description:
'At street level, see individual properties, what they sold for, floor area, energy rating, estimated current value.',
},
{
title: 'Go to viewings with a shortlist, not a prayer',
description:
"You\u2019ve already done the hard part. Every area on your list meets your actual criteria, not just what happened to be listed that week.",
},
];

View file

@ -0,0 +1,342 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_FEATURE_NAMES = [
'Estimated current price',
'Good+ primary schools within 5km',
'Number of restaurants within 2km',
];
const DEMO_BOUNDS = '49,-9.5,57,5';
const DEMO_RESOLUTION = 5;
const noop = () => {};
// Filter fractions per stage: featureName -> [minFrac, maxFrac]
// 0 = feature.min, 1 = feature.max
interface StageDef {
filters: Record<string, [number, number]>;
colorFeature?: string;
}
const STAGES: StageDef[] = [
// 0: No filters — the problem
{ filters: {} },
// 1: Price filter — "affordable price"
{
filters: { 'Estimated current price': [0, 0.25] },
colorFeature: 'Estimated current price',
},
// 2: Price + schools
{
filters: {
'Estimated current price': [0, 0.25],
'Good+ primary schools within 5km': [0.3, 1],
},
colorFeature: 'Good+ primary schools within 5km',
},
// 3: All three
{
filters: {
'Estimated current price': [0, 0.25],
'Good+ primary schools within 5km': [0.3, 1],
'Number of restaurants within 2km': [0.15, 1],
},
colorFeature: 'Number of restaurants within 2km',
},
// 4: Same filters — "that's just three"
{
filters: {
'Estimated current price': [0, 0.25],
'Good+ primary schools within 5km': [0.3, 1],
'Number of restaurants within 2km': [0.15, 1],
},
},
];
const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{
heading: null,
body: (
<>
<p className="text-lg leading-relaxed mb-4">
You&apos;re about to spend{' '}
<strong className="text-navy-950 dark:text-warm-100">
&pound;300,000&ndash;600,000
</strong>{' '}
on a home. Your research method? Scrolling through listings and hoping for the best.
</p>
<p className="text-lg leading-relaxed mb-4">
Listings only show what&apos;s on the market <em>right now</em> &mdash; a tiny, random
slice of what&apos;s actually out there. You&apos;ll never see the 3-bed Victorian on a
quiet street that sold six months ago, or the one that&apos;ll list next month.
</p>
<p className="text-base italic text-warm-500 dark:text-warm-400">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong property distract
you from finding the right one.
</p>
</>
),
},
{
heading: 'Set your requirements. The map shows you where they intersect.',
body: (
<p className="text-lg leading-relaxed">
Say you want a home at an{' '}
<strong className="text-navy-950 dark:text-warm-100">affordable price</strong>&hellip;
</p>
),
},
{
heading: null,
body: (
<p className="text-lg leading-relaxed">
&hellip;with{' '}
<strong className="text-navy-950 dark:text-warm-100">good primary schools</strong>{' '}
nearby&hellip;
</p>
),
},
{
heading: null,
body: (
<>
<p className="text-lg leading-relaxed mb-4">
&hellip;and{' '}
<strong className="text-navy-950 dark:text-warm-100">
restaurants within walking distance
</strong>
.
</p>
<p className="text-lg leading-relaxed font-semibold text-navy-950 dark:text-warm-100">
You haven&apos;t opened a single listing yet &mdash; and you already know exactly where to
focus.
</p>
</>
),
},
{
heading: null,
body: (
<>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-2">
That&apos;s just three filters.
</p>
<p className="text-lg leading-relaxed">
We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">43</strong>.
Spanning property prices, commute times, school ratings, crime rates, broadband speeds,
road noise, energy efficiency, amenities, deprivation scores, demographics, and more. All
layered on top of each other, all filterable at once.
</p>
</>
),
},
];
interface ScrollStoryProps {
features: FeatureMeta[];
theme: 'light' | 'dark';
}
export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const [stage, setStage] = useState(0);
const [hexData, setHexData] = useState<HexagonData[]>([]);
const [loading, setLoading] = useState(true);
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
const abortRef = useRef<AbortController>();
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const demoFeatures = useMemo(
() =>
DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter(
Boolean
) as FeatureMeta[],
[features]
);
// Compute actual filter values from stage fractions + feature metadata
const stageFilters = useMemo(() => {
const stageDef = STAGES[stage];
const result: Record<string, [number, number]> = {};
for (const [name, [minFrac, maxFrac]] of Object.entries(stageDef.filters)) {
const meta = demoFeatures.find((f) => f.name === name);
if (meta?.min != null && meta?.max != null) {
const range = meta.max - meta.min;
result[name] = [meta.min + range * minFrac, meta.min + range * maxFrac];
}
}
return result;
}, [stage, demoFeatures]);
// IntersectionObserver for scroll stage detection
useEffect(() => {
const observers: IntersectionObserver[] = [];
stepRefs.current.forEach((el, i) => {
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setStage(i);
},
{ rootMargin: '-35% 0px -35% 0px', threshold: 0 }
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, [demoFeatures.length]);
// Fetch hex data when filters change
useEffect(() => {
if (features.length === 0) return;
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
const filterParts: string[] = [];
for (const [name, [min, max]] of Object.entries(stageFilters)) {
filterParts.push(`${name}:${min}:${max}`);
}
if (filterParts.length > 0) params.set('filters', filterParts.join(','));
const stageDef = STAGES[stage];
if (stageDef.colorFeature) params.set('fields', stageDef.colorFeature);
clearTimeout(fetchTimeoutRef.current);
fetchTimeoutRef.current = setTimeout(() => {
abortRef.current?.abort();
abortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
.then((res) => {
assertOk(res, 'hexagons');
return res.json();
})
.then((data: { features: HexagonData[] }) => {
setHexData(data.features);
setLoading(false);
})
.catch((err) => logNonAbortError('Failed to fetch story hexagons', err));
}, 300);
return () => clearTimeout(fetchTimeoutRef.current);
}, [features, stageFilters, stage]);
useEffect(() => {
return () => {
abortRef.current?.abort();
clearTimeout(fetchTimeoutRef.current);
};
}, []);
const stageDef = STAGES[stage];
const viewFeatureName = stageDef.colorFeature || null;
const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null;
const colorRange: [number, number] | null =
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
return (
<section className="relative">
{/* Sticky map background */}
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
<div className="absolute inset-0">
<MapComponent
data={hexData}
postcodeData={[]}
usePostcodeView={false}
pois={[]}
onViewChange={noop}
viewFeature={viewFeatureName}
colorRange={colorRange}
filterRange={null}
viewSource={viewFeatureName ? 'drag' : null}
onCancelPin={noop}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={noop}
onHexagonHover={noop}
initialViewState={DEMO_VIEW}
theme={theme}
screenshotMode={true}
hideLegend={true}
/>
</div>
{/* Interaction blocker */}
<div className="absolute inset-0 z-30" />
{/* Loading */}
{loading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<SpinnerIcon className="w-10 h-10 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
)}
{/* Filter indicators */}
<div className="absolute bottom-4 left-4 z-40 pointer-events-none w-[200px] md:w-[240px]">
<div className="bg-white/85 dark:bg-warm-800/85 rounded-lg p-3 backdrop-blur-sm shadow-lg space-y-2.5">
{demoFeatures.map((feature) => {
const filterVal = stageFilters[feature.name];
const isActive = !!filterVal;
const min = feature.min ?? 0;
const max = feature.max ?? 1;
const range = max - min || 1;
const leftPct = filterVal ? ((filterVal[0] - min) / range) * 100 : 0;
const widthPct = filterVal ? ((filterVal[1] - filterVal[0]) / range) * 100 : 100;
return (
<div
key={feature.name}
className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`}
>
<div className="flex justify-between items-baseline text-[11px] mb-1 gap-2">
<span
className={`font-medium truncate ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
>
{feature.name}
</span>
{isActive && filterVal && (
<span className="text-teal-600 dark:text-teal-400 font-medium whitespace-nowrap">
{formatValue(filterVal[0], feature)}&ndash;
{formatValue(filterVal[1], feature)}
</span>
)}
</div>
<div className="relative h-1.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
<div
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Scrolling text overlay */}
<div className="relative z-10 -mt-[60vh] md:-mt-[calc(100dvh-3rem)] pointer-events-none">
<div className="mx-4 md:ml-auto md:mr-[4%] md:max-w-md">
<div className="h-[35vh] md:h-[45vh]" />
{STEPS.map((step, i) => (
<div
key={i}
ref={(el) => {
stepRefs.current[i] = el;
}}
className="pointer-events-auto mb-[30vh] md:mb-[40vh] bg-white/90 dark:bg-warm-800/90 rounded-xl p-5 md:p-6 backdrop-blur-sm shadow-lg border border-warm-200/40 dark:border-warm-700/40"
>
{step.heading && (
<h3 className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-3 leading-snug">
{step.heading}
</h3>
)}
<div className="text-warm-700 dark:text-warm-300">{step.body}</div>
</div>
))}
<div className="h-[30vh] md:h-[40vh]" />
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import type { AuthUser } from '../../hooks/useAuth';
interface InvitePageProps {
code: string;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLicenseGranted: () => void;
}
interface InviteInfo {
valid: boolean;
invite_type: string;
used: boolean;
}
export default function InvitePage({
code,
user,
onLoginClick,
onRegisterClick,
onLicenseGranted,
}: InvitePageProps) {
const [invite, setInvite] = useState<InviteInfo | null>(null);
const [loading, setLoading] = useState(true);
const [redeeming, setRedeeming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redeemed, setRedeemed] = useState(false);
const [pricePence, setPricePence] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [inviteRes, pricingRes] = await Promise.all([
fetch(apiUrl(`invite/${encodeURIComponent(code)}`)),
fetch(apiUrl('pricing')),
]);
if (!inviteRes.ok) throw new Error('Failed to validate invite');
const data: InviteInfo = await inviteRes.json();
if (!cancelled) setInvite(data);
if (pricingRes.ok) {
const pricing = await pricingRes.json();
if (!cancelled) setPricePence(pricing.current_price_pence);
}
} catch {
if (!cancelled) setError('Failed to validate invite link');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [code]);
const handleRedeem = useCallback(async () => {
if (!user) return;
setRedeeming(true);
setError(null);
try {
const res = await fetch(apiUrl('redeem-invite'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
}),
});
assertOk(res, 'Redeem invite');
const data = await res.json();
if (data.result === 'licensed') {
setRedeemed(true);
onLicenseGranted();
} else if (data.checkout_url) {
window.location.href = data.checkout_url;
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to redeem invite');
} finally {
setRedeeming(false);
}
}, [code, user, onLicenseGranted]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
);
}
if (error && !invite) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">Invalid invite</p>
<p className="text-warm-500 dark:text-warm-400">{error}</p>
</div>
</div>
);
}
if (!invite?.valid || invite.used) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
{invite?.used ? 'Invite already used' : 'Invalid invite link'}
</p>
<p className="text-warm-500 dark:text-warm-400">
{invite?.used
? 'This invite link has already been redeemed.'
: 'This invite link is invalid or has expired.'}
</p>
</div>
</div>
);
}
if (redeemed) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
License activated!
</p>
<p className="text-warm-500 dark:text-warm-400">
You now have full access to Perfect Postcode.
</p>
</div>
</div>
);
}
const isAdminInvite = invite.invite_type === 'admin';
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">
{isAdminInvite ? "You're invited!" : 'Special offer!'}
</h2>
<p className="text-warm-300 text-sm">
{isAdminInvite
? 'You have been invited to get a free lifetime license.'
: 'A friend has shared a 30% discount on the lifetime license.'}
</p>
</div>
<div className="px-6 py-6">
{isAdminInvite && (
<div className="text-center mb-4">
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">Free</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime license</span>
</div>
)}
{!isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="text-center mb-4">
<span className="text-warm-400 dark:text-warm-500 line-through text-xl mr-2">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${Math.round(pricePence * 0.7) / 100}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">/once</span>
</div>
)}
{user ? (
<button
onClick={handleRedeem}
disabled={redeeming}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{redeeming && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{isAdminInvite
? redeeming
? 'Activating...'
: 'Activate license'
: redeeming
? 'Redirecting...'
: 'Claim discount'}
</button>
) : (
<div className="flex flex-col gap-3">
<button
onClick={onRegisterClick}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Register to claim
</button>
<button
onClick={onLoginClick}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Already have an account? Log in
</button>
</div>
)}
{error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
</div>
</div>
);
}

View file

@ -134,64 +134,79 @@ interface FAQItem {
const FAQ_ITEMS: FAQItem[] = [
{
question: 'What is this application?',
question: 'Are the prices shown current market values?',
answer:
'Perfect Postcode is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
'No. The prices shown are the last known sale price recorded by HM Land Registry, which is the price the property actually sold for. A property last sold in 2005 will show its 2005 price. These are not valuations or estimates of current market value. You can use the "Last known price" filter to focus on properties sold within a recent date range, and compare against the median rental prices for the local authority.',
},
{
question: 'Where does the data come from?',
question: 'What is the "Estimated current price" and how is it calculated?',
answer:
'All data comes from open government and community sources. Property prices are from HM Land Registry, energy certificates from MHCLG, transport times from TfL, deprivation scores from the English Indices of Deprivation 2025, crime data from data.police.uk, school ratings from Ofsted, broadband from Ofcom, noise from Defra, ethnicity from the 2021 Census, and points of interest from OpenStreetMap. See the Data Sources tab for full details and links.',
'The estimated current price is an inflation-adjusted estimate of what a property might be worth today, based on its last known sale price. It works in three stages. First, a repeat-sales price index (built from properties that have sold more than once) tracks how prices have moved within each postcode sector and property type over time — this adjusts the historical sale price to current levels. Second, spatial comparable sales from nearby properties of the same type are used to refine the estimate. Third, a machine learning model captures quality differences that the index alone misses (such as floor area, energy rating, local amenities, and deprivation). Properties with post-sale improvements detected from EPC records — such as extensions or renovations — also receive a renovation premium. The more recently a property sold, the closer the estimate will be to the actual sale price; older sales are adjusted more heavily.',
},
{
question: 'What are the coloured hexagons on the map?',
question: 'How are current for-sale and for-rent listings found?',
answer:
'The map uses H3 hexagons to aggregate property data at different zoom levels. Each hexagon summarises the properties within it. The colour represents the value of whichever feature you have pinned or are actively filtering — for example, average price or energy rating. Zoom in to see smaller, more detailed hexagons; zoom out for a broader overview.',
'Properties currently on the market are sourced by periodically searching independent property portals (Rightmove, OnTheMarket, and Zoopla). These listings are fuzzy-matched by address to existing Land Registry records so that current asking prices appear alongside the historical sale price, EPC data, and all area-level statistics. You can filter by "Listing status" to show only properties currently for sale or for rent. When you click on a hexagon, you\'ll also see direct links to search Rightmove, OnTheMarket, and Zoopla for that area, pre-filled with your active price filters.',
},
{
question: 'How do filters work?',
question: 'What area does this cover?',
answer:
'Use the Filters panel on the left to narrow down properties. Add a filter by clicking a feature name, then drag the range slider to set minimum and maximum values. For categorical features like property type, select or deselect individual values. Only hexagons containing properties that match all active filters are shown. Filters are combined with AND logic — every property must satisfy every filter.',
'England. The core datasets — Land Registry prices, EPC energy certificates, deprivation indices, crime statistics, school ratings, broadband speeds, noise mapping, and council tax — all cover England. Points of interest from OpenStreetMap cover Great Britain, but the property-level data is England only.',
},
{
question: 'What does the eye icon do on a filter?',
question: 'Why is data missing for my property?',
answer:
"The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.",
'There are a few common reasons. If a property has never been sold (or was last sold before Land Registry digital records began in 1995), there will be no price record. EPC data may be missing if the property has never had an energy assessment, or if the owner has opted out of public disclosure. Floor area, number of rooms, and energy ratings all come from EPC records, so a missing EPC means those fields will be blank. Finally, the fuzzy address matching between EPC and Land Registry records occasionally fails for unusual addresses.',
},
{
question: 'How fresh is the data?',
question: 'How do I find areas that match what I\'m looking for?',
answer:
'Property prices cover all Land Registry transactions up to the most recent quarterly release. EPC data includes certificates issued up to the latest available download. Crime data spans 20232025 as yearly averages. TfL journey times are computed from current timetables. Deprivation indices are from the 2025 release. School ratings reflect the latest Ofsted inspections as at April 2025. Broadband data is from Ofcom Connected Nations 2025.',
'Use the Filters panel on the left. Add filters for the features you care about — for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source — this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
},
{
question: 'How are EPC records matched to Land Registry sales?',
question: 'How does the travel time feature work?',
answer:
"EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.",
'Click the travel time icon in the filters panel, search for a destination (any address or postcode in England), and choose a transport mode (car, bicycle, walking, or public transport). The map will colour hexagons by average journey time to that destination. You can add a time range filter to only show areas within, say, 30 minutes. Multiple destinations can be added simultaneously to find areas that are well-connected to several places.',
},
{
question: 'What are Points of Interest (POIs)?',
question: 'Can I export the data I\'m looking at?',
answer:
'POIs are places like cafes, schools, supermarkets, GP surgeries, parks, and train stations extracted from OpenStreetMap and the NaPTAN public transport dataset. Use the POI panel on the right to toggle categories on and off. POIs appear as markers on the map when you are zoomed in far enough.',
'Yes. Use the export button to download the currently filtered properties within your map view as an Excel spreadsheet. The export respects all your active filters, so you can narrow down to exactly the properties you want before downloading.',
},
{
question: 'What do the deprivation scores mean?',
answer:
'The English Indices of Deprivation 2025 rank every small area (LSOA, roughly 1,500 people) in England from most to least deprived. A rank of 1 means the most deprived area in the country. The scores cover seven domains: Income, Employment, Education, Health, Crime, Barriers to Housing & Services, and Living Environment. Each domain can be filtered independently. Lower rank numbers indicate higher deprivation.',
},
{
question: 'How reliable is the crime data at this scale?',
answer:
'Crime figures are sourced from data.police.uk and aggregated as yearly averages (2023\u20132025) per LSOA — an area of roughly 1,500 people. This means the numbers reflect a neighbourhood average, not a specific street. Crime counts are broken down by type (violence, burglary, anti-social behaviour, vehicle crime, etc.) so you can filter on the categories that matter to you. As with all area-level statistics, they are useful for comparing neighbourhoods but should not be over-interpreted for individual streets.',
},
{
question: 'What does the school rating represent?',
answer:
'The school rating is the average Ofsted inspection outcome for state-funded schools near each postcode. Ofsted grades schools from 1 (Outstanding) to 4 (Inadequate). A value of 1.5 for a postcode means the nearby schools average between Outstanding and Good. This covers primary and secondary schools with inspection results as at April 2025.',
},
{
question: 'What happens when I zoom in very far?',
answer:
'At lower zoom levels, properties are grouped into hexagons that get smaller as you zoom in. When you zoom past level 15, the map switches from hexagons to individual postcode polygons, showing the actual postcode boundary shapes. Click any postcode polygon to see the properties within it.',
},
{
question: 'Can I share a specific view with someone?',
answer:
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL to your clipboard. Anyone who opens that link will see the same view, filters, and active POI categories.',
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL. Anyone who opens that link will see the same map position, zoom level, filters, pinned feature, and active POI categories.',
},
{
question: 'How do I see individual properties?',
question: 'How can I remove my property from the map?',
answer:
'Click on a hexagon to open the Properties panel on the right. It lists all matching properties within that hexagon, showing address, price, and key features. Use "Load more" at the bottom to paginate through large hexagons.',
'Property sale prices are public records from HM Land Registry and cannot be removed. EPC data (energy rating, floor area, number of rooms, etc.) can be removed by opting out of public disclosure through the government\u2019s official process at gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure. Once opted out, your EPC data will no longer appear in future data updates.',
},
{
question: 'Why are some hexagons grey?',
question: 'How often is the data updated?',
answer:
'Grey hexagons contain properties that have data but fall outside the range of your currently pinned or active feature. This gives you a sense of where properties exist even when their values are outside your selected range.',
},
{
question: 'Does this work on mobile?',
answer:
'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.',
'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source.',
},
];

View file

@ -11,7 +11,7 @@ import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { RouteIcon, PlusIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
@ -22,8 +22,8 @@ interface FeatureBrowserProps {
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
activeTravelModes: TransportMode[];
onEnableTravelMode: (mode: TransportMode) => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
}
export default function FeatureBrowser({
@ -35,8 +35,8 @@ export default function FeatureBrowser({
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
activeTravelModes,
onEnableTravelMode,
travelTimeEntries,
onAddTravelTimeEntry,
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -61,15 +61,9 @@ export default function FeatureBrowser({
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;
// Inactive modes available to add
const inactiveModes = useMemo(
() => TRANSPORT_MODES.filter((m) => !activeTravelModes.includes(m)),
[activeTravelModes]
);
// All modes are always available (can add multiple entries per mode)
const showTravelModes =
inactiveModes.length > 0 &&
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase());
return (
<>
@ -77,21 +71,21 @@ export default function FeatureBrowser({
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{showTravelModes && inactiveModes.map((mode) => (
{showTravelModes && TRANSPORT_MODES.map((mode) => (
<div key={mode} className="shrink-0 border-b border-warm-200 dark:border-warm-700">
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
<div className="flex items-center gap-2 min-w-0" onClick={() => onEnableTravelMode(mode)}>
<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" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time ({MODE_LABELS[mode]})
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Color by journey time to a destination
Filter by journey time to a destination
</span>
</div>
</div>
<IconButton onClick={() => onEnableTravelMode(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
<PlusIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
@ -139,7 +133,7 @@ export default function FeatureBrowser({
</div>
);
})}
{grouped.length === 0 && !showTravelModes ? (
{grouped.length === 0 ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'}

View file

@ -18,9 +18,8 @@ import AiFilterInput from './AiFilterInput';
import FeatureBrowser from './FeatureBrowser';
import { TravelTimeCard } from './TravelTimeCard';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
type TravelTimeEntry,
} from '../../hooks/useTravelTime';
function SliderLabels({
@ -77,12 +76,12 @@ interface FiltersProps {
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntries;
travelTimeDataRanges: Partial<Record<TransportMode, [number, number]>>;
onTravelTimeEnableMode: (mode: TransportMode) => void;
onTravelTimeDisableMode: (mode: TransportMode) => void;
onTravelTimeSetDestination: (mode: TransportMode, lat: number, lon: number, label: string) => void;
onTravelTimeRangeChange: (mode: TransportMode, range: [number, number]) => 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;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterNotes: string | null;
@ -109,8 +108,8 @@ export default memo(function Filters({
onClearOpenInfoFeature,
travelTimeEntries,
travelTimeDataRanges,
onTravelTimeEnableMode,
onTravelTimeDisableMode,
onTravelTimeAddEntry,
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
aiFilterLoading,
@ -156,10 +155,7 @@ export default memo(function Filters({
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const activeModes = useMemo(
() => TRANSPORT_MODES.filter((m) => m in travelTimeEntries),
[travelTimeEntries]
);
const activeEntryCount = travelTimeEntries.length;
const handleAddAndScroll = useCallback(
(name: string) => {
@ -186,7 +182,7 @@ export default memo(function Filters({
}, [features]);
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
const badgeCount = enabledFeatureList.length + activeModes.length + (hasListingFilter ? 1 : 0);
const badgeCount = enabledFeatureList.length + activeEntryCount + (hasListingFilter ? 1 : 0);
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
@ -228,25 +224,22 @@ export default memo(function Filters({
</div>
<div className="md:flex-1 md:overflow-y-auto">
{activeModes.map((mode) => {
const entry = travelTimeEntries[mode]!;
return (
<div key={mode} className="px-2 py-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} className="px-2 py-1">
<TravelTimeCard
mode={mode}
destination={entry.destination}
destinationLabel={entry.destinationLabel}
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
dataRange={travelTimeDataRanges[mode] ?? null}
onSetDestination={(lat, lon, label) => onTravelTimeSetDestination(mode, lat, lon, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(mode, range)}
onRemove={() => onTravelTimeDisableMode(mode)}
dataRange={travelTimeDataRanges.get(index) ?? null}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
);
})}
))}
{enabledFeatureList.length === 0 && activeModes.length === 0 && (
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
Browse features below and click + to add a filter
</p>
@ -378,8 +371,8 @@ export default memo(function Filters({
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
activeTravelModes={activeModes}
onEnableTravelMode={onTravelTimeEnableMode}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onTravelTimeAddEntry}
/>
</div>
</div>

View file

@ -51,7 +51,7 @@ export default function LocationSearch({
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isMobile, search]);
}, [isMobile, search.close]);
// Focus input when expanding on mobile
useEffect(() => {

View file

@ -21,7 +21,7 @@ import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import type { FeatureFilters } from '../../types';
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
import { MODE_LABELS, type TransportMode, type TravelTimeEntries } from '../../hooks/useTravelTime';
import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } from '../../hooks/useTravelTime';
interface MapProps {
data: HexagonData[];
@ -48,10 +48,13 @@ interface MapProps {
onLocationSearched?: (location: SearchedLocation | null) => void;
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_TRAVEL_RANGES = new globalThis.Map<number, [number, number]>();
interface Dimensions {
width: number;
height: number;
@ -102,8 +105,8 @@ export default memo(function Map({
onLocationSearched,
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = {},
travelTimeColorRanges = {},
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
travelTimeColorRanges = EMPTY_TRAVEL_RANGES,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
@ -166,7 +169,7 @@ export default memo(function Map({
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
primaryTravelMode,
primaryTravelIndex,
} = useDeckLayers({
data,
postcodeData,
@ -221,10 +224,10 @@ export default memo(function Map({
<>
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
{!hideLegend &&
(primaryTravelMode && travelTimeColorRanges[primaryTravelMode] ? (
(primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[primaryTravelMode]})`}
range={travelTimeColorRanges[primaryTravelMode]!}
featureLabel={`Travel time (${MODE_LABELS[travelTimeEntries[primaryTravelIndex].mode]})`}
range={travelTimeColorRanges.get(primaryTravelIndex)!}
showCancel={false}
onCancel={onCancelPin}
mode="feature"

View file

@ -23,12 +23,13 @@ import { getTutorialStyles } from '../../lib/tutorial-styles';
import Joyride from 'react-joyride';
import {
useTravelTime,
TRANSPORT_MODES,
MODE_LABELS,
type TransportMode,
travelFieldKey,
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, assertOk, buildFilterString, logNonAbortError } from '../../lib/api';
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
@ -54,6 +55,9 @@ interface MapPageProps {
ogMode?: boolean;
isMobile?: boolean;
initialTravelTime?: TravelTimeInitial;
user?: { id: string; subscription: string } | null;
onLoginClick?: () => void;
onRegisterClick?: () => void;
}
export default function MapPage({
@ -73,6 +77,9 @@ export default function MapPage({
ogMode,
isMobile = false,
initialTravelTime,
user,
onLoginClick,
onRegisterClick,
}: MapPageProps) {
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
@ -125,6 +132,9 @@ export default function MapPage({
// Travel time hook
const travelTime = useTravelTime(initialTravelTime);
// License hook
const license = useLicense();
// Map data hook
const mapData = useMapData({
filters,
@ -164,20 +174,21 @@ export default function MapPage({
// POI data
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Compute data range for travel time slider per mode (full min/max for slider bounds)
const travelTimeDataRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
for (const mode of TRANSPORT_MODES) {
const entry = travelTime.entries[mode];
if (!entry?.destination) continue;
// Compute data range for travel time slider per entry index (full min/max for slider bounds)
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[`travel_time_${mode}`];
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[mode] = [vals[0], vals[vals.length - 1]];
ranges.set(i, [vals[0], vals[vals.length - 1]]);
}
return ranges;
}, [travelTime.entries, mapData.data]);
@ -253,7 +264,7 @@ export default function MapPage({
const url = apiUrl('export', params);
setExporting(true);
fetch(url)
fetch(url, authHeaders())
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
@ -397,8 +408,8 @@ export default function MapPage({
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeEnableMode={travelTime.handleEnableMode}
onTravelTimeDisableMode={travelTime.handleDisableMode}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={travelTime.handleRemoveEntry}
onTravelTimeSetDestination={travelTime.handleSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
aiFilterLoading={aiFilters.loading}
@ -478,14 +489,14 @@ export default function MapPage({
>
{/* Legend */}
{(() => {
const primaryMode = TRANSPORT_MODES.find(
(m) => travelTime.entries[m]?.destination && mapData.travelTimeColorRanges[m]
const primaryIdx = travelTime.entries.findIndex(
(e, i) => e.slug && mapData.travelTimeColorRanges.get(i)
);
if (primaryMode) {
if (primaryIdx >= 0) {
return (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[primaryMode]})`}
range={mapData.travelTimeColorRanges[primaryMode]!}
featureLabel={`Travel time (${MODE_LABELS[travelTime.entries[primaryIdx].mode]})`}
range={mapData.travelTimeColorRanges.get(primaryIdx)!}
showCancel={false}
onCancel={handleCancelPin}
mode="feature"
@ -539,6 +550,16 @@ export default function MapPage({
renderProperties={renderPropertiesPane}
/>
)}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
/>
)}
</div>
);
}
@ -664,6 +685,16 @@ export default function MapPage({
</div>
</div>
</div>
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
/>
)}
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useRef, useEffect, useCallback } from 'react';
import { Slider } from '../ui/Slider';
import { IconButton } from '../ui/IconButton';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
@ -6,34 +6,31 @@ import { CloseIcon } from '../ui/icons/CloseIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { formatFilterValue } from '../../lib/format';
import { authHeaders, logNonAbortError } from '../../lib/api';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
interface TravelTimeCardProps {
mode: TransportMode;
destination: [number, number] | null;
destinationLabel: string;
slug: string;
label: string;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
onSetDestination: (lat: number, lon: number, label: string) => void;
onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void;
onRemove: () => void;
}
export function TravelTimeCard({
mode,
destination,
destinationLabel,
slug,
label,
timeRange,
dataRange,
onSetDestination,
onTimeRangeChange,
onRemove,
}: TravelTimeCardProps) {
const search = useLocationSearch();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = useLocationSearch(mode);
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
@ -45,42 +42,16 @@ export function TravelTimeCard({
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [search]);
}, [search.close]);
const selectResult = useCallback(
async (result: SearchResult) => {
(result: SearchResult) => {
if (result.type === 'place') {
onSetDestination(result.lat, result.lon, result.name);
onSetDestination(result.slug, result.name);
search.clear();
setError(null);
return;
}
// Postcode — fetch coordinates
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders(),
);
if (!res.ok) {
setError('Postcode not found');
return;
}
const json: { postcode: string; latitude: number; longitude: number } =
await res.json();
onSetDestination(json.latitude, json.longitude, json.postcode);
search.clear();
} catch (err) {
logNonAbortError('Postcode lookup failed', err);
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[onSetDestination, search],
[onSetDestination, search.clear],
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
@ -107,28 +78,23 @@ export function TravelTimeCard({
<PlaceSearchInput
search={search}
onSelect={selectResult}
loading={loading}
placeholder={destination ? 'Change destination...' : 'Search destination...'}
placeholder={slug ? 'Change destination...' : 'Search destination...'}
size="xs"
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
onInputChange={() => setError(null)}
/>
{error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</p>
)}
{destination && destinationLabel && (
{slug && label && (
<div className="flex items-center gap-1 mt-1">
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
<span className="text-xs text-warm-600 dark:text-warm-300">
{destinationLabel}
{label}
</span>
</div>
)}
</div>
{/* Time range slider — only show when we have data */}
{destination && dataRange && (
{slug && dataRange && (
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time

View file

@ -1,4 +1,9 @@
import { useState, useEffect } from 'react';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { AuthUser } from '../../hooks/useAuth';
import { useLicense } from '../../hooks/useLicense';
import { apiUrl } from '../../lib/api';
const FEATURES = [
'56 data layers across England',
@ -9,20 +14,82 @@ const FEATURES = [
'All future data updates included',
];
interface PricingTier {
up_to: number | null;
price_pence: number;
slots: number;
}
interface PricingData {
licensed_count: number;
current_price_pence: number;
tiers: PricingTier[];
}
function formatPrice(pence: number): string {
if (pence === 0) return 'Free';
return `\u00A3${pence / 100}`;
}
function tierLabel(tier: PricingTier, index: number): string {
if (index === 0) return `First ${tier.slots} users`;
if (tier.up_to === null) return 'Everyone after';
return `Next ${tier.slots} users`;
}
export default function PricingPage({
onOpenDashboard,
user,
onLoginClick,
onRegisterClick,
}: {
onOpenDashboard: () => void;
user?: AuthUser | null;
onLoginClick?: () => void;
onRegisterClick?: () => void;
}) {
const license = useLicense();
const [pricing, setPricing] = useState<PricingData | null>(null);
useEffect(() => {
fetch(apiUrl('pricing'))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setPricing)
.catch((err) => console.error('Failed to load pricing:', err));
}, []);
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
const currentPrice = pricing?.current_price_pence ?? 10000;
const isFree = currentPrice === 0;
// Find current tier index and remaining spots
let currentTierIndex = (pricing?.tiers.length ?? 1) - 1;
let spotsRemaining = 0;
if (pricing) {
for (let i = 0; i < pricing.tiers.length; i++) {
const tier = pricing.tiers[i];
if (tier.up_to === null || pricing.licensed_count < tier.up_to) {
currentTierIndex = i;
spotsRemaining =
tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
break;
}
}
}
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-3xl mx-auto px-6 py-16">
<div className="text-center mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-warm-100 mb-3">
One price. Yours forever.
Early access pricing
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime access to every feature.
No subscriptions, no recurring fees. Pay once and get lifetime
access to every feature. The earlier you join, the less you pay.
</p>
</div>
@ -33,33 +100,147 @@ export default function PricingPage({
Lifetime License
</div>
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl font-extrabold text-white">£100</span>
<span className="text-warm-400 text-lg">/once</span>
<span className="text-5xl font-extrabold text-white">
{pricing ? formatPrice(currentPrice) : '...'}
</span>
{!isFree && (
<span className="text-warm-400 text-lg">/once</span>
)}
</div>
<p className="text-warm-300 text-sm mt-2">
One-time payment, no subscription
{spotsRemaining > 0 && pricing && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining} spot{spotsRemaining !== 1 ? 's' : ''}{' '}
remaining at this price
</p>
)}
<p className="text-warm-300 text-sm mt-1">
{isFree
? 'Free for early adopters'
: 'One-time payment, no subscription'}
</p>
</div>
{/* Features list */}
<div className="px-8 py-8">
{/* Tier breakdown */}
{pricing && (
<div className="mb-8 space-y-1.5">
<p className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
Pricing tiers
</p>
{pricing.tiers.map((tier, i) => {
const isCurrent = i === currentTierIndex;
const isFilled =
tier.up_to !== null &&
pricing.licensed_count >= tier.up_to;
const filledInTier = isCurrent
? pricing.licensed_count -
(i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
: 0;
const tierSlots = tier.slots;
const fillPercent = isFilled
? 100
: isCurrent && tierSlots > 0
? (filledInTier / tierSlots) * 100
: 0;
return (
<div
key={i}
className={`relative flex items-center justify-between px-3 py-2 rounded-lg text-sm ${
isCurrent
? 'bg-teal-50 dark:bg-teal-900/30 ring-1 ring-teal-400'
: isFilled
? 'opacity-50'
: ''
}`}
>
<span
className={`${isCurrent ? 'text-navy-950 dark:text-warm-100 font-medium' : 'text-warm-600 dark:text-warm-400'}`}
>
{tierLabel(tier, i)}
</span>
<div className="flex items-center gap-2">
{isCurrent && tierSlots > 0 && (
<div className="w-16 h-1.5 rounded-full bg-warm-200 dark:bg-warm-700 overflow-hidden">
<div
className="h-full rounded-full bg-teal-500"
style={{ width: `${fillPercent}%` }}
/>
</div>
)}
<span
className={`font-semibold ${
isCurrent
? 'text-teal-700 dark:text-teal-400'
: isFilled
? 'text-warm-400 dark:text-warm-500 line-through'
: 'text-warm-600 dark:text-warm-400'
}`}
>
{formatPrice(tier.price_pence)}
</span>
{isFilled && (
<CheckIcon className="w-4 h-4 text-warm-400 dark:text-warm-500" />
)}
</div>
</div>
);
})}
</div>
)}
{/* Features list */}
<ul className="space-y-4">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-3">
<CheckIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feature}</span>
<span className="text-warm-700 dark:text-warm-300">
{feature}
</span>
</li>
))}
</ul>
<button
onClick={onOpenDashboard}
className="w-full mt-8 px-6 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"
>
Get started
</button>
{isLicensed ? (
<button
onClick={onOpenDashboard}
className="w-full mt-8 px-6 py-4 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
>
Open dashboard
</button>
) : user ? (
<button
onClick={() => license.startCheckout()}
disabled={license.checkingOut}
className="w-full mt-8 px-6 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 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{license.checkingOut && (
<SpinnerIcon className="w-5 h-5 animate-spin" />
)}
{license.checkingOut
? 'Redirecting...'
: isFree
? 'Claim free license'
: `Get started \u2014 ${formatPrice(currentPrice)}`}
</button>
) : (
<button
onClick={onRegisterClick}
className="w-full mt-8 px-6 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"
>
{isFree ? 'Claim free license' : 'Get started'}
</button>
)}
{license.error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">
{license.error}
</p>
)}
<p className="text-center text-sm text-warm-400 dark:text-warm-500 mt-3">
30-day money-back guarantee
{isFree
? 'No credit card required'
: '30-day money-back guarantee'}
</p>
</div>
</div>

View file

@ -0,0 +1,99 @@
import { useState, useCallback } from 'react';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
const FAQ_ITEMS = [
{
question: 'What data is included?',
answer:
'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.',
},
{
question: 'What can I access on the free tier?',
answer:
'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need a lifetime license.',
},
{
question: 'What does "lifetime" mean?',
answer:
'Your license never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.',
},
{
question: 'Can I get a refund?',
answer:
'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@propertymap.co.uk within 30 days of purchase for a full refund.',
},
{
question: 'How often is the data updated?',
answer:
'We update the data regularly as new Land Registry, EPC, crime, and other government datasets are published. Updates are typically quarterly. All updates are included with your license at no extra cost.',
},
];
function FAQItem({ question, answer }: { question: string; answer: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-warm-200 dark:border-warm-700 last:border-b-0">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between px-5 py-4 text-left"
>
<span className="text-navy-950 dark:text-warm-100 font-medium pr-4">{question}</span>
<ChevronIcon
direction="down"
className={`w-5 h-5 text-warm-400 dark:text-warm-500 shrink-0 transition-transform ${
open ? 'rotate-180' : ''
}`}
/>
</button>
{open && (
<div className="px-5 pb-4">
<p className="text-warm-600 dark:text-warm-300 text-sm leading-relaxed">{answer}</p>
</div>
)}
</div>
);
}
export default function SupportPage() {
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-2xl mx-auto px-6 py-16">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
Support & FAQ
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400">
Have a question? Check below or reach out to us directly.
</p>
</div>
{/* Contact */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 mb-8 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>
{/* FAQ */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
<div className="px-5 py-4 border-b border-warm-200 dark:border-warm-700">
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
Frequently Asked Questions
</h2>
</div>
{FAQ_ITEMS.map((item) => (
<FAQItem key={item.question} question={item.question} answer={item.answer} />
))}
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,7 @@
import { useState, useCallback } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { AppleIcon } from './icons/AppleIcon';
type View = 'login' | 'register' | 'forgot';
@ -7,6 +9,7 @@ export default function AuthModal({
onClose,
onLogin,
onRegister,
onOAuthLogin,
onForgotPassword,
loading,
error,
@ -16,6 +19,7 @@ export default function AuthModal({
onClose: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string) => Promise<void>;
onOAuthLogin: (provider: string) => Promise<void>;
onForgotPassword: (email: string) => Promise<void>;
loading: boolean;
error: string | null;
@ -57,6 +61,18 @@ export default function AuthModal({
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
);
const handleOAuth = useCallback(
async (provider: string) => {
try {
await onOAuthLogin(provider);
onClose();
} catch {
// Error is handled by the hook
}
},
[onOAuthLogin, onClose]
);
const title =
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
@ -104,82 +120,117 @@ export default function AuthModal({
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
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="you@example.com"
/>
</div>
<div className="p-5 space-y-4">
{/* OAuth buttons (hidden in forgot view) */}
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
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={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<>
<div className="space-y-2">
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
onClick={() => handleOAuth('google')}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-100 text-sm font-medium hover:bg-warm-50 dark:hover:bg-warm-700 disabled:opacity-50 disabled:cursor-wait"
>
Forgot password?
<GoogleIcon className="w-4 h-4" />
Continue with Google
</button>
)}
<button
type="button"
onClick={() => handleOAuth('apple')}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded bg-navy-950 dark:bg-white text-white dark:text-navy-950 text-sm font-medium hover:bg-navy-900 dark:hover:bg-warm-100 disabled:opacity-50 disabled:cursor-wait"
>
<AppleIcon className="w-4 h-4" />
Continue with Apple
</button>
</div>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
<span className="text-xs text-warm-400 dark:text-warm-500">or</span>
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
</div>
</>
)}
{/* Email form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
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="you@example.com"
/>
</div>
)}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
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={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Forgot password?
</button>
)}
</div>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
</div>
</div>
</div>
);

View file

@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account' | 'invite' | 'support';
export default function Header({
activePage,
@ -139,6 +139,9 @@ export default function Header({
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
<button className={tabClass('support')} onClick={() => onPageChange('support')}>
Support
</button>
</nav>
)}
</div>

View file

@ -0,0 +1,92 @@
import { useEffect, useMemo } from 'react';
interface LicenseSuccessModalProps {
onClose: () => void;
}
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
// Generate confetti particles once
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
id: i,
left: Math.random() * 100,
delay: Math.random() * 2,
duration: 2 + Math.random() * 2,
color: ['#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'][
Math.floor(Math.random() * 6)
],
size: 6 + Math.random() * 6,
})),
[]
);
// Auto-dismiss after 8 seconds
useEffect(() => {
const timer = setTimeout(onClose, 8000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
{/* Confetti */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<div
key={p.id}
className="absolute animate-confetti"
style={{
left: `${p.left}%`,
top: '-10px',
width: `${p.size}px`,
height: `${p.size}px`,
backgroundColor: p.color,
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
/>
))}
</div>
{/* Card */}
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
<div className="text-5xl mb-3">🎉</div>
<h2 className="text-2xl font-bold text-white">Welcome aboard!</h2>
<p className="text-warm-300 text-sm mt-2">
Your lifetime license is now active.
</p>
</div>
<div className="px-6 py-6">
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
You now have full access to every feature across all of England. Happy exploring!
</p>
<button
onClick={onClose}
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
>
Start exploring
</button>
</div>
</div>
{/* CSS animation for confetti */}
<style>{`
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-confetti {
animation: confetti-fall linear forwards;
}
`}</style>
</div>
);
}

View file

@ -83,6 +83,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('pricing', 'Pricing')}
{mobileNavItem('support', 'Support')}
{user && mobileNavItem('account', 'Account')}
{/* Dashboard actions */}

View file

@ -0,0 +1,133 @@
import { useState, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { apiUrl } from '../../lib/api';
interface UpgradeModalProps {
isLoggedIn: boolean;
onLoginClick: () => void;
onRegisterClick: () => void;
onStartCheckout: () => Promise<void>;
onDismiss: () => void;
}
export default function UpgradeModal({
isLoggedIn,
onLoginClick,
onRegisterClick,
onStartCheckout,
onDismiss,
}: UpgradeModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pricePence, setPricePence] = useState<number | null>(null);
useEffect(() => {
fetch(apiUrl('pricing'))
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) setPricePence(data.current_price_pence);
})
.catch(() => {});
}, []);
const priceLabel =
pricePence === null
? '...'
: pricePence === 0
? 'Free'
: `\u00A3${pricePence / 100}`;
const isFree = pricePence === 0;
const handleUpgrade = async () => {
setLoading(true);
setError(null);
try {
await onStartCheckout();
} catch (err) {
setError(err instanceof Error ? err.message : 'Checkout failed');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
{/* Close button */}
<button
onClick={onDismiss}
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
</button>
{/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">Unlock the full map</h2>
<p className="text-warm-300 text-sm">
Free users can explore inner London. Upgrade for lifetime access to all of England.
</p>
</div>
{/* Body */}
<div className="px-6 py-6">
<div className="flex items-baseline justify-center gap-1 mb-4">
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
{priceLabel}
</span>
{!isFree && (
<span className="text-warm-500 dark:text-warm-400 text-lg">/once</span>
)}
</div>
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
{isFree
? 'Free for early adopters. No credit card required.'
: 'One-time payment. Lifetime access. 30-day money-back guarantee.'}
</p>
{isLoggedIn ? (
<button
onClick={handleUpgrade}
disabled={loading}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{loading
? 'Redirecting...'
: isFree
? 'Claim free license'
: `Upgrade for ${priceLabel}`}
</button>
) : (
<div className="flex flex-col gap-3">
<button
onClick={onRegisterClick}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Register & Upgrade
</button>
<button
onClick={onLoginClick}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Already have an account? Log in
</button>
</div>
)}
{error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
onClick={onDismiss}
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
>
Or zoom back into London
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useState, useCallback } from 'react';
import { SpinnerIcon } from './icons/SpinnerIcon';
export default function VerificationBanner({
email,
onRequestVerification,
onDismiss,
}: {
email: string;
onRequestVerification: (email: string) => Promise<void>;
onDismiss: () => void;
}) {
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const handleResend = useCallback(async () => {
setSending(true);
try {
await onRequestVerification(email);
setSent(true);
setTimeout(() => setSent(false), 3000);
} catch {
// Error handled by hook
} finally {
setSending(false);
}
}, [email, onRequestVerification]);
return (
<div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-2.5 flex items-center justify-between gap-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
Please verify your email address. Check your inbox.
</p>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleResend}
disabled={sending || sent}
className="text-sm font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100 disabled:opacity-50 flex items-center gap-1"
>
{sending && <SpinnerIcon className="w-3.5 h-3.5 animate-spin" />}
{sent ? 'Sent!' : 'Resend'}
</button>
<button
onClick={onDismiss}
className="text-amber-400 dark:text-amber-600 hover:text-amber-600 dark:hover:text-amber-400 text-lg leading-none"
aria-label="Dismiss"
>
&times;
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function AppleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
);
}

View file

@ -0,0 +1,26 @@
interface IconProps {
className?: string;
}
export function GoogleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}

View file

@ -117,7 +117,7 @@ export function useAreaSummary({
return () => {
abortRef.current?.abort();
};
}, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps
}, [fetchSummary]);
return { summary, loading, error };
}

View file

@ -7,6 +7,7 @@ export interface AuthUser {
verified: boolean;
isAdmin: boolean;
subscription: string;
newsletter: boolean;
}
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
@ -19,6 +20,7 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
verified: typeof record.verified === 'boolean' ? record.verified : false,
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
};
}
@ -115,8 +117,32 @@ export function useAuth() {
}, []);
const refreshAuth = useCallback(async () => {
const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record));
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Auth refresh failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const requestVerification = useCallback(async (email: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').requestVerification(email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Verification request failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const clearError = useCallback(() => {
@ -132,6 +158,7 @@ export function useAuth() {
loginWithOAuth,
logout,
requestPasswordReset,
requestVerification,
refreshAuth,
clearError,
};

View file

@ -1,6 +1,6 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
@ -14,9 +14,8 @@ import type {
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
type TravelTimeEntry,
travelFieldKey,
} from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
@ -46,8 +45,8 @@ interface UseDeckLayersProps {
theme: 'light' | 'dark';
selectedPostcodeGeometry?: PostcodeGeometry | null;
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
export interface PopupInfo {
@ -58,15 +57,15 @@ export interface PopupInfo {
id: string;
}
/** Find the primary travel mode: first mode (in canonical order) with a destination and color range. */
function getPrimaryTravelMode(
entries: TravelTimeEntries,
colorRanges: Partial<Record<TransportMode, [number, number]>>
): TransportMode | null {
for (const mode of TRANSPORT_MODES) {
if (entries[mode]?.destination && colorRanges[mode]) return mode;
/** Find the primary travel time entry: first entry with a slug and color range. */
function getPrimaryTravelIndex(
entries: TravelTimeEntry[],
colorRanges: Map<number, [number, number]>
): number {
for (let i = 0; i < entries.length; i++) {
if (entries[i].slug && colorRanges.has(i)) return i;
}
return null;
return -1;
}
export function useDeckLayers({
@ -85,8 +84,8 @@ export function useDeckLayers({
theme,
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEntries = {},
travelTimeColorRanges = {},
travelTimeEntries = [],
travelTimeColorRanges = new Map(),
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -105,7 +104,7 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
// --- Refs for deck.gl accessors (avoid re-creating layers on every change) ---
// --- Refs for deck.gl accessors ---
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const colorRangeRef = useRef(colorRange);
@ -128,12 +127,12 @@ export function useDeckLayers({
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
travelTimeColorRangesRef.current = travelTimeColorRanges;
const primaryTravelMode = useMemo(
() => getPrimaryTravelMode(travelTimeEntries, travelTimeColorRanges),
const primaryTravelIndex = useMemo(
() => getPrimaryTravelIndex(travelTimeEntries, travelTimeColorRanges),
[travelTimeEntries, travelTimeColorRanges]
);
const primaryTravelModeRef = useRef(primaryTravelMode);
primaryTravelModeRef.current = primaryTravelMode;
const primaryTravelIndexRef = useRef(primaryTravelIndex);
primaryTravelIndexRef.current = primaryTravelIndex;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -260,13 +259,12 @@ export function useDeckLayers({
}, []);
// --- Color triggers ---
// Build travel time trigger from all entries
const ttTrigger = useMemo(() => {
const parts: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
const cr = travelTimeColorRanges[mode];
parts.push(`${mode}:${entry?.destination?.[0]}|${entry?.destination?.[1]}|${cr?.[0]}|${cr?.[1]}|${entry?.timeRange?.[0]}|${entry?.timeRange?.[1]}`);
for (let i = 0; i < travelTimeEntries.length; i++) {
const entry = travelTimeEntries[i];
const cr = travelTimeColorRanges.get(i);
parts.push(`${i}:${entry.slug}|${cr?.[0]}|${cr?.[1]}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
}
return parts.join(';');
}, [travelTimeEntries, travelTimeColorRanges]);
@ -283,23 +281,26 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const pm = primaryTravelModeRef.current;
const pti = primaryTravelIndexRef.current;
const entries = travelTimeEntriesRef.current;
const colorRanges = travelTimeColorRangesRef.current;
// Travel time coloring: primary mode colors, others dim-filter
if (pm) {
const ttVal = d[`travel_time_${pm}`];
const ttClr = colorRanges[pm];
// Travel time coloring: primary entry colors, others dim-filter
if (pti >= 0) {
const primaryEntry = entries[pti];
const fieldKey = travelFieldKey(primaryEntry);
const ttVal = d[`avg_${fieldKey}`];
const ttClr = colorRanges.get(pti);
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
// Check all modes with time ranges as filters (including primary)
for (const mode of TRANSPORT_MODES) {
const entry = entries[mode];
if (!entry?.timeRange) continue;
const modeVal = d[`travel_time_${mode}`];
// Check all entries with time ranges as filters
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
const fk = travelFieldKey(entry);
const modeVal = d[`avg_${fk}`];
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
}
@ -504,7 +505,7 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
// Marching ants highlight layer for selected postcode (click or search)
// Marching ants highlight layer for selected postcode
const marchingAntsLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null;
return new GeoJsonLayer({
@ -527,42 +528,12 @@ export function useDeckLayers({
});
}, [selectedPostcodeGeometry, marchTime]);
// Destination markers: one red dot per mode with a destination
const destinationMarkerData = useMemo(() => {
const points: { position: [number, number] }[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
points.push({ position: [entry.destination[1], entry.destination[0]] });
}
}
return points;
}, [travelTimeEntries]);
const destinationMarkerLayer = useMemo(() => {
if (destinationMarkerData.length === 0) return null;
return new ScatterplotLayer({
id: 'travel-time-destinations',
data: destinationMarkerData,
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 8,
getFillColor: [239, 68, 68, 220],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
lineWidthUnits: 'pixels' as const,
radiusUnits: 'pixels' as const,
stroked: true,
pickable: false,
});
}, [destinationMarkerData]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
return baseLayers;
}, [
usePostcodeView,
@ -571,7 +542,6 @@ export function useDeckLayers({
postcodeLabelsLayer,
poiLayer,
marchingAntsLayer,
destinationMarkerLayer,
]);
const handleMouseLeave = useCallback(() => {
@ -590,6 +560,6 @@ export function useDeckLayers({
colorFeatureMeta,
handleMouseLeave,
hoveredPostcode,
primaryTravelMode,
primaryTravelIndex,
};
}

View file

@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../lib/api';
export function useLicense() {
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
const startCheckout = useCallback(async (referralCode?: string) => {
setCheckingOut(true);
setError(null);
try {
const body: Record<string, string> = {};
if (referralCode) body.referral_code = referralCode;
const res = await fetch(apiUrl('checkout'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
});
assertOk(res, 'Checkout');
const data = await res.json();
if (data.url) {
window.location.href = data.url;
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Checkout failed';
setError(msg);
throw err;
} finally {
setCheckingOut(false);
}
}, []);
return { startCheckout, checkingOut, error };
}

View file

@ -10,9 +10,9 @@ export function looksLikePostcode(s: string) {
export type SearchResult =
| { type: 'postcode'; label: string }
| { type: 'place'; name: string; place_type: string; lat: number; lon: number; city?: string };
| { type: 'place'; name: string; slug: string; place_type: string; lat: number; lon: number; city?: string };
export function useLocationSearch() {
export function useLocationSearch(mode?: string) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
@ -34,7 +34,7 @@ export function useLocationSearch() {
return;
}
if (looksLikePostcode(trimmed)) {
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: trimmed.toUpperCase() }]);
setOpen(true);
return;
@ -51,6 +51,7 @@ export function useLocationSearch() {
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed, limit: '7' });
if (mode) params.set('mode', mode);
const res = await fetch(
`/api/places?${params}`,
authHeaders({ signal: controller.signal }),
@ -59,7 +60,12 @@ export function useLocationSearch() {
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
type: 'place' as const,
...p,
name: p.name,
slug: p.slug,
place_type: p.place_type,
lat: p.lat,
lon: p.lon,
city: p.city,
}));
setResults(placeResults);
setOpen(placeResults.length > 0);
@ -67,7 +73,7 @@ export function useLocationSearch() {
logNonAbortError('places search', err);
}
}, 200);
}, []);
}, [mode]);
const close = useCallback(() => setOpen(false), []);

View file

@ -8,10 +8,10 @@ import type {
ViewChangeParams,
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { TRANSPORT_MODES, type TransportMode, type TravelTimeEntries } from './useTravelTime';
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -33,7 +33,7 @@ interface UseMapDataOptions {
activeFeature: string | null;
dragValue: [number, number] | null;
dragData: HexagonData[] | null;
travelTimeEntries: TravelTimeEntries;
travelTimeEntries: TravelTimeEntry[];
}
export function useMapData({
@ -56,6 +56,8 @@ export function useMapData({
longitude: number;
zoom: number;
} | null>(null);
const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@ -69,13 +71,16 @@ export function useMapData({
);
// Build the travel param string from entries with destinations
// Format: mode:slug|mode:slug or mode:slug:min:max|mode:slug
const travelParam = useMemo((): string => {
const segments: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
segments.push(`${entry.destination[0]},${entry.destination[1]},${mode}`);
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(seg);
}
return segments.join('|');
}, [travelTimeEntries]);
@ -109,7 +114,16 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
return;
}
}
assertOk(res, 'postcodes');
setLicenseRequired(false);
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features);
setRawData([]);
@ -129,13 +143,22 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
return;
}
}
assertOk(res, 'hexagons');
setLicenseRequired(false);
const json: ApiResponse = await res.json();
setRawData(json.features);
setPostcodeData([]);
}
} catch (err) {
logNonAbortError('Failed to fetch data', err);
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
} finally {
setLoading(false);
}
@ -151,7 +174,6 @@ export function useMapData({
const data = dragData ?? rawData;
// Compute p5/p95 from visible data for the viewed feature
// Only considers hexagons/postcodes whose center falls within the viewport bounds
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
@ -207,13 +229,13 @@ export function useMapData({
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
// Color ranges for travel time per mode (computed from response data)
const travelTimeColorRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (!entry?.destination) continue;
const fieldName = `travel_time_${mode}`;
// Color ranges for travel time per entry (computed from response data)
const travelTimeColorRanges = useMemo((): Map<number, [number, number]> => {
const ranges = new Map<number, [number, number]>();
for (let i = 0; i < travelTimeEntries.length; i++) {
const entry = travelTimeEntries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
@ -226,10 +248,10 @@ export function useMapData({
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges[mode] = [
ranges.set(i, [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
]);
}
return ranges;
}, [travelTimeEntries, data, bounds]);
@ -276,5 +298,7 @@ export function useMapData({
travelTimeColorRanges,
handleViewChange,
setInitialView,
licenseRequired,
freeZone,
};
}

View file

@ -38,6 +38,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
signal: abortControllerRef.current.signal,
})
);
if (!res.ok) throw new Error(`POIs fetch failed: HTTP ${res.status}`);
const json: POIResponse = await res.json();
setPois(json.pois || []);
} catch (err) {

View file

@ -12,71 +12,73 @@ export const MODE_LABELS: Record<TransportMode, string> = {
};
export interface TravelTimeEntry {
destination: [number, number] | null; // [lat, lon]
destinationLabel: string;
mode: TransportMode;
slug: string;
label: string;
timeRange: [number, number] | null;
}
export type TravelTimeEntries = Partial<Record<TransportMode, TravelTimeEntry>>;
/** Unique key for a travel time entry */
export function travelEntryKey(entry: TravelTimeEntry): string {
return `${entry.mode}:${entry.slug}`;
}
/** Field key matching the backend response: tt_{mode}_{slug} */
export function travelFieldKey(entry: TravelTimeEntry): string {
return `tt_${entry.mode}_${entry.slug}`;
}
export interface TravelTimeInitial {
entries?: TravelTimeEntries;
entries?: TravelTimeEntry[];
}
export function useTravelTime(initial?: TravelTimeInitial) {
const [entries, setEntries] = useState<TravelTimeEntries>(initial?.entries ?? {});
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
const activeModes = useMemo(
() => TRANSPORT_MODES.filter((m) => m in entries),
[entries]
);
const modesWithDestination = useMemo(
() => TRANSPORT_MODES.filter((m) => entries[m]?.destination != null),
[entries]
);
const handleEnableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => ({
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
[mode]: { destination: null, destinationLabel: '', timeRange: null },
}));
{ mode, slug: '', label: '', timeRange: null },
]);
}, []);
const handleDisableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => {
const next = { ...prev };
delete next[mode];
return next;
});
const handleRemoveEntry = useCallback((index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleSetDestination = useCallback(
(mode: TransportMode, lat: number, lon: number, label: string) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], destination: [lat, lon] as [number, number], destinationLabel: label },
}));
(index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: null } : entry
)
);
},
[]
);
const handleTimeRangeChange = useCallback(
(mode: TransportMode, range: [number, number]) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], timeRange: range },
}));
(index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, timeRange: range } : entry
)
);
},
[]
);
/** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''),
[entries]
);
return {
entries,
activeModes,
modesWithDestination,
handleEnableMode,
handleDisableMode,
activeEntries,
handleAddEntry,
handleRemoveEntry,
handleSetDestination,
handleTimeRangeChange,
};

View file

@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state';
import type { TravelTimeEntries } from './useTravelTime';
import type { TravelTimeEntry } from './useTravelTime';
const URL_DEBOUNCE_MS = 300;
@ -11,7 +11,7 @@ export function useUrlSync(
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntries
travelTimeEntries?: TravelTimeEntry[]
) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View file

@ -2,7 +2,7 @@ import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
type TravelTimeEntry,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
@ -70,32 +70,31 @@ export function parseUrlState(): {
result.tab = tab;
}
// Travel time: per-mode params (tt_car=lat,lon ttl_car=label ttr_car=min:max)
const entries: TravelTimeEntries = {};
for (const mode of TRANSPORT_MODES) {
const dest = params.get(`tt_${mode}`);
if (dest) {
const parts = dest.split(',').map(Number);
if (parts.length === 2 && parts.every((n) => !isNaN(n))) {
const label = params.get(`ttl_${mode}`) || '';
let timeRange: [number, number] | null = null;
const rangeStr = params.get(`ttr_${mode}`);
if (rangeStr) {
const [min, max] = rangeStr.split(':').map(Number);
if (!isNaN(min) && !isNaN(max)) {
timeRange = [min, max];
}
// Travel time: repeated `tt` params
// Format: mode:slug:label or mode:slug:label:min:max
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const entries: TravelTimeEntry[] = [];
for (const tt of ttParams) {
const parts = tt.split(':');
if (parts.length < 3) continue;
const mode = parts[0] as TransportMode;
if (!TRANSPORT_MODES.includes(mode)) continue;
const slug = parts[1];
const label = decodeURIComponent(parts[2]);
let timeRange: [number, number] | null = null;
if (parts.length >= 5) {
const min = Number(parts[3]);
const max = Number(parts[4]);
if (!isNaN(min) && !isNaN(max)) {
timeRange = [min, max];
}
entries[mode] = {
destination: [parts[0], parts[1]],
destinationLabel: label,
timeRange,
};
}
entries.push({ mode, slug, label, timeRange });
}
if (entries.length > 0) {
result.travelTime = { entries };
}
}
if (Object.keys(entries).length > 0) {
result.travelTime = { entries };
}
return result;
@ -107,7 +106,7 @@ export function stateToParams(
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntries
travelTimeEntries?: TravelTimeEntry[]
): URLSearchParams {
const params = new URLSearchParams();
@ -135,18 +134,15 @@ export function stateToParams(
params.set('tab', 'properties');
}
// Travel time: per-mode params
// Travel time: repeated `tt` params
if (travelTimeEntries) {
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (!entry?.destination) continue;
params.set(`tt_${mode}`, `${entry.destination[0].toFixed(5)},${entry.destination[1].toFixed(5)}`);
if (entry.destinationLabel) {
params.set(`ttl_${mode}`, entry.destinationLabel);
}
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.timeRange) {
params.set(`ttr_${mode}`, `${entry.timeRange[0]}:${entry.timeRange[1]}`);
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
params.append('tt', val);
}
}

View file

@ -102,6 +102,7 @@ export interface POICategoriesResponse {
export interface PlaceResult {
name: string;
slug: string;
place_type: string;
lat: number;
lon: number;

View file

@ -85,6 +85,12 @@ module.exports = (env, argv) => {
},
hot: true,
liveReload: true,
static: {
directory: path.resolve(__dirname, 'public'),
watch: {
ignored: ['**/assets/**'],
},
},
proxy: [
{
context: ['/api'],

View file

@ -12,7 +12,7 @@ import osmium
import polars as pl
from tqdm import tqdm
from .pois import UK_BBOX_EAST, UK_BBOX_NORTH, UK_BBOX_SOUTH, UK_BBOX_WEST, download_pbf
from .pois import UK_BBOX_EAST, UK_BBOX_NORTH, UK_BBOX_SOUTH, UK_BBOX_WEST
PLACE_TYPES = {
"city",
@ -74,9 +74,17 @@ class PlaceHandler(osmium.SimpleHandler):
self._progress = progress
self.places: list[dict] = []
def _add(self, name: str, place_type: str, lat: float, lon: float, population: int) -> None:
def _add(
self, name: str, place_type: str, lat: float, lon: float, population: int
) -> None:
self.places.append(
{"name": name, "place_type": place_type, "lat": lat, "lon": lon, "population": population}
{
"name": name,
"place_type": place_type,
"lat": lat,
"lon": lon,
"population": population,
}
)
self._progress.set_postfix(places=f"{len(self.places):,}", refresh=False)
@ -85,7 +93,10 @@ class PlaceHandler(osmium.SimpleHandler):
if not n.location.valid:
return
lat, lon = n.location.lat, n.location.lon
if not (UK_BBOX_SOUTH <= lat <= UK_BBOX_NORTH and UK_BBOX_WEST <= lon <= UK_BBOX_EAST):
if not (
UK_BBOX_SOUTH <= lat <= UK_BBOX_NORTH
and UK_BBOX_WEST <= lon <= UK_BBOX_EAST
):
return
name = n.tags.get("name:en", n.tags.get("name", ""))
@ -112,27 +123,16 @@ class PlaceHandler(osmium.SimpleHandler):
def main() -> None:
parser = argparse.ArgumentParser(
description="Extract place names from OSM PBF"
)
parser = argparse.ArgumentParser(description="Extract place names from OSM PBF")
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
parser.add_argument(
"--pbf", type=Path, default=None, help="Path to existing PBF file (skips download)"
"--pbf", type=Path, required=True, help="Path to OSM PBF file"
)
args = parser.parse_args()
if args.pbf and args.pbf.exists():
pbf_file = args.pbf
print(f"Using existing PBF: {pbf_file}")
else:
pbf_file = Path("data/great-britain-latest.osm.pbf")
if not pbf_file.exists():
download_pbf(pbf_file)
else:
print(f"Using cached PBF: {pbf_file}")
pbf_file = args.pbf
print(f"Extracting place nodes: {sorted(PLACE_TYPES)} + railway=station")
with tqdm(
unit=" elements",

View file

@ -1,6 +1,4 @@
import argparse
import tempfile
import urllib.request
from pathlib import Path
from tempfile import mkdtemp
@ -13,8 +11,6 @@ BATCH_SIZE = 50_000
MIN_OCCURENCE_COUNT = 20
GEOFABRIK_GB_URL = "https://download.geofabrik.de/europe/great-britain-latest.osm.pbf"
UK_BBOX_WEST = -7.57
UK_BBOX_SOUTH = 49.96
UK_BBOX_EAST = 1.68
@ -34,27 +30,6 @@ POI_TAG_KEYS: list[str] = [
]
def download_pbf(pbf_file: Path) -> None:
pbf_file.parent.mkdir(parents=True, exist_ok=True)
tmp = pbf_file.with_suffix(".pbf.tmp")
print(f"Downloading {GEOFABRIK_GB_URL}")
with (
tqdm(unit="B", unit_scale=True, desc="Downloading") as bar,
urllib.request.urlopen(GEOFABRIK_GB_URL) as resp,
open(tmp, "wb") as f,
):
length = resp.headers.get("Content-Length")
if length:
bar.total = int(length)
while chunk := resp.read(1 << 20):
f.write(chunk)
bar.update(len(chunk))
tmp.rename(pbf_file)
print(f"Saved to {pbf_file}")
class POIHandler(osmium.SimpleHandler):
def __init__(self, progress: tqdm, tmp_dir: Path) -> None:
super().__init__()
@ -130,51 +105,41 @@ def main() -> None:
"--output", type=Path, required=True, help="Output parquet file path"
)
parser.add_argument(
"--pbf", type=Path, default=None, help="Path to existing PBF file (skips download)"
"--pbf", type=Path, required=True, help="Path to OSM PBF file"
)
args = parser.parse_args()
with tempfile.TemporaryDirectory() as cache_dir:
if args.pbf and args.pbf.exists():
pbf_file = args.pbf
print(f"Using provided PBF file at {pbf_file}")
else:
pbf_file = Path(cache_dir) / "great-britain-latest.osm.pbf"
if not pbf_file.exists():
download_pbf(pbf_file)
else:
print(f"Using cached PBF file at {pbf_file}")
pbf_file = args.pbf
print(f"Tag keys: {POI_TAG_KEYS}")
print(f"Tag keys: {POI_TAG_KEYS}")
tmp_dir = Path(mkdtemp(prefix="pois_"))
with tqdm(
unit=" elements",
unit_scale=True,
desc="Streaming",
smoothing=0.05,
mininterval=1.0,
) as progress:
handler = POIHandler(progress, tmp_dir)
handler.apply_file(str(pbf_file), locations=True)
handler._flush_batch() # write any remaining POIs
tmp_dir = Path(mkdtemp(prefix="pois_"))
with tqdm(
unit=" elements",
unit_scale=True,
desc="Streaming",
smoothing=0.05,
mininterval=1.0,
) as progress:
handler = POIHandler(progress, tmp_dir)
handler.apply_file(str(pbf_file), locations=True)
handler._flush_batch() # write any remaining POIs
print(f"Extracted {handler.poi_count:,} POIs")
print(f"Extracted {handler.poi_count:,} POIs")
batch_files = sorted(tmp_dir.glob("batch_*.parquet"))
df = pl.concat([pl.scan_parquet(f) for f in batch_files])
batch_files = sorted(tmp_dir.glob("batch_*.parquet"))
df = pl.concat([pl.scan_parquet(f) for f in batch_files])
# Only keep categories with enough occurrences
valid_categories = (
df.group_by("category")
.agg(pl.len().alias("count"))
.filter(pl.col("count") >= MIN_OCCURENCE_COUNT)
)
df = df.join(valid_categories.select("category"), on="category", how="semi")
# Only keep categories with enough occurrences
valid_categories = (
df.group_by("category")
.agg(pl.len().alias("count"))
.filter(pl.col("count") >= MIN_OCCURENCE_COUNT)
)
df = df.join(valid_categories.select("category"), on="category", how="semi")
print(f"Total POIs: {handler.poi_count:,}")
df.sink_parquet(args.output)
print(f"Saved to {args.output}")
print(f"Total POIs: {handler.poi_count:,}")
df.sink_parquet(args.output)
print(f"Saved to {args.output}")
if __name__ == "__main__":

View file

@ -18,7 +18,7 @@ BEDROOM_SHEETS = {
16: 4, # Four or more Bedrooms
}
# Local authority district codes in England
# Local authority district codes in England, https://en.wikipedia.org/wiki/ONS_coding_system
LA_PREFIXES = ("E06", "E07", "E08", "E09")
@ -41,10 +41,9 @@ def _read_sheet(xls_path: Path, sheet_id: int, bedrooms: int) -> pl.DataFrame:
)
.filter(
pl.col("area_code").is_not_null()
& pl.col("area_code").str.starts_with("E06")
| pl.col("area_code").str.starts_with("E07")
| pl.col("area_code").str.starts_with("E08")
| pl.col("area_code").str.starts_with("E09")
& pl.any_horizontal(
pl.col("area_code").str.starts_with(p) for p in LA_PREFIXES
)
)
.with_columns(
# Suppressed values are ".." — cast will turn them to null

View file

@ -0,0 +1,417 @@
"""Add online buy/rent listings to wide.parquet as new rows.
Matches online listings to existing historical rows by postcode + fuzzy address,
carrying over historical prices and area-level data for matched properties.
Unmatched listings get area data from any same-postcode row in wide.
Modifies wide.parquet in-place, adding:
- A `Listing status` column to all rows ("Historical sale" / "For sale" / "For rent")
- New columns: Asking price, Asking rent (monthly), Bedrooms, Bathrooms,
Listing date, Property sub-type, Listing URL, Price qualifier
"""
import argparse
import re
from concurrent.futures import ProcessPoolExecutor
from os import cpu_count
from pathlib import Path
import polars as pl
from thefuzz import fuzz
from tqdm import tqdm
from pipeline.utils.fuzzy_join import _numbers_compatible
_NORMALIZE_RE = re.compile(r"[,.\-]")
_WHITESPACE_RE = re.compile(r"\s+")
# Columns that are property-specific (carried from matched historical row only)
_PROPERTY_COLUMNS = [
"Last known price",
"Date of last transaction",
"historical_prices",
"renovation_history",
"Construction age",
"Is construction date approximate",
"Current energy rating",
"Potential energy rating",
"Address per EPC",
"Interior height (m)",
"Number of bedrooms & living rooms",
"Price per sqm",
"Estimated current price",
"Est. price per sqm",
]
# Columns that are area-level (carried from matched row, or any same-postcode row)
_AREA_COLUMNS = [
"Public transport to Bank (mins)",
"Cycling to Bank (mins)",
"Public transport to Fitzrovia (mins)",
"Cycling to Fitzrovia (mins)",
"Income Score (rate)",
"Employment Score (rate)",
"Education, Skills and Training Score",
"Health Deprivation and Disability Score",
"Living Environment Score",
"Indoors Sub-domain Score",
"Outdoors Sub-domain Score",
"% Asian",
"% Black",
"% Mixed",
"% White",
"% Other",
"Estimated monthly rent",
"Criminal damage and arson (avg/yr)",
"Violence and sexual offences (avg/yr)",
"Drugs (avg/yr)",
"Anti-social behaviour (avg/yr)",
"Public order (avg/yr)",
"Other crime (avg/yr)",
"Burglary (avg/yr)",
"Vehicle crime (avg/yr)",
"Theft from the person (avg/yr)",
"Possession of weapons (avg/yr)",
"Other theft (avg/yr)",
"Shoplifting (avg/yr)",
"Bicycle theft (avg/yr)",
"Robbery (avg/yr)",
"Serious crime (avg/yr)",
"Minor crime (avg/yr)",
"Number of restaurants within 2km",
"Number of grocery shops and supermarkets within 2km",
"Number of parks within 2km",
"Number of public transport stations within 2km",
"Noise (dB)",
"Good+ primary schools within 5km",
"Good+ secondary schools within 5km",
"Max available download speed (Mbps)",
"Collapsible deposits risk",
"Compressible ground risk",
"Landslide risk",
"Running sand risk",
"Shrink-swell risk",
"Soluble rocks risk",
"Environmental risk",
]
def _normalize(s: str) -> str:
return _WHITESPACE_RE.sub(" ", _NORMALIZE_RE.sub(" ", s.upper())).strip()
def _score_bucket(
args: tuple[list[tuple[int, str]], list[tuple[int, str]]],
) -> list[tuple[int, int, int]]:
"""Score all address pairs within a single postcode bucket."""
wide_entries, online_entries = args
pairs = []
for wide_idx, wide_address in wide_entries:
for online_idx, online_address in online_entries:
if not _numbers_compatible(wide_address, online_address):
continue
score = fuzz.token_sort_ratio(wide_address, online_address)
pairs.append((score, online_idx, wide_idx))
return pairs
def _load_online(buy_path: Path, rent_path: Path) -> pl.DataFrame:
"""Load buy + rent parquets, tag with channel, normalize rent to monthly."""
buy = pl.scan_parquet(buy_path).with_columns(
pl.lit("For sale").alias("_channel"),
)
rent = pl.scan_parquet(rent_path).with_columns(
pl.lit("For rent").alias("_channel"),
)
online = pl.concat([buy, rent]).collect()
# Normalize rent prices to monthly
freq = online["price_frequency"]
price = online["price"].cast(pl.Float64)
monthly_price = (
pl.when(freq == "weekly")
.then(price * 52.0 / 12.0)
.when(freq == "yearly")
.then(price / 12.0)
.when(freq == "daily")
.then(price * 365.25 / 12.0)
.when(freq == "quarterly")
.then(price / 3.0)
.otherwise(price) # monthly, not specified
.round(0)
.cast(pl.Int64)
)
online = online.with_columns(
pl.when(pl.col("_channel") == "For sale")
.then(pl.col("price"))
.otherwise(None)
.alias("Asking price"),
pl.when(pl.col("_channel") == "For rent")
.then(monthly_price)
.otherwise(None)
.alias("Asking rent (monthly)"),
)
return online
def _match_online_to_wide(
wide: pl.DataFrame,
online: pl.DataFrame,
) -> dict[int, int]:
"""Match online listings to wide rows by postcode + fuzzy address.
Returns dict mapping online row index wide row index.
"""
# Build postcode → [(row_idx, normalized_address)] for wide
wide_postcodes = wide["Postcode"]
wide_addresses = wide["Address per Property Register"]
wide_by_postcode: dict[str, list[tuple[int, str]]] = {}
for i in range(wide.height):
pc = wide_postcodes[i]
addr = wide_addresses[i]
if pc is not None and addr is not None:
pc_upper = pc.strip().upper()
wide_by_postcode.setdefault(pc_upper, []).append((i, _normalize(addr)))
# Build postcode → [(row_idx, normalized_address)] for online
online_postcodes = online["postcode"]
online_addresses = online["address"]
online_by_postcode: dict[str, list[tuple[int, str]]] = {}
for i in range(online.height):
pc = online_postcodes[i]
addr = online_addresses[i]
if pc is not None and addr is not None:
pc_upper = pc.strip().upper()
online_by_postcode.setdefault(pc_upper, []).append((i, _normalize(addr)))
# Build tasks: only postcodes present in both
tasks = [
(wide_by_postcode[pc], online_entries)
for pc, online_entries in online_by_postcode.items()
if pc in wide_by_postcode
]
# Score in parallel
all_pairs: list[tuple[int, int, int]] = []
with ProcessPoolExecutor(max_workers=cpu_count()) as executor:
for pairs in tqdm(
executor.map(_score_bucket, tasks, chunksize=64),
total=len(tasks),
desc="Matching online listings",
):
all_pairs.extend(pairs)
del tasks, wide_by_postcode, online_by_postcode
# Greedy assignment: best score first, one-to-one
all_pairs.sort(key=lambda t: (t[0], -t[1]), reverse=True)
matches: dict[int, int] = {} # online_idx → wide_idx
matched_wide: set[int] = set()
for _score, online_idx, wide_idx in all_pairs:
if online_idx in matches or wide_idx in matched_wide:
continue
matches[online_idx] = wide_idx
matched_wide.add(wide_idx)
return matches
def _build_postcode_area_lookup(wide: pl.DataFrame) -> dict[str, int]:
"""Build postcode → first row index for area data fallback."""
postcodes = wide["Postcode"]
lookup: dict[str, int] = {}
for i in range(wide.height):
pc = postcodes[i]
if pc is not None:
pc_upper = pc.strip().upper()
if pc_upper not in lookup:
lookup[pc_upper] = i
return lookup
def _build_online_rows(
wide: pl.DataFrame,
online: pl.DataFrame,
matches: dict[int, int],
postcode_lookup: dict[str, int],
) -> pl.DataFrame:
"""Build a DataFrame of online listing rows with all wide.parquet columns."""
wide_schema = wide.schema
n = online.height
# Initialize all columns as null lists
columns: dict[str, list] = {col: [None] * n for col in wide_schema}
# Add new columns
columns["Listing status"] = [None] * n
columns["Asking price"] = [None] * n
columns["Asking rent (monthly)"] = [None] * n
columns["Bedrooms"] = [None] * n
columns["Bathrooms"] = [None] * n
columns["Listing date"] = [None] * n
columns["Property sub-type"] = [None] * n
columns["Listing URL"] = [None] * n
columns["Price qualifier"] = [None] * n
for i in range(n):
# Direct mappings from online listing
columns["Address per Property Register"][i] = online["address"][i]
columns["Postcode"][i] = online["postcode"][i]
columns["lat"][i] = online["latitude"][i]
columns["lon"][i] = online["longitude"][i]
columns["Property type"][i] = online["property_type"][i]
columns["Leashold/Freehold"][i] = online["tenure"][i]
columns["Total floor area (sqm)"][i] = online["floorspace_sqm"][i]
# New columns
columns["Listing status"][i] = online["_channel"][i]
columns["Asking price"][i] = online["Asking price"][i]
columns["Asking rent (monthly)"][i] = online["Asking rent (monthly)"][i]
columns["Bedrooms"][i] = online["bedrooms"][i]
columns["Bathrooms"][i] = online["bathrooms"][i]
columns["Property sub-type"][i] = online["property_sub_type"][i]
columns["Listing URL"][i] = online["url"][i]
columns["Price qualifier"][i] = online["price_qualifier"][i]
# Parse listing date
fvd = online["first_visible_date"][i]
if fvd is not None:
try:
from datetime import datetime
dt = datetime.fromisoformat(fvd.replace("Z", "+00:00"))
columns["Listing date"][i] = dt.replace(tzinfo=None)
except (ValueError, TypeError):
pass
# Determine source row for carried data
matched_wide_idx = matches.get(i)
postcode = online["postcode"][i]
pc_upper = postcode.strip().upper() if postcode else None
area_source_idx = matched_wide_idx
if area_source_idx is None and pc_upper is not None:
area_source_idx = postcode_lookup.get(pc_upper)
# Copy property-specific columns from matched row only
if matched_wide_idx is not None:
for col in _PROPERTY_COLUMNS:
if col in wide_schema:
columns[col][i] = wide[col][matched_wide_idx]
# Copy area columns from matched row or same-postcode fallback
if area_source_idx is not None:
for col in _AREA_COLUMNS:
if col in wide_schema:
columns[col][i] = wide[col][area_source_idx]
# Build DataFrame with correct types
series_list = []
for col_name, dtype in wide_schema.items():
series_list.append(pl.Series(col_name, columns[col_name], dtype=dtype))
# New columns with their types
series_list.append(
pl.Series("Listing status", columns["Listing status"], dtype=pl.String)
)
series_list.append(
pl.Series("Asking price", columns["Asking price"], dtype=pl.Int64)
)
series_list.append(
pl.Series(
"Asking rent (monthly)", columns["Asking rent (monthly)"], dtype=pl.Int64
)
)
series_list.append(pl.Series("Bedrooms", columns["Bedrooms"], dtype=pl.Int32))
series_list.append(pl.Series("Bathrooms", columns["Bathrooms"], dtype=pl.Int32))
series_list.append(
pl.Series("Listing date", columns["Listing date"], dtype=pl.Datetime("us"))
)
series_list.append(
pl.Series("Property sub-type", columns["Property sub-type"], dtype=pl.String)
)
series_list.append(
pl.Series("Listing URL", columns["Listing URL"], dtype=pl.String)
)
series_list.append(
pl.Series("Price qualifier", columns["Price qualifier"], dtype=pl.String)
)
return pl.DataFrame(series_list)
def main():
parser = argparse.ArgumentParser(
description="Add online buy/rent listings to wide.parquet"
)
parser.add_argument(
"--input",
type=Path,
required=True,
help="wide.parquet path (modified in-place)",
)
parser.add_argument(
"--buy", type=Path, required=True, help="rightmove_buy.parquet path"
)
parser.add_argument(
"--rent", type=Path, required=True, help="rightmove_rent.parquet path"
)
args = parser.parse_args()
print("Loading wide.parquet...")
wide = pl.read_parquet(args.input)
print(f" {wide.height} rows, {wide.width} columns")
print("Loading online listings...")
online = _load_online(args.buy, args.rent)
print(
f" {online.height} online listings ({online.filter(pl.col('_channel') == 'For sale').height} buy, {online.filter(pl.col('_channel') == 'For rent').height} rent)"
)
print("Matching online listings to historical rows...")
matches = _match_online_to_wide(wide, online)
print(f" {len(matches)} online listings matched to historical rows")
print("Building postcode area lookup...")
postcode_lookup = _build_postcode_area_lookup(wide)
print("Building online listing rows...")
online_rows = _build_online_rows(wide, online, matches, postcode_lookup)
print(f" {online_rows.height} online rows built")
# Add Listing status + new columns to existing wide rows
wide = wide.with_columns(
pl.lit("Historical sale").alias("Listing status"),
pl.lit(None, dtype=pl.Int64).alias("Asking price"),
pl.lit(None, dtype=pl.Int64).alias("Asking rent (monthly)"),
pl.lit(None, dtype=pl.Int32).alias("Bedrooms"),
pl.lit(None, dtype=pl.Int32).alias("Bathrooms"),
pl.lit(None, dtype=pl.Datetime("us")).alias("Listing date"),
pl.lit(None, dtype=pl.String).alias("Property sub-type"),
pl.lit(None, dtype=pl.String).alias("Listing URL"),
pl.lit(None, dtype=pl.String).alias("Price qualifier"),
)
# Concat
result = pl.concat([wide, online_rows], how="diagonal_relaxed")
print(f"Final: {result.height} rows, {result.width} columns")
# Verify
status_counts = (
result["Listing status"].value_counts().sort("count", descending=True)
)
print(f"Listing status distribution:\n{status_counts}")
result.write_parquet(args.input)
size_mb = args.input.stat().st_size / (1024 * 1024)
print(f"Wrote {args.input} ({size_mb:.1f} MB)")
if __name__ == "__main__":
main()

View file

@ -7,6 +7,7 @@ from ..utils import fuzzy_join_on_postcode
pl.Config.set_tbl_cols(-1)
RATING_RANK = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7}
MIN_PRICE = 50_000
def main():
@ -117,7 +118,9 @@ def main():
.collect()
)
event_counts = events["renovation_history"].explode().struct.field("event").value_counts()
event_counts = (
events["renovation_history"].explode().struct.field("event").value_counts()
)
print(f"Renovation events: {events.height} properties with events")
print(event_counts)
@ -159,6 +162,7 @@ def main():
"old_new",
)
.filter(pl.col("pp_property_type") != "Other")
.filter(pl.col("price") >= MIN_PRICE)
.with_columns(
pl.concat_str(
[pl.col("saon"), pl.col("paon"), pl.col("street")],
@ -171,6 +175,7 @@ def main():
.agg(
pl.struct(
pl.col("date_of_transfer").dt.year().alias("year"),
pl.col("date_of_transfer").dt.month().cast(pl.UInt8).alias("month"),
"price",
).alias("historical_prices"),
pl.col("pp_property_type").last(),

View file

@ -3,7 +3,8 @@ import argparse
import polars as pl
from pathlib import Path
MIN_PRICE = 10_000
from pipeline.utils.postcode_mapping import build_postcode_mapping
MIN_FLOOR_AREA_M2 = 10
@ -45,20 +46,23 @@ def _build_wide(
rental_prices_path: Path,
) -> pl.DataFrame:
"""Build the wide dataframe by joining epc_pp with all auxiliary data."""
wide = (
pl.scan_parquet(epc_pp_path)
.filter(
pl.col("total_floor_area").is_null()
| (pl.col("total_floor_area") > MIN_FLOOR_AREA_M2)
)
.filter(
pl.col("latest_price").is_null() | (pl.col("latest_price") >= MIN_PRICE)
)
wide = pl.scan_parquet(epc_pp_path).filter(
pl.col("total_floor_area").is_null()
| (pl.col("total_floor_area") > MIN_FLOOR_AREA_M2)
)
# Remap terminated postcodes to nearest active successor
postcode_mapping = build_postcode_mapping(arcgis_path)
wide = wide.join(
postcode_mapping.lazy(), left_on="postcode", right_on="old_postcode", how="left"
).with_columns(
pl.coalesce("new_postcode", "postcode").alias("postcode"),
).drop("new_postcode")
arcgis = (
pl.scan_parquet(arcgis_path)
.filter(pl.col("ctry") == "E92000001") # England only
.filter(pl.col("doterm").is_null()) # Active postcodes only
.select(
pl.col("pcds").alias("postcode"),
"lat",
@ -67,7 +71,7 @@ def _build_wide(
"oa21",
)
)
wide = wide.join(arcgis, on="postcode", how="full", coalesce=True)
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")
@ -147,11 +151,6 @@ def _build_wide(
.with_columns(
pl.max_horizontal(*noise_cols).alias("noise_lden_db"),
)
.with_columns(
pl.col("noise_lden_db")
.fill_null(pl.col("noise_lden_db").min())
.alias("noise_lden_db"),
)
.select("postcode", "noise_lden_db")
)
wide = wide.join(noise, on="postcode", how="left")
@ -181,7 +180,7 @@ def _build_wide(
.group_by("bb_postcode")
.agg(pl.col("max_download_speed").max())
)
wide = wide.join(broadband, left_on="postcode", right_on="bb_postcode", how="left")
wide = wide.join(broadband, left_on="postcode", right_on="bb_postcode", how="left").drop("bb_postcode")
geosure = pl.scan_parquet(geosure_path)
wide = wide.join(geosure, on="postcode", how="left")

View file

@ -8,11 +8,12 @@ import polars as pl
from pipeline.utils.poi_counts import count_pois_per_postcode
# POI category groups for proximity counting
# POI category groups for proximity counting.
# Names must match the friendly names produced by transform_poi.py / naptan.py.
POI_GROUPS = {
"restaurants": ["Restaurant", "Fast Food"],
"groceries": ["Greengrocer", "Grocery Shop", "Supermarket", "Convenience Store"],
"parks": ["Park", "Garden", "Nature Reserve"],
"groceries": ["Greengrocer", "Supermarket", "Convenience Store"],
"parks": ["Park"],
"public_transport": [
"Metro or Tram stop",
"Rail station",

View file

@ -0,0 +1,292 @@
"""Backtest price estimation on held-out recent sales.
Uses temporal holdout: index built from pairs before TEST_YEAR_MIN only.
Test set: properties with 2+ sales where the last sale >= TEST_YEAR_MIN.
Evaluates: Naive vs Index vs kNN vs Blended.
"""
import argparse
from pathlib import Path
import numpy as np
import polars as pl
from pipeline.transform.price_estimation.index import build_index
from pipeline.transform.price_estimation.knn import (
KNN_BLEND_WEIGHT,
build_knn_pool,
knn_median_psm,
)
from pipeline.transform.price_estimation.utils import (
CURRENT_YEAR,
MAX_LOG_ADJUSTMENT,
compute_seasonal_factors,
interpolate_log_index,
sector_expr,
type_group_expr,
)
TEST_YEAR_MIN = 2022
def extract_test_set(input_path: Path) -> pl.DataFrame:
"""Extract test pairs: second-to-last sale as input, last sale as ground truth."""
print("Loading test set...")
df = (
pl.scan_parquet(input_path)
.filter(
pl.col("Postcode").is_not_null(),
pl.col("historical_prices").list.len() >= 2,
)
.with_columns(
sector_expr(),
type_group_expr(),
# Last sale (ground truth)
pl.col("historical_prices")
.list.last()
.struct.field("year")
.alias("actual_year"),
pl.col("historical_prices")
.list.last()
.struct.field("month")
.alias("actual_month"),
pl.col("historical_prices")
.list.last()
.struct.field("price")
.alias("actual_price"),
# Second-to-last sale (input)
pl.col("historical_prices")
.list.get(-2)
.struct.field("year")
.alias("input_year"),
pl.col("historical_prices")
.list.get(-2)
.struct.field("month")
.alias("input_month"),
pl.col("historical_prices")
.list.get(-2)
.struct.field("price")
.alias("input_price"),
)
.with_columns(
(
pl.col("actual_year").cast(pl.Float64)
+ (pl.col("actual_month").cast(pl.Float64) - 1.0) / 12.0
).alias("actual_frac_year"),
(
pl.col("input_year").cast(pl.Float64)
+ (pl.col("input_month").cast(pl.Float64) - 1.0) / 12.0
).alias("input_frac_year"),
)
.filter(
pl.col("actual_year") >= TEST_YEAR_MIN,
pl.col("input_price") > 0,
pl.col("actual_price") > 0,
pl.col("actual_frac_year") > pl.col("input_frac_year"),
)
.collect()
)
print(f" {len(df):,} test pairs (last sale {TEST_YEAR_MIN}-{CURRENT_YEAR})")
return df
def predict(test: pl.DataFrame, index: pl.DataFrame) -> pl.DataFrame:
"""Index-based prediction with interpolation, capping, and seasonal adjustment."""
test = interpolate_log_index(
index, test, "sector", "type_group", "input_frac_year", "log_index_input"
)
test = interpolate_log_index(
index, test, "sector", "type_group", "actual_frac_year", "log_index_actual"
)
test = test.with_columns(
(
pl.col("input_price").cast(pl.Float64)
* (pl.col("log_index_actual") - pl.col("log_index_input"))
.clip(-MAX_LOG_ADJUSTMENT, MAX_LOG_ADJUSTMENT)
.exp()
* pl.col("_seasonal_adj")
)
.fill_null(pl.col("input_price").cast(pl.Float64))
.alias("predicted"),
)
return test
def compute_metrics(actual: np.ndarray, predicted: np.ndarray) -> dict:
valid = np.isfinite(predicted) & np.isfinite(actual) & (actual > 0) & (predicted > 0)
actual = actual[valid]
predicted = predicted[valid]
ape = np.abs(predicted - actual) / actual
signed_err = predicted - actual
return {
"MdAPE (%)": float(np.median(ape) * 100),
"% within 10%": float(np.mean(ape <= 0.10) * 100),
"% within 20%": float(np.mean(ape <= 0.20) * 100),
"% within 30%": float(np.mean(ape <= 0.30) * 100),
"MAE (£)": float(np.mean(np.abs(signed_err))),
"Mean signed error (£)": float(np.mean(signed_err)),
"n": int(len(actual)),
}
def print_metrics_table(metrics_by_stage: dict):
stages = list(metrics_by_stage.keys())
col_w = 15
width = 25 + col_w * len(stages)
print("\n" + "=" * width)
print(f"BACKTEST RESULTS (holdout: sales >= {TEST_YEAR_MIN})")
print("=" * width)
metric_names = [
"MdAPE (%)",
"% within 10%",
"% within 20%",
"% within 30%",
"MAE (£)",
"Mean signed error (£)",
"n",
]
header = f"{'Metric':<25s}"
for stage in stages:
header += f" {stage:>{col_w - 1}s}"
print(header)
print("-" * width)
for metric in metric_names:
row = f"{metric:<25s}"
for stage in stages:
val = metrics_by_stage[stage][metric]
if metric == "n":
row += f" {val:>{col_w - 1},d}"
elif "£" in metric:
row += f" {val:>{col_w - 2},.0f}"
else:
row += f" {val:>{col_w - 2}.1f}%"
print(row)
print("=" * width)
def main():
parser = argparse.ArgumentParser(description="Backtest price estimation model")
parser.add_argument(
"--input", type=Path, required=True, help="Path to wide.parquet"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output backtest_results.parquet"
)
args = parser.parse_args()
# Build index from pre-test data only (temporal holdout)
print(f"Building price index (pairs with year2 < {TEST_YEAR_MIN})...")
index = build_index(args.input, max_pair_year=TEST_YEAR_MIN)
print(
f"\nHoldout index: {len(index):,} rows, {index['sector'].n_unique():,} sectors, "
f"{index['type_group'].n_unique()} type groups"
)
# Compute seasonal factors from pre-test data only
seasonal = compute_seasonal_factors(args.input, max_sale_year=TEST_YEAR_MIN)
months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
print(
f"Seasonal factors: {', '.join(f'{m}={f:.3f}' for m, f in zip(months, seasonal))}"
)
test = extract_test_set(args.input)
# Compute seasonal adjustment for each test pair
input_months = test["input_month"].fill_null(6).to_numpy().astype(np.int32)
actual_months = test["actual_month"].fill_null(6).to_numpy().astype(np.int32)
seasonal_adj = seasonal[actual_months - 1] / seasonal[input_months - 1]
test = test.with_columns(
pl.Series("_seasonal_adj", seasonal_adj, dtype=pl.Float64),
)
print("\nPredicting with price index...")
test = predict(test, index)
# --- kNN ---
ref_fy = float(TEST_YEAR_MIN)
trees = build_knn_pool(args.input, index, ref_fy, max_sale_year=TEST_YEAR_MIN)
# Interpolate log_index at reference year for temporal adjustment
test = test.with_columns(pl.lit(ref_fy).alias("_ref_fy"))
test = interpolate_log_index(
index, test, "sector", "type_group", "_ref_fy", "_log_index_ref"
)
lat = test["lat"].cast(pl.Float64).to_numpy()
lon = test["lon"].cast(pl.Float64).to_numpy()
tg = test["type_group"].to_numpy()
fa = test["Total floor area (sqm)"].cast(pl.Float64).fill_null(0.0).to_numpy()
print("\nComputing kNN estimates...")
knn_psm = knn_median_psm(trees, lat, lon, tg)
# Temporal adjustment: pool PSM is at ref, adjust to actual
log_idx_actual = test["log_index_actual"].to_numpy().astype(np.float64)
log_idx_ref = test["_log_index_ref"].to_numpy().astype(np.float64)
temporal_adj = np.where(
np.isfinite(log_idx_actual) & np.isfinite(log_idx_ref),
np.exp(log_idx_actual - log_idx_ref),
1.0,
)
knn_est = knn_psm * fa * temporal_adj
n_knn = int((np.isfinite(knn_est) & (knn_est > 0)).sum())
print(f" kNN estimates: {n_knn:,} of {len(test):,} ({n_knn / len(test) * 100:.1f}%)")
# Blend: (1-w)*index + w*kNN where both available
index_est = test["predicted"].to_numpy().astype(np.float64)
knn_valid = np.isfinite(knn_est) & (knn_est > 0)
blended = np.where(
knn_valid & np.isfinite(index_est),
(1 - KNN_BLEND_WEIGHT) * index_est + KNN_BLEND_WEIGHT * knn_est,
np.where(np.isfinite(index_est), index_est, knn_est),
)
actual = test["actual_price"].to_numpy().astype(np.float64)
metrics = {
"Naive": compute_metrics(
actual, test["input_price"].to_numpy().astype(np.float64)
),
"Index": compute_metrics(actual, index_est),
"kNN": compute_metrics(actual, knn_est),
"Blended": compute_metrics(actual, blended),
}
print_metrics_table(metrics)
# Save results
result = test.select(
"Postcode",
"sector",
"input_year",
"input_frac_year",
"input_price",
"actual_year",
"actual_frac_year",
"actual_price",
"predicted",
).with_columns(
pl.Series("knn_predicted", knn_est, dtype=pl.Float64),
pl.Series("blended", blended, dtype=pl.Float64),
)
result.write_parquet(args.output)
size_mb = args.output.stat().st_size / (1024 * 1024)
print(f"\nWrote {args.output} ({size_mb:.1f} MB)")
print(f" {len(result):,} rows")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,204 @@
"""Augment wide.parquet with estimated current prices.
For properties with a known prior sale, applies the repeat-sales price index
to adjust the last known price to the current date, then blends with kNN
estimates from nearby recently-sold properties. Includes:
- Capping extreme index adjustments
- Seasonal month-of-sale adjustment
- kNN spatial blending
Modifies wide.parquet in-place.
"""
import argparse
from pathlib import Path
import numpy as np
import polars as pl
from pipeline.transform.price_estimation.knn import (
KNN_BLEND_WEIGHT,
build_knn_pool,
knn_median_psm,
)
from pipeline.transform.price_estimation.utils import (
CURRENT_FRAC_YEAR,
CURRENT_MONTH,
MAX_LOG_ADJUSTMENT,
compute_seasonal_factors,
interpolate_log_index,
sector_expr,
type_group_expr,
)
def main():
parser = argparse.ArgumentParser(
description="Augment wide.parquet with estimated current prices"
)
parser.add_argument(
"--input",
type=Path,
required=True,
help="Path to wide.parquet (modified in-place)",
)
parser.add_argument(
"--index", type=Path, required=True, help="Path to price_index.parquet"
)
args = parser.parse_args()
print("Loading wide.parquet...")
df = pl.read_parquet(args.input)
print(f" {len(df):,} rows, {len(df.columns)} columns")
# Drop existing estimated columns if re-running
for col in ["Estimated current price", "Est. price per sqm"]:
if col in df.columns:
df = df.drop(col)
# Compute seasonal factors
seasonal = compute_seasonal_factors(args.input)
months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
print(
f" Seasonal factors: {', '.join(f'{m}={f:.3f}' for m, f in zip(months, seasonal))}"
)
# Build seasonal adjustment: seasonal[current_month] / seasonal[sale_month]
sale_month = (
df["Date of last transaction"]
.dt.month()
.fill_null(6)
.to_numpy()
.astype(np.int32)
)
seasonal_adj = seasonal[CURRENT_MONTH - 1] / seasonal[sale_month - 1]
# Derive helper columns
df = df.with_columns(
sector_expr().alias("_sector"),
(
pl.col("Date of last transaction").dt.year().cast(pl.Float64)
+ (pl.col("Date of last transaction").dt.month().cast(pl.Float64) - 1.0)
/ 12.0
).alias("_sale_frac_year"),
type_group_expr().alias("_type_group"),
pl.lit(CURRENT_FRAC_YEAR).alias("_current_frac_year"),
pl.Series("_seasonal_adj", seasonal_adj, dtype=pl.Float64),
)
index = pl.read_parquet(args.index)
print(
f" Price index: {len(index):,} rows, {index['sector'].n_unique():,} sectors, "
f"{index['type_group'].n_unique()} type groups"
)
print("\nApplying repeat-sales index with fractional year interpolation...")
df = interpolate_log_index(
index, df, "_sector", "_type_group", "_sale_frac_year", "_log_index_sale_interp"
)
df = interpolate_log_index(
index,
df,
"_sector",
"_type_group",
"_current_frac_year",
"_log_index_current_interp",
)
# Compute index-adjusted estimate with cap and seasonal adjustment
has_price = (
pl.col("Last known price").is_not_null()
& pl.col("Postcode").is_not_null()
& pl.col("Date of last transaction").is_not_null()
)
df = df.with_columns(
pl.when(has_price)
.then(
pl.col("Last known price").cast(pl.Float64)
* (
pl.col("_log_index_current_interp") - pl.col("_log_index_sale_interp")
)
.clip(-MAX_LOG_ADJUSTMENT, MAX_LOG_ADJUSTMENT)
.exp()
* pl.col("_seasonal_adj")
)
.otherwise(pl.lit(None))
.alias("Estimated current price"),
)
n_estimated = df.filter(pl.col("Estimated current price").is_not_null()).height
n_with_price = df.filter(has_price).height
print(
f" {n_estimated:,} of {n_with_price:,} properties estimated "
f"({n_estimated / max(n_with_price, 1) * 100:.1f}%)"
)
# --- kNN blending ---
print("\nBuilding kNN estimates...")
trees = build_knn_pool(args.input, index, CURRENT_FRAC_YEAR)
lat = df["lat"].cast(pl.Float64).to_numpy()
lon = df["lon"].cast(pl.Float64).to_numpy()
tg = df["_type_group"].fill_null("").to_numpy()
fa = df["Total floor area (sqm)"].cast(pl.Float64).fill_null(0.0).to_numpy()
knn_psm = knn_median_psm(trees, lat, lon, tg)
knn_est = knn_psm * fa # No temporal adj: ref == current
df = df.with_columns(
pl.Series("_knn_est", knn_est, dtype=pl.Float64),
)
# Blend: where kNN available, use weighted average; else keep index
df = df.with_columns(
pl.when(
pl.col("Estimated current price").is_not_null()
& pl.col("_knn_est").is_not_null()
& pl.col("_knn_est").is_finite()
& (pl.col("_knn_est") > 0)
)
.then(
(1 - KNN_BLEND_WEIGHT) * pl.col("Estimated current price")
+ KNN_BLEND_WEIGHT * pl.col("_knn_est")
)
.when(pl.col("Estimated current price").is_not_null())
.then(pl.col("Estimated current price"))
.otherwise(pl.lit(None))
.alias("Estimated current price"),
)
n_blended = df.filter(
pl.col("_knn_est").is_not_null()
& pl.col("_knn_est").is_finite()
& (pl.col("_knn_est") > 0)
& pl.col("Estimated current price").is_not_null()
).height
print(f" kNN blended: {n_blended:,} of {n_estimated:,} estimates")
# Derive estimated price per sqm where both estimated price and floor area exist
df = df.with_columns(
(pl.col("Estimated current price") / pl.col("Total floor area (sqm)"))
.round(0)
.cast(pl.Int32, strict=False)
.alias("Est. price per sqm"),
)
# Drop all temporary columns
temp_cols = [c for c in df.columns if c.startswith("_") or c.startswith("log_idx_")]
df = df.drop(temp_cols)
df.write_parquet(args.input)
size_mb = args.input.stat().st_size / (1024 * 1024)
print(f"\nWrote {args.input} ({size_mb:.1f} MB)")
print(
f" {len(df):,} rows, {len(df.columns)} columns (including 'Estimated current price')"
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,465 @@
"""Hierarchical repeat-sales price index.
Stratified by property type and postcode sector, with IRLS Huber regression,
hierarchical shrinkage (sector district area national hedonic),
and KD-tree spatial smoothing for sparse sectors.
Output: price_index.parquet sector x type_group x year -> log_index
"""
import argparse
from pathlib import Path
import numpy as np
import polars as pl
from scipy.sparse import csc_matrix
from scipy.sparse.linalg import lsqr
from tqdm import tqdm
from pipeline.transform.price_estimation.shrinkage import (
blend_dicts,
hierarchical_shrinkage,
shrink_dicts,
spatial_smooth,
)
from pipeline.transform.price_estimation.utils import (
CURRENT_YEAR,
TYPE_GROUPS,
build_hedonic_features,
extract_centroids,
hierarchy_keys,
sector_expr,
type_group_expr,
)
MIN_PAIRS = 5
OUTLIER_THRESHOLD = 3.0 # hard pre-filter; Huber handles the rest
HUBER_K = 1.345
IRLS_ITERATIONS = 5
def extract_pairs(input_path: Path, max_year2: int | None = None) -> pl.DataFrame:
"""Extract consecutive repeat-sale pairs.
If max_year2 is set, only pairs where year2 < max_year2 are included
(for temporal holdout in backtesting).
"""
print("Extracting repeat-sale pairs...")
df = (
pl.scan_parquet(input_path)
.select("Postcode", "historical_prices", "Property type")
.filter(
pl.col("Postcode").is_not_null(),
pl.col("historical_prices").list.len() >= 2,
)
.with_columns(sector_expr(), type_group_expr())
.collect()
)
print(f" {len(df):,} properties with 2+ transactions")
pairs = (
df.lazy()
.with_columns(
pl.col("historical_prices")
.list.slice(0, pl.col("historical_prices").list.len() - 1)
.alias("from_txn"),
pl.col("historical_prices").list.slice(1).alias("to_txn"),
)
.explode("from_txn", "to_txn")
.with_columns(
pl.col("from_txn").struct.field("year").alias("year1"),
pl.col("from_txn").struct.field("month").alias("month1"),
pl.col("from_txn").struct.field("price").alias("price1"),
pl.col("to_txn").struct.field("year").alias("year2"),
pl.col("to_txn").struct.field("month").alias("month2"),
pl.col("to_txn").struct.field("price").alias("price2"),
)
.with_columns(
(
pl.col("year1").cast(pl.Float64)
+ (pl.col("month1").cast(pl.Float64) - 1.0) / 12.0
).alias("frac_year1"),
(
pl.col("year2").cast(pl.Float64)
+ (pl.col("month2").cast(pl.Float64) - 1.0) / 12.0
).alias("frac_year2"),
)
.select(
"sector",
"type_group",
"year1",
"price1",
"year2",
"price2",
"frac_year1",
"frac_year2",
)
.filter(
pl.col("price1") > 0,
pl.col("price2") > 0,
pl.col("frac_year2") > pl.col("frac_year1"),
)
.with_columns(
(pl.col("price2").cast(pl.Float64) / pl.col("price1").cast(pl.Float64))
.log()
.alias("log_ratio"),
(
1.0
/ (pl.col("frac_year2") - pl.col("frac_year1"))
.cast(pl.Float64)
.sqrt()
).alias("weight"),
)
.filter(pl.col("log_ratio").abs() <= OUTLIER_THRESHOLD)
.collect()
)
if max_year2 is not None:
pairs = pairs.filter(pl.col("year2") < max_year2)
# Add hierarchy columns
pairs = pairs.with_columns(
pl.col("sector").str.replace(r"\s+\d+$", "").alias("district"),
).with_columns(
pl.col("district").str.replace(r"\d.*$", "").alias("area"),
)
print(f" {len(pairs):,} pairs extracted")
return pairs
def solve_robust_index(
years1: np.ndarray,
years2: np.ndarray,
log_ratios: np.ndarray,
base_weights: np.ndarray,
) -> dict[int, float]:
"""IRLS Huber M-estimation for the Case-Shiller repeat-sales model."""
n = len(years1)
if n < MIN_PAIRS:
return {}
all_years = np.union1d(years1, years2)
min_year = int(all_years.min())
col = 0
year_to_col = {}
for y in all_years:
iy = int(y)
if iy != min_year:
year_to_col[iy] = col
col += 1
n_cols = len(year_to_col)
if n_cols == 0:
return {}
# Vectorized column index mapping
col2 = np.full(n, -1, dtype=np.int32)
col1 = np.full(n, -1, dtype=np.int32)
for year, c in year_to_col.items():
col2[years2 == year] = c
col1[years1 == year] = c
# Sparse matrix structure (fixed across iterations)
mask2 = col2 >= 0
mask1 = col1 >= 0
rows_arr = np.concatenate([np.where(mask2)[0], np.where(mask1)[0]])
cols_arr = np.concatenate([col2[mask2], col1[mask1]])
signs_arr = np.concatenate([np.ones(mask2.sum()), -np.ones(mask1.sum())])
weights = base_weights.copy()
for _ in range(IRLS_ITERATIONS):
data = signs_arr * weights[rows_arr]
A = csc_matrix((data, (rows_arr, cols_arr)), shape=(n, n_cols))
b = log_ratios * weights
betas = lsqr(A, b, atol=1e-10, btol=1e-10)[0]
# Residuals
predicted = np.zeros(n)
predicted[mask2] += betas[col2[mask2]]
predicted[mask1] -= betas[col1[mask1]]
residuals = log_ratios - predicted
# Huber reweighting
abs_r = np.abs(residuals)
huber_w = np.where(abs_r <= HUBER_K, 1.0, HUBER_K / np.maximum(abs_r, 1e-10))
weights = base_weights * huber_w
index = {min_year: 0.0}
for year, c in year_to_col.items():
index[year] = float(betas[c])
return index
def compute_indices_for_level(pairs: pl.DataFrame, group_col: str):
"""Solve robust indices for each group. Returns (indices, n_pairs) dicts."""
groups = pairs.group_by(group_col).agg(
pl.col("year1"),
pl.col("year2"),
pl.col("log_ratio"),
pl.col("weight"),
)
indices = {}
n_pairs = {}
for row in tqdm(
groups.iter_rows(named=True), total=len(groups), desc=f" {group_col}"
):
key = row[group_col]
y1 = np.array(row["year1"], dtype=np.int32)
y2 = np.array(row["year2"], dtype=np.int32)
lr = np.array(row["log_ratio"], dtype=np.float64)
w = np.array(row["weight"], dtype=np.float64)
idx = solve_robust_index(y1, y2, lr, w)
if idx:
indices[key] = idx
n_pairs[key] = len(y1)
return indices, n_pairs
def compute_hedonic_index(
input_path: Path,
min_year: int,
max_year: int,
max_sale_year: int | None = None,
) -> dict[int, float]:
"""Quality-adjusted hedonic index: regress log(price) on features, average residual by year.
Used as the ultimate shrinkage fallback for the repeat-sales index.
If max_sale_year is set, only sales before that year are used (backtesting holdout).
"""
effective_max = max_sale_year - 1 if max_sale_year is not None else max_year
print("Computing hedonic index...")
df = (
pl.scan_parquet(input_path)
.select(
"Last known price",
"Date of last transaction",
"Property type",
"Total floor area (sqm)",
)
.filter(
pl.col("Last known price").is_not_null(),
pl.col("Total floor area (sqm)").is_not_null(),
pl.col("Total floor area (sqm)") > 0,
)
.with_columns(
pl.col("Date of last transaction").dt.year().alias("sale_year"),
type_group_expr(),
)
.filter(
pl.col("type_group").is_not_null(),
pl.col("sale_year").is_not_null(),
pl.col("sale_year") >= min_year,
pl.col("sale_year") <= effective_max,
)
.collect()
)
print(f" {len(df):,} complete cases for hedonic model")
# Target
log_price = np.log(df["Last known price"].to_numpy().astype(np.float64))
sale_years = df["sale_year"].to_numpy()
# Build feature matrix (5 hedonic features + intercept)
X = build_hedonic_features(df)
F = np.hstack([X, np.ones((len(df), 1), dtype=np.float32)])
print(f" Feature matrix: {F.shape[0]:,} x {F.shape[1]}")
# Step 1: regress log(price) on features -> quality score
betas = np.linalg.lstsq(F.astype(np.float64), log_price, rcond=None)[0]
quality_score = F.astype(np.float64) @ betas
residuals = log_price - quality_score
# Step 2: average residual by year = hedonic index
hedonic = {}
for y in range(min_year, max_year + 1):
mask = sale_years == y
if mask.sum() > 0:
hedonic[y] = float(np.mean(residuals[mask]))
# Normalize: min_year = 0
base = hedonic.get(min_year, 0.0)
for y in hedonic:
hedonic[y] -= base
print(
f" Hedonic index: {len(hedonic)} years, range {min(hedonic.values()):.3f} to {max(hedonic.values()):.3f}"
)
return hedonic
EXTRAPOLATION_YEARS = 3
def forward_fill(index: dict, min_year: int, max_year: int) -> dict:
"""Forward-fill missing years, with linear extrapolation beyond last known year."""
if not index:
return {y: 0.0 for y in range(min_year, max_year + 1)}
sorted_years = sorted(index.keys())
last_known_year = sorted_years[-1]
# Forward fill up to last known year
filled = {}
last = 0.0
for y in range(min_year, last_known_year + 1):
if y in index:
last = index[y]
filled[y] = last
# Linear extrapolation beyond last known year
if last_known_year < max_year:
recent = [
(y, index[y])
for y in sorted_years
if y >= last_known_year - EXTRAPOLATION_YEARS
]
if len(recent) >= 2:
years_arr = np.array([r[0] for r in recent], dtype=np.float64)
vals_arr = np.array([r[1] for r in recent], dtype=np.float64)
slope = np.polyfit(years_arr, vals_arr, 1)[0]
for y in range(last_known_year + 1, max_year + 1):
filled[y] = index[last_known_year] + slope * (y - last_known_year)
else:
for y in range(last_known_year + 1, max_year + 1):
filled[y] = index[last_known_year]
return filled
def build_index(input_path: Path, max_pair_year: int | None = None) -> pl.DataFrame:
"""Build the full price index from raw data.
If max_pair_year is set, only pairs before that year are used (backtesting holdout).
The index is still forward-filled to CURRENT_YEAR.
"""
pairs = extract_pairs(input_path, max_year2=max_pair_year)
centroids = extract_centroids(input_path)
min_year = int(pairs["year1"].min())
max_year = CURRENT_YEAR
hedonic_idx = compute_hedonic_index(
input_path, min_year, max_year, max_sale_year=max_pair_year
)
# Precompute hierarchy
all_sectors = pairs["sector"].unique().to_list()
sector_to_dist = {}
dist_to_area = {}
for s in all_sectors:
d, a = hierarchy_keys(s)
sector_to_dist[s] = d
dist_to_area[d] = a
# Process each type group + "All"
all_type_groups = ["All"] + TYPE_GROUPS
final = {} # {type_group: {sector: {year: log_index}}}
final_n = {} # {type_group: {sector: n_pairs}}
for tg in all_type_groups:
print(f"\n--- {tg} ---")
typed = pairs if tg == "All" else pairs.filter(pl.col("type_group") == tg)
if len(typed) < MIN_PAIRS:
print(f" Skipping (only {len(typed)} pairs)")
final[tg] = {s: dict(hedonic_idx) for s in all_sectors}
final_n[tg] = {s: 0 for s in all_sectors}
continue
print(f" {len(typed):,} pairs")
# National
np_arrs = typed.select("year1", "year2", "log_ratio", "weight")
national_idx = solve_robust_index(
np_arrs["year1"].to_numpy(),
np_arrs["year2"].to_numpy(),
np_arrs["log_ratio"].to_numpy(),
np_arrs["weight"].to_numpy(),
)
national_n = len(typed)
print(f" National: {len(national_idx)} years")
# Area, district, sector
print(" Computing per-level indices:")
area_idx, area_n = compute_indices_for_level(typed, "area")
district_idx, district_n = compute_indices_for_level(typed, "district")
sector_idx, sector_n = compute_indices_for_level(typed, "sector")
print(
f" {len(area_idx)} areas, {len(district_idx)} districts, {len(sector_idx)} sectors"
)
# Shrinkage: national -> hedonic first, then hierarchical
print(" Applying shrinkage...")
national_shrunk = shrink_dicts(national_idx, hedonic_idx, national_n)
sector_shrunk = hierarchical_shrinkage(
sector_idx,
sector_n,
district_idx,
district_n,
area_idx,
area_n,
national_shrunk,
all_sectors,
sector_to_dist,
dist_to_area,
shrink_dicts,
)
# Spatial smoothing
print(" Spatial smoothing...")
sector_smoothed = spatial_smooth(
sector_shrunk, centroids, sector_n, blend_dicts
)
# Forward fill
for sec in all_sectors:
sector_smoothed[sec] = forward_fill(
sector_smoothed.get(sec, hedonic_idx), min_year, max_year
)
final[tg] = sector_smoothed
final_n[tg] = sector_n
# Assemble output
print("\nAssembling output...")
rows = []
for tg in all_type_groups:
for sec in all_sectors:
n = final_n[tg].get(sec, 0)
for year, log_idx in final[tg][sec].items():
rows.append((sec, tg, year, log_idx, n))
return pl.DataFrame(
rows,
schema={
"sector": pl.String,
"type_group": pl.String,
"year": pl.Int32,
"log_index": pl.Float64,
"n_pairs": pl.Int64,
},
orient="row",
).sort("type_group", "sector", "year")
def main():
parser = argparse.ArgumentParser(
description="Build improved repeat-sales price index"
)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
result = build_index(args.input)
result.write_parquet(args.output)
size_mb = args.output.stat().st_size / (1024 * 1024)
print(f"\nWrote {args.output} ({size_mb:.1f} MB)")
print(
f" {result['sector'].n_unique():,} sectors x {result['type_group'].n_unique()} types x {result['year'].n_unique()} years = {len(result):,} rows"
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,161 @@
"""kNN price estimation using nearby recently-sold properties.
For each target property, finds k nearest sold properties of the same type,
computes the median index-adjusted price-per-sqm, and multiplies by the
target's floor area to produce an estimate.
"""
from pathlib import Path
import numpy as np
import polars as pl
from scipy.spatial import KDTree
from pipeline.transform.price_estimation.utils import (
TYPE_GROUPS,
interpolate_log_index,
sector_expr,
type_group_expr,
)
KNN_K = 20
KNN_MIN_NEIGHBORS = 5
KNN_BLEND_WEIGHT = 0.35
def _scale_coords(lat: np.ndarray, lon: np.ndarray) -> np.ndarray:
"""Equirectangular projection: scale lon by cos(lat) for approximate distances."""
return np.column_stack([lat, lon * np.cos(np.radians(lat))])
def build_knn_pool(
input_path: Path,
index: pl.DataFrame,
ref_frac_year: float,
max_sale_year: int | None = None,
) -> dict[str, tuple[KDTree, np.ndarray]]:
"""Build per-type_group KD-trees of index-adjusted price-per-sqm.
Adjusts all pool properties' sale prices to ref_frac_year using the index,
then builds a KD-tree per type_group for nearest-neighbor queries.
Returns dict mapping type_group -> (KDTree over scaled lat/lon, adjusted_psm array).
"""
print("Building kNN pool...")
query = (
pl.scan_parquet(input_path)
.select(
"Postcode",
"Property type",
"lat",
"lon",
"Total floor area (sqm)",
"Last known price",
"Date of last transaction",
)
.filter(
pl.col("lat").is_not_null(),
pl.col("lon").is_not_null(),
pl.col("Total floor area (sqm)").is_not_null(),
pl.col("Total floor area (sqm)") > 0,
pl.col("Last known price").is_not_null(),
pl.col("Last known price") > 0,
pl.col("Postcode").is_not_null(),
pl.col("Date of last transaction").is_not_null(),
)
)
if max_sale_year is not None:
query = query.filter(
pl.col("Date of last transaction").dt.year() < max_sale_year
)
pool = (
query.with_columns(
sector_expr(),
type_group_expr(),
(
pl.col("Date of last transaction").dt.year().cast(pl.Float64)
+ (
pl.col("Date of last transaction").dt.month().cast(pl.Float64)
- 1.0
)
/ 12.0
).alias("_sale_fy"),
pl.lit(ref_frac_year).alias("_ref_fy"),
).collect()
)
pool = pool.filter(pl.col("type_group").is_not_null())
print(f" {len(pool):,} pool properties with lat/lon, floor area, price")
# Interpolate log_index at sale date and reference date
pool = interpolate_log_index(
index, pool, "sector", "type_group", "_sale_fy", "_li_sale"
)
pool = interpolate_log_index(
index, pool, "sector", "type_group", "_ref_fy", "_li_ref"
)
# adjusted_psm = price / floor_area * exp(log_index_ref - log_index_sale)
pool = pool.with_columns(
(
pl.col("Last known price").cast(pl.Float64)
/ pl.col("Total floor area (sqm)").cast(pl.Float64)
* (pl.col("_li_ref") - pl.col("_li_sale")).exp()
).alias("_adj_psm")
).filter(
pl.col("_adj_psm").is_not_null(),
pl.col("_adj_psm").is_finite(),
pl.col("_adj_psm") > 0,
)
print(f" {len(pool):,} after index adjustment")
# Build per-type KD-trees
trees: dict[str, tuple[KDTree, np.ndarray]] = {}
for tg in TYPE_GROUPS:
sub = pool.filter(pl.col("type_group") == tg)
n = len(sub)
if n < KNN_MIN_NEIGHBORS:
continue
lat = sub["lat"].to_numpy().astype(np.float64)
lon = sub["lon"].to_numpy().astype(np.float64)
psm = sub["_adj_psm"].to_numpy().astype(np.float64)
tree = KDTree(_scale_coords(lat, lon))
trees[tg] = (tree, psm)
print(f" {tg}: {n:,}")
return trees
def knn_median_psm(
trees: dict[str, tuple[KDTree, np.ndarray]],
lat: np.ndarray,
lon: np.ndarray,
type_groups: np.ndarray,
k: int = KNN_K,
) -> np.ndarray:
"""Return median adjusted-PSM of k nearest neighbours for each target.
PSM is at the reference date used when building the pool.
NaN where not computable (missing coords, unknown type, too few neighbors).
"""
n = len(lat)
result = np.full(n, np.nan)
for tg, (tree, psm) in trees.items():
mask = (type_groups == tg) & np.isfinite(lat) & np.isfinite(lon)
idx = np.where(mask)[0]
if len(idx) == 0:
continue
actual_k = min(k, len(psm))
if actual_k < KNN_MIN_NEIGHBORS:
continue
coords = _scale_coords(lat[idx], lon[idx])
_, nn_idx = tree.query(coords, k=actual_k)
if nn_idx.ndim == 1:
nn_idx = nn_idx.reshape(-1, 1)
result[idx] = np.nanmedian(psm[nn_idx], axis=1)
return result

View file

@ -0,0 +1,140 @@
"""Hierarchical shrinkage and spatial smoothing for sector-level estimates."""
from typing import Callable, TypeVar
import numpy as np
from scipy.spatial import KDTree
from pipeline.transform.price_estimation.utils import SHRINKAGE_K
V = TypeVar("V")
SPATIAL_NEIGHBORS = 5
SPATIAL_BLEND_K = 30
def shrink_dicts(raw: dict, parent: dict, n: int) -> dict:
"""Shrink dict values toward parent using n/(n+k) weighting.
Works for any dict keyed by year or category.
"""
w = n / (n + SHRINKAGE_K)
result = {}
for key in set(raw) | set(parent):
r = raw.get(key, parent.get(key, 0.0))
p = parent.get(key, raw.get(key, 0.0))
result[key] = w * r + (1 - w) * p
return result
def hierarchical_shrinkage(
sector_vals: dict[str, V],
sector_n: dict[str, int],
district_vals: dict[str, V],
district_n: dict[str, int],
area_vals: dict[str, V],
area_n: dict[str, int],
top_level: V,
all_sectors: list[str],
sector_to_dist: dict[str, str],
dist_to_area: dict[str, str],
shrink_fn: Callable[[V, V, int], V],
) -> dict[str, V]:
"""Top-down hierarchical shrinkage: area->top, district->area, sector->district.
`top_level` is the ultimate fallback value (e.g. national shrunk toward hedonic,
or just national). `shrink_fn(raw, parent, n)` blends raw toward parent.
"""
# Area -> top level
area_shrunk = {}
for area, val in area_vals.items():
area_shrunk[area] = shrink_fn(val, top_level, area_n[area])
# District -> area
district_shrunk = {}
for dist, val in district_vals.items():
a = dist_to_area.get(dist, "")
parent = area_shrunk.get(a, top_level)
district_shrunk[dist] = shrink_fn(val, parent, district_n[dist])
# Sector -> district
sector_shrunk = {}
for sec, val in sector_vals.items():
d = sector_to_dist.get(sec, "")
parent = district_shrunk.get(d, top_level)
sector_shrunk[sec] = shrink_fn(val, parent, sector_n[sec])
# Fill sectors without their own values
for sec in all_sectors:
if sec not in sector_shrunk:
d = sector_to_dist.get(sec, "")
a = dist_to_area.get(d, "")
sector_shrunk[sec] = district_shrunk.get(d, area_shrunk.get(a, top_level))
return sector_shrunk
def spatial_smooth(
sector_values: dict[str, V],
centroids: dict[str, tuple[float, float]],
counts: dict[str, int],
blend_fn: Callable[[V, list[V], float, list[float]], V],
) -> dict[str, V]:
"""Blend sparse sector values with K nearest neighbors via KDTree."""
sectors_with_coords = [s for s in sector_values if s in centroids]
if len(sectors_with_coords) < SPATIAL_NEIGHBORS + 1:
return sector_values
coords = np.array([centroids[s] for s in sectors_with_coords])
# Scale longitude by cos(mean_lat) for approximate Euclidean distance
mean_lat = np.mean(coords[:, 0])
scale = np.cos(np.radians(mean_lat))
scaled_coords = np.column_stack([coords[:, 0], coords[:, 1] * scale])
tree = KDTree(scaled_coords)
result = dict(sector_values)
for i, sec in enumerate(sectors_with_coords):
n = counts.get(sec, 0)
self_w = n / (n + SPATIAL_BLEND_K)
if self_w > 0.95:
continue # enough data, skip smoothing
dists, idxs = tree.query(scaled_coords[i], k=SPATIAL_NEIGHBORS + 1)
# Skip self (index 0, distance ~0)
neighbor_dists = dists[1:]
neighbor_idxs = idxs[1:]
inv_dists = []
neighbor_vals = []
for d, j in zip(neighbor_dists, neighbor_idxs):
ns = sectors_with_coords[j]
if d > 0 and ns in sector_values:
inv_dists.append(1.0 / d)
neighbor_vals.append(sector_values[ns])
if not neighbor_vals:
continue
total_inv = sum(inv_dists)
nbr_w = 1.0 - self_w
neighbor_ws = [iw / total_inv * nbr_w for iw in inv_dists]
result[sec] = blend_fn(sector_values[sec], neighbor_vals, self_w, neighbor_ws)
return result
def blend_dicts(
self_val: dict, neighbor_vals: list[dict], self_w: float, neighbor_ws: list[float]
) -> dict:
"""Blend dict values by weighted sum across all keys."""
all_keys: set = set(self_val)
for nv in neighbor_vals:
all_keys |= set(nv)
result = {}
for k in all_keys:
val = self_w * self_val.get(k, 0.0)
for nv, w in zip(neighbor_vals, neighbor_ws):
val += w * nv.get(k, 0.0)
result[k] = val
return result

View file

@ -0,0 +1,233 @@
"""Shared utilities for price estimation modules."""
from datetime import date
from pathlib import Path
import numpy as np
import polars as pl
CURRENT_YEAR = 2026
_today = date.today()
CURRENT_FRAC_YEAR = _today.year + (_today.month - 1) / 12
CURRENT_MONTH = _today.month
# Cap on log(index_ratio) to prevent wild estimates from thin sectors
MAX_LOG_ADJUSTMENT = 3.0 # ~20x max price change
TERRACE_TYPES = [
"Mid-Terrace",
"End-Terrace",
"Enclosed Mid-Terrace",
"Enclosed End-Terrace",
"Terraced",
]
FLAT_TYPES = ["Flats/Maisonettes", "Flat", "Maisonette"]
TYPE_GROUPS = ["Detached", "Semi-Detached", "Terraced", "Flats", "Bungalow"]
SHRINKAGE_K = 50
def type_group_expr():
"""Polars expression: Property type -> type_group."""
return (
pl.when(pl.col("Property type").is_in(TERRACE_TYPES))
.then(pl.lit("Terraced"))
.when(pl.col("Property type").is_in(FLAT_TYPES))
.then(pl.lit("Flats"))
.when(pl.col("Property type") == "Bungalow")
.then(pl.lit("Bungalow"))
.when(pl.col("Property type").is_in(["Detached", "Semi-Detached"]))
.then(pl.col("Property type"))
.otherwise(pl.lit(None))
.alias("type_group")
)
def sector_expr():
"""Polars expression: Postcode -> sector (drop last 2 chars, strip)."""
return (
pl.col("Postcode")
.str.slice(0, pl.col("Postcode").str.len_chars() - 2)
.str.strip_chars()
.alias("sector")
)
def hierarchy_keys(sector: str) -> tuple[str, str]:
"""Return (district, area) for a sector string."""
district = sector.rsplit(" ", 1)[0] if " " in sector else sector
area = ""
for ch in district:
if ch.isalpha():
area += ch
else:
break
return district, area
NON_REF_TYPES = ["Terraced", "Semi-Detached", "Flats", "Bungalow"]
def build_hedonic_features(df: pl.DataFrame) -> np.ndarray:
"""Build hedonic feature matrix: log(floor_area) + 4 type dummies (ref: Detached)."""
fa = df["Total floor area (sqm)"].to_numpy().astype(np.float32)
log_fa = np.log(np.maximum(fa, 1.0)).reshape(-1, 1)
tg = df["type_group"].to_numpy()
parts = [log_fa]
for t in NON_REF_TYPES:
parts.append((tg == t).astype(np.float32).reshape(-1, 1))
return np.hstack(parts)
def interpolate_log_index(
index: pl.DataFrame,
df: pl.DataFrame,
sector_col: str,
type_col: str,
frac_year_col: str,
output_alias: str,
) -> pl.DataFrame:
"""Join and interpolate log_index at fractional years.
For frac_year 2019.75: joins index at year=2019 and year=2020,
then linearly interpolates: 0.25*idx_2019 + 0.75*idx_2020.
Falls back to floor or ceil when the other is missing.
"""
floor_col = f"_{output_alias}_floor"
ceil_col = f"_{output_alias}_ceil"
floor_year = f"_{output_alias}_floor_year"
ceil_year = f"_{output_alias}_ceil_year"
frac_col = f"_{output_alias}_frac"
df = df.with_columns(
pl.col(frac_year_col).floor().cast(pl.Int32).alias(floor_year),
pl.col(frac_year_col).ceil().cast(pl.Int32).alias(ceil_year),
(pl.col(frac_year_col) - pl.col(frac_year_col).floor()).alias(frac_col),
)
df = join_type_stratified_index(
df, index, sector_col, type_col, floor_year, floor_col
)
df = join_type_stratified_index(
df, index, sector_col, type_col, ceil_year, ceil_col
)
# Interpolate: (1-frac)*floor + frac*ceil, with fallbacks
df = df.with_columns(
pl.when(pl.col(floor_col).is_not_null() & pl.col(ceil_col).is_not_null())
.then(
(1.0 - pl.col(frac_col)) * pl.col(floor_col)
+ pl.col(frac_col) * pl.col(ceil_col)
)
.when(pl.col(floor_col).is_not_null())
.then(pl.col(floor_col))
.when(pl.col(ceil_col).is_not_null())
.then(pl.col(ceil_col))
.otherwise(pl.lit(None))
.alias(output_alias),
).drop(floor_col, ceil_col, floor_year, ceil_year, frac_col)
return df
def extract_centroids(input_path) -> dict[str, tuple[float, float]]:
"""Compute mean lat/lon per postcode sector."""
print("Computing sector centroids...")
df = (
pl.scan_parquet(input_path)
.select("Postcode", "lat", "lon")
.filter(pl.col("Postcode").is_not_null(), pl.col("lat").is_not_null())
.with_columns(sector_expr())
.group_by("sector")
.agg(pl.col("lat").mean(), pl.col("lon").mean())
.collect()
)
centroids = {}
for row in df.iter_rows(named=True):
centroids[row["sector"]] = (row["lat"], row["lon"])
print(f" {len(centroids):,} sector centroids")
return centroids
def join_type_stratified_index(
df: pl.DataFrame,
index: pl.DataFrame,
sector_col: str,
type_col: str,
year_col: str,
output_alias: str,
) -> pl.DataFrame:
"""Join price index with typed->All fallback. Returns df with `output_alias` column."""
idx_typed = index.filter(pl.col("type_group") != "All")
idx_all = index.filter(pl.col("type_group") == "All")
_typed = f"_{output_alias}_typed"
_all = f"_{output_alias}_all"
df = df.join(
idx_typed.select(
"sector", "type_group", "year", pl.col("log_index").alias(_typed)
),
left_on=[sector_col, type_col, year_col],
right_on=["sector", "type_group", "year"],
how="left",
).join(
idx_all.select("sector", "year", pl.col("log_index").alias(_all)),
left_on=[sector_col, year_col],
right_on=["sector", "year"],
how="left",
)
df = df.with_columns(
pl.col(_typed).fill_null(pl.col(_all)).alias(output_alias),
).drop(_typed, _all)
return df
def compute_seasonal_factors(
input_path: Path, max_sale_year: int | None = None
) -> np.ndarray:
"""Compute 12 multiplicative monthly price factors from price-per-sqm.
Detrends by normalizing median £/sqm within each year, then averages
across years. Returns array of 12 factors (index 0 = January).
Normalized so mean = 1.0.
"""
query = (
pl.scan_parquet(input_path)
.select("Last known price", "Total floor area (sqm)", "Date of last transaction")
.filter(
pl.col("Last known price").is_not_null(),
pl.col("Last known price") > 0,
pl.col("Total floor area (sqm)").is_not_null(),
pl.col("Total floor area (sqm)") > 0,
pl.col("Date of last transaction").is_not_null(),
)
.with_columns(
(
pl.col("Last known price").cast(pl.Float64)
/ pl.col("Total floor area (sqm)").cast(pl.Float64)
).alias("psm"),
pl.col("Date of last transaction").dt.month().alias("month"),
pl.col("Date of last transaction").dt.year().alias("year"),
)
)
if max_sale_year is not None:
query = query.filter(pl.col("year") < max_sale_year)
monthly = (
query.group_by("year", "month")
.agg(pl.col("psm").median().alias("median_psm"))
.with_columns(
pl.col("median_psm").mean().over("year").alias("year_mean"),
)
.with_columns(
(pl.col("median_psm") / pl.col("year_mean")).alias("ratio"),
)
.group_by("month")
.agg(pl.col("ratio").mean().alias("factor"))
.sort("month")
.collect()
)
factors = monthly["factor"].to_numpy().astype(np.float64)
return factors / factors.mean()

View file

@ -100,6 +100,7 @@ DROP_CATEGORIES = {
"building/entrance",
"building/entry",
"building/farm",
"building/farm_auxiliary",
"building/garage",
"building/garages",
"building/greenhouse",

View file

@ -2,6 +2,7 @@ from .download import download, extract_zip
from .fuzzy_join import fuzzy_join_on_postcode
from .haversine import haversine_km, haversine_km_expr
from .poi_counts import count_pois_per_postcode
from .postcode_mapping import build_postcode_mapping
__all__ = [
"download",
@ -10,4 +11,5 @@ __all__ = [
"haversine_km",
"haversine_km_expr",
"count_pois_per_postcode",
"build_postcode_mapping",
]

View file

@ -154,14 +154,16 @@ def fuzzy_join_on_postcode(
left_cached = pl.scan_parquet(left_path)
right_cached = pl.scan_parquet(right_path)
return (
result = (
left_cached.join(mapping, on="_left_idx", how="left")
.join(right_cached, on="_right_idx", how="left")
.drop("_left_idx", "_right_idx")
.collect(engine="streaming")
)
except BaseException:
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
raise
return result.lazy()
def _numbers_compatible(a: str, b: str) -> bool:
@ -180,7 +182,7 @@ def _numbers_compatible(a: str, b: str) -> bool:
def _score_bucket(
args: tuple[list[tuple[int, str]], list[tuple[int, str]], int],
args: tuple[list[tuple[int, str]], list[tuple[int, str]]],
) -> list[tuple[int, int, int]]:
"""Score all address pairs within a single postcode bucket."""
left_entries, right_entries = args

View file

@ -0,0 +1,40 @@
"""Map terminated postcodes to their nearest active successor using OS grid coordinates."""
from pathlib import Path
import numpy as np
import polars as pl
from scipy.spatial import cKDTree
def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame:
"""Build a mapping from terminated England postcodes to their nearest active postcode.
Uses OS National Grid coordinates (oseast1m, osnrth1m) which are Cartesian meters,
so Euclidean distance via cKDTree gives accurate results without projection.
"""
arcgis = pl.scan_parquet(arcgis_path).filter(pl.col("ctry") == "E92000001")
active = arcgis.filter(pl.col("doterm").is_null()).select("pcds", "oseast1m", "osnrth1m").collect()
terminated = arcgis.filter(pl.col("doterm").is_not_null()).select("pcds", "oseast1m", "osnrth1m").collect()
print(f"Active postcodes: {active.height}, terminated postcodes: {terminated.height}")
if terminated.height == 0:
return pl.DataFrame({"old_postcode": pl.Series([], dtype=pl.Utf8), "new_postcode": pl.Series([], dtype=pl.Utf8)})
active_coords = np.column_stack([active["oseast1m"].to_numpy(), active["osnrth1m"].to_numpy()])
terminated_coords = np.column_stack([terminated["oseast1m"].to_numpy(), terminated["osnrth1m"].to_numpy()])
tree = cKDTree(active_coords)
distances, indices = tree.query(terminated_coords)
active_postcodes = active["pcds"]
mapping = pl.DataFrame({
"old_postcode": terminated["pcds"],
"new_postcode": active_postcodes.gather(indices),
})
print(f"Postcode mapping: max distance = {distances.max():.0f}m, median = {np.median(distances):.0f}m")
return mapping

View file

@ -1,7 +1,14 @@
import polars as pl
import pytest
from pipeline.utils.poi_counts import POI_GROUPS, count_pois_within_radius
from pipeline.utils.poi_counts import count_pois_per_postcode
POI_GROUPS = {
"restaurants": ["Restaurant", "Fast Food"],
"groceries": ["Supermarket"],
"parks": ["Park"],
"public_transport": ["Station"],
}
@pytest.fixture
@ -24,41 +31,39 @@ def pois():
@pytest.fixture
def properties():
"""Two properties at the same postcode near central London, one at a distant postcode."""
def postcodes():
"""Two postcodes: one near central London, one far away."""
return pl.DataFrame(
{
"postcode": ["EC1A 1BB", "EC1A 1BB", "ZZ99 9ZZ"],
"lat": [51.5074, 51.5074, 55.0],
"lon": [-0.1278, -0.1278, -3.0],
"postcode": ["EC1A 1BB", "ZZ99 9ZZ"],
"lat": [51.5074, 55.0],
"lon": [-0.1278, -3.0],
}
)
def test_counts_pois_within_radius(properties, pois):
result = count_pois_within_radius(properties, pois, radius_km=2.0)
def test_counts_pois_within_radius(postcodes, pois):
result = count_pois_per_postcode(postcodes, pois, groups=POI_GROUPS, radius_km=2.0)
assert set(result.keys()) == {f"{g}_2km" for g in POI_GROUPS}
expected_cols = {f"{g}_2km" for g in POI_GROUPS}
assert expected_cols.issubset(set(result.columns))
# Result Series must be aligned to properties (3 rows)
for col, series in result.items():
assert len(series) == 3, f"{col} has {len(series)} rows, expected 3"
# Result must be aligned to postcodes (2 rows)
assert len(result) == 2
# First two rows share a postcode near the central London cluster
assert result["restaurants_2km"][0] == 2 # Restaurant + Fast Food
assert result["groceries_2km"][0] == 1 # Supermarket
assert result["parks_2km"][0] == 1 # Park
assert result["public_transport_2km"][0] == 1 # Station
ec1a = result.filter(pl.col("postcode") == "EC1A 1BB")
assert ec1a["restaurants_2km"][0] == 2 # Restaurant + Fast Food
assert ec1a["groceries_2km"][0] == 1 # Supermarket
assert ec1a["parks_2km"][0] == 1 # Park
assert ec1a["public_transport_2km"][0] == 1 # Station
# Second row is the same postcode, so same counts
assert result["restaurants_2km"][1] == result["restaurants_2km"][0]
# Third row (ZZ99 9ZZ) is far from all POIs → zero counts
# Far-away postcode should have zero counts
zz99 = result.filter(pl.col("postcode") == "ZZ99 9ZZ")
for group in POI_GROUPS:
assert result[f"{group}_2km"][2] == 0
assert zz99[f"{group}_2km"][0] == 0
def test_no_pois_returns_zeros(properties):
def test_no_pois_returns_zeros(postcodes):
empty_pois = pl.DataFrame(
{
"lat": pl.Series([], dtype=pl.Float64),
@ -66,17 +71,17 @@ def test_no_pois_returns_zeros(properties):
"category": pl.Series([], dtype=pl.String),
}
)
result = count_pois_within_radius(properties, empty_pois, radius_km=2.0)
result = count_pois_per_postcode(postcodes, empty_pois, groups=POI_GROUPS, radius_km=2.0)
for group in POI_GROUPS:
col = f"{group}_2km"
assert col in result
assert result[col].to_list() == [0, 0, 0]
assert col in result.columns
assert result[col].to_list() == [0, 0]
def test_custom_radius(pois):
"""A tiny radius should exclude POIs that are even slightly away."""
properties = pl.DataFrame(
postcodes = pl.DataFrame(
{
"postcode": ["EC1A 1BB"],
"lat": [51.5074],
@ -85,7 +90,7 @@ def test_custom_radius(pois):
)
# 0.01 km = 10m — only the POI at the exact same location should match
result = count_pois_within_radius(properties, pois, radius_km=0.01)
result = count_pois_per_postcode(postcodes, pois, groups=POI_GROUPS, radius_km=0.01)
# The Restaurant at (51.5074, -0.1278) is at distance 0
assert result["restaurants_0km"][0] >= 1
# POIs >100m away should not be counted

73
pitch.md Normal file
View file

@ -0,0 +1,73 @@
Get more home for your money.
Buying a home may be your most important decision. Why not ensure you make your best-ever decision?
You have so. many. options. Picking the best one is daunting and stressful. It won't be anymore when looking at the property landscape through our dashboard.
---
## The problem
You're about to spend £300,000600,000 on a home. Your research method? Scrolling through listings and hoping for the best.
Listings only show what's on the market right now — a tiny, random slice of what's actually out there. You'll never see the 3-bed Victorian on a quiet street that sold six months ago, or the one that'll list next month. And even if you could see everything, you're choosing between 1.5 million postcodes in England. You can't research them all one by one.
So most people just... wing it. They pick a few areas they've heard of, browse whatever happens to be listed, and hope the place they end up in is the right one.
Your home is not a box of cereal. Don't let a discount on the wrong property distract you from finding the right one.
---
## What this actually does
Set your requirements. The map shows you where they intersect. That's it.
{{demo — 3 sliders, map reacts}}
Say you want a 3-bed under £450k, within 30 minutes of King's Cross by train, low crime, a park nearby, and decent broadband. Set those six filters. The map lights up with exactly the postcode clusters that pass every single one. Areas you'd never have thought to look at appear. Areas you assumed were perfect go dark.
You haven't opened a single listing yet — and you already know exactly where to focus.
That's just three filters. We've built {{number}}.
Spanning property prices, commute times, school ratings, crime rates, broadband speeds, road noise, energy efficiency, amenities, deprivation scores, demographics, and more. All layered on top of each other, all filterable at once.
---
## Why existing tools don't cut it
**Listing portals** show you what's for sale today. That's a snapshot, not a strategy. You can filter by price and bedrooms — that's about it. They tell you nothing about the area.
**"Check my postcode" sites** give you stats for one postcode at a time. Useful if you already know where to look. Useless if you don't — and there are 1.5 million postcodes in England. You'd be there a while.
**Area guides** show one statistic on a map — crime, or school ratings, or prices. But you don't care about one thing in isolation. You care about the intersection: affordable AND safe AND good schools AND short commute. Nobody else shows you that.
We do. 13 million historical transactions. 56 data layers. Real travel-time routing to any destination. Every postcode in England, scored and filterable, on a single map.
---
## How to use it
{{funnel picture}}
**1. Set your non-negotiables** — Budget, commute, bedrooms, whatever matters most. The map narrows to only the areas that qualify.
**2. Explore what's left** — Zoom in. Toggle layers. See crime, schools, noise, amenities. Discover areas you didn't know existed.
**3. Drill into postcodes** — At street level, see individual properties, what they sold for, floor area, energy rating, estimated current value.
**4. Go to viewings with a shortlist, not a prayer** — You've already done the hard part. Every area on your list meets your actual criteria, not just what happened to be listed that week.
{{/funnel picture}}
---
## The real cost of getting this wrong
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.
One payment. Lifetime access. Less than your survey costs and vastly more useful.
The biggest financial decision of your life deserves proper tools behind it.
{{button — Explore the map}}

View file

@ -14,13 +14,12 @@ set -euo pipefail
# - places_ref.parquet: place order reference
#
# Usage:
# ./r5-java/run.sh # 4 threads, 16g heap
# ./r5-java/run.sh --threads 8
# ./r5-java/run.sh --heap 24g
# ./r5-java/run.sh
# ./r5-java/run.sh --threads 8 --heap 24g
# --- Defaults ---
THREADS=28
HEAP=40g
THREADS=16
HEAP=16g
NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times
R5_DIR=r5-java
@ -125,5 +124,5 @@ java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
echo ""
echo "=== Complete ==="
echo "Output: $OUTPUT_BASE/{car,bicycle,walking,transit}/"
echo "Output: $OUTPUT_BASE/{car,bicycle,walking,transit}/{place-name}.parquet"
echo "Reference: $OUTPUT_BASE/postcodes_ref.parquet, $OUTPUT_BASE/places_ref.parquet"

View file

@ -16,15 +16,19 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Batch-compute travel times from each origin (place) to all destinations (postcodes)
* Batch-compute travel times from each origin (place) to nearby postcodes
* for all transport modes (car, bicycle, walking, transit).
*
* Output per mode: one parquet file per origin in {output-dir}/{mode}/{index}.parquet
* with columns (pcds VARCHAR, travel_minutes SMALLINT). -1 = unreachable within 120 min.
* Each origin is spatially pre-filtered to only route to postcodes within a
* plausible travel radius for the mode. Output is sparse: only reachable
* 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).
*/
public class App {
private static final String[] MODES = {"car", "bicycle", "walking", "transit"};
private static final String[] MODES = {"bicycle", "walking", "transit", "car"};
private static final int MAX_RETRIES = 2;
public static void main(String[] args) throws Exception {
@ -42,18 +46,14 @@ public class App {
System.err.println("Loading postcodes (England only)...");
Parquet.Postcodes postcodes = Parquet.loadEnglandPostcodes(
postcodesPath, outDir.resolve("postcodes_ref.parquet"));
int nDest = postcodes.lats().length;
System.err.printf(" %,d postcodes%n", nDest);
List<Router.DestinationChunk> chunks = Router.buildDestinationChunks(postcodes.lats(), postcodes.lons());
System.err.printf(" %,d postcodes%n", postcodes.lats().length);
System.err.println("Loading places (deduplicated)...");
double[][] placesLatLon = Parquet.loadPlaces(placesPath, outDir.resolve("places_ref.parquet"));
double[] originLats = placesLatLon[0], originLons = placesLatLon[1];
Parquet.Places places = Parquet.loadPlaces(placesPath, outDir.resolve("places_ref.parquet"));
String[] originNames = places.names();
double[] originLats = places.lats(), originLons = places.lons();
int nOrigins = originLats.length;
System.err.printf(" %,d places%n", nOrigins);
System.err.printf(" Estimated output: %.1f GB (%,d x %,d x 2B)%n",
(double) nOrigins * nDest * 2 / 1e9, nOrigins, nDest);
// One thread pool shared across all modes
ExecutorService pool = Executors.newFixedThreadPool(threads);
@ -65,8 +65,8 @@ public class App {
try {
for (String mode : MODES) {
processMode(network, chunks, postcodes.codes(), originLats, originLons,
nDest, outDir, mode, today, pool, threadConn);
processMode(network, postcodes.codes(), postcodes.lats(), postcodes.lons(),
originNames, originLats, originLons, outDir, mode, today, pool, threadConn);
}
} finally {
pool.shutdown();
@ -75,17 +75,19 @@ public class App {
}
private static void processMode(
TransportNetwork network, List<Router.DestinationChunk> chunks,
String[] postcodes, double[] originLats, double[] originLons, int nDest,
TransportNetwork network,
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
String[] originNames, double[] originLats, double[] originLons,
Path outDir, String mode, LocalDate date,
ExecutorService pool, ThreadLocal<DuckDBConnection> threadConn) throws Exception {
int nOrigins = originLats.length;
System.err.printf("%n=== %s ===%n", mode.toUpperCase());
System.err.printf(" Radius: %.0f km%n", Router.maxRadiusKm(mode));
Path modeDir = outDir.resolve(mode);
Files.createDirectories(modeDir);
List<Integer> remaining = findRemaining(modeDir, nOrigins);
List<Integer> remaining = findRemaining(modeDir, originNames);
int alreadyDone = nOrigins - remaining.size();
System.err.printf(" %,d done, %,d remaining%n", alreadyDone, remaining.size());
@ -121,12 +123,13 @@ public class App {
for (int idx : remaining) {
pool.submit(() -> {
try {
processOrigin(network, chunks, postcodes, originLats[idx], originLons[idx],
nDest, modeDir, mode, date, idx, threadConn.get());
processOrigin(network, postcodes, postcodeLats, postcodeLons,
originLats[idx], originLons[idx],
modeDir, mode, date, originNames[idx], threadConn.get());
completed.incrementAndGet();
} catch (Exception e) {
failed.incrementAndGet();
System.err.printf("%n [FAIL] origin %d: %s%n", idx, e.getMessage());
System.err.printf("%n [FAIL] origin %s: %s%n", originNames[idx], e.getMessage());
} finally {
latch.countDown();
}
@ -144,24 +147,43 @@ public class App {
/** Compute and write travel times for a single origin, with retry on failure. */
private static void processOrigin(
TransportNetwork network, List<Router.DestinationChunk> chunks,
String[] postcodes, double lat, double lon, int nDest,
Path modeDir, String mode, LocalDate date, int idx,
TransportNetwork network,
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
double originLat, double originLon,
Path modeDir, String mode, LocalDate date, String name,
DuckDBConnection conn) throws Exception {
Path outPath = modeDir.resolve(String.format("%06d.parquet", idx));
Path outPath = modeDir.resolve(sanitizeFilename(name) + ".parquet");
Exception lastError = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
short[] times = Router.computeTravelTimes(network, chunks, lat, lon, mode, nDest, date);
Parquet.writeTravelTimes(conn, outPath, postcodes, times);
Router.FilteredResult result = Router.computeForOrigin(
network, postcodeLats, postcodeLons,
originLat, originLon, mode, date);
// Write only reachable postcodes (sparse output)
int reachable = 0;
for (short t : result.times()) if (t >= 0) reachable++;
String[] codes = new String[reachable];
short[] times = new short[reachable];
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];
j++;
}
}
Parquet.writeTravelTimes(conn, outPath, codes, times);
return;
} catch (Exception e) {
lastError = e;
if (attempt < MAX_RETRIES) {
System.err.printf("%n [RETRY %d/%d] origin %d: %s%n",
attempt + 1, MAX_RETRIES, idx, e.getMessage());
System.err.printf("%n [RETRY %d/%d] %s: %s%n",
attempt + 1, MAX_RETRIES, name, e.getMessage());
}
}
}
@ -169,10 +191,10 @@ public class App {
}
/** Find origin indices that don't yet have output parquet files. */
private static List<Integer> findRemaining(Path modeDir, int nOrigins) throws Exception {
private static List<Integer> findRemaining(Path modeDir, String[] names) throws Exception {
List<Integer> remaining = new ArrayList<>();
for (int i = 0; i < nOrigins; i++) {
Path f = modeDir.resolve(String.format("%06d.parquet", i));
for (int i = 0; i < names.length; i++) {
Path f = modeDir.resolve(sanitizeFilename(names[i]) + ".parquet");
if (!Files.exists(f) || Files.size(f) == 0) {
remaining.add(i);
}
@ -180,6 +202,13 @@ 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()
.replaceAll("[^a-z0-9 -]", "")
.replaceAll("\\s+", "-");
}
private static String requiredArg(String[] args, String name) {
for (int i = 0; i < args.length - 1; i++) {
if (args[i].equals(name)) return args[i + 1];

View file

@ -16,6 +16,7 @@ import java.util.List;
public class Parquet {
record Postcodes(String[] codes, double[] lats, double[] lons) {}
record Places(String[] names, double[] lats, double[] lons) {}
static {
try { Class.forName("org.duckdb.DuckDBDriver"); }
@ -26,7 +27,7 @@ public class Parquet {
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'");
+ parquetPath + "') WHERE ctry = 'E92000001' AND doterm IS NULL");
copyToParquet(stmt, "SELECT * FROM postcodes", refOut);
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM postcodes")) {
@ -50,8 +51,8 @@ public class Parquet {
}
}
/** Load places deduplicated by lat/lon, write reference parquet, return flat lat/lon arrays. */
static double[][] loadPlaces(String parquetPath, Path refOut) throws Exception {
/** Load places deduplicated by lat/lon, write reference parquet, return names + flat lat/lon arrays. */
static Places loadPlaces(String parquetPath, Path refOut) throws Exception {
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 "
@ -61,19 +62,20 @@ public class Parquet {
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM places")) {
rs.next();
int n = rs.getInt(1);
// Return as [lats, lons] flat arrays
String[] names = new String[n];
double[] lats = new double[n];
double[] lons = new double[n];
try (ResultSet data = stmt.executeQuery("SELECT lat, lon FROM places")) {
try (ResultSet data = stmt.executeQuery("SELECT name, lat, lon FROM places")) {
int i = 0;
while (data.next()) {
lats[i] = data.getDouble(1);
lons[i] = data.getDouble(2);
names[i] = data.getString(1);
lats[i] = data.getDouble(2);
lons[i] = data.getDouble(3);
i++;
}
}
return new double[][]{lats, lons};
return new Places(names, lats, lons);
}
}
}

View file

@ -20,17 +20,25 @@ import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
/** R5 routing: network loading, point set construction, travel time computation. */
/** R5 routing: network loading, spatial filtering, travel time computation. */
public class Router {
private static final int ZOOM = 9;
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
/**
* A chunk of destinations that fits within R5's grid cell limit at zoom 9.
* originalIndices maps each position in this chunk back to the full destinations array.
*/
record DestinationChunk(FreeFormPointSet pointSet, WebMercatorExtents extents, int[] originalIndices) {}
/** Result of computing travel times for a single origin with spatial pre-filtering. */
record FilteredResult(int[] originalIndices, short[] times) {}
/** Max plausible travel radius in km for 120-minute trips. */
static double maxRadiusKm(String mode) {
return switch (mode) {
case "car" -> 150;
case "transit" -> 150;
case "bicycle" -> 60;
case "walking" -> 12;
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
};
}
/** Load or build the transport network with Kryo caching. */
static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception {
@ -56,10 +64,80 @@ public class Router {
}
/**
* Split destinations into geographic chunks that each fit within R5's grid cell limit.
* Sorts by latitude and splits into bands so each band's bounding box at zoom 9 is under 5M cells.
* Filter destinations by distance, build chunks, compute travel times for one origin.
* Returns only the filtered subset indices and their travel times.
*/
static List<DestinationChunk> buildDestinationChunks(double[] lats, double[] lons) {
static FilteredResult computeForOrigin(
TransportNetwork network,
double[] allLats, double[] allLons,
double originLat, double originLon,
String mode, LocalDate date) {
double maxRadius = maxRadiusKm(mode);
// 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]);
}
// 2. Extract filtered coordinate arrays
double[] fLats = new double[filtered.length];
double[] fLons = new double[filtered.length];
for (int i = 0; i < filtered.length; i++) {
fLats[i] = allLats[filtered[i]];
fLons[i] = allLons[filtered[i]];
}
// 3. Build chunks from filtered destinations
List<DestinationChunk> chunks = buildDestinationChunks(fLats, fLons);
// 4. Compute travel times
short[] times = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date);
return new FilteredResult(filtered, times);
}
/**
* Filter destination indices to those within a bounding box of maxRadiusKm from origin.
* Uses degree-based approximation slightly overestimates at corners, which is fine.
*/
private static int[] filterByDistance(
double[] lats, double[] lons,
double originLat, double originLon,
double maxRadiusKm) {
double degLat = maxRadiusKm / 111.0;
double degLon = maxRadiusKm / (111.0 * Math.cos(Math.toRadians(originLat)));
double minLat = originLat - degLat;
double maxLat = originLat + degLat;
double minLon = originLon - degLon;
double maxLon = originLon + degLon;
// Two-pass: count then fill (avoids ArrayList/boxing overhead)
int count = 0;
for (int i = 0; i < lats.length; i++) {
if (lats[i] >= minLat && lats[i] <= maxLat && lons[i] >= minLon && lons[i] <= maxLon) {
count++;
}
}
int[] result = new int[count];
int j = 0;
for (int i = 0; i < lats.length; i++) {
if (lats[i] >= minLat && lats[i] <= maxLat && lons[i] >= minLon && lons[i] <= maxLon) {
result[j++] = i;
}
}
return result;
}
/**
* Split destinations into geographic chunks that each fit within R5's grid cell limit.
* Sorts by latitude and splits into bands so each band's bounding box is under 5M cells.
*/
private static List<DestinationChunk> buildDestinationChunks(double[] lats, double[] lons) {
int n = lats.length;
// Sort indices by latitude for geographic chunking
@ -94,13 +172,11 @@ public class Router {
start = end;
}
System.err.printf(" Split into %d chunks at zoom %d (grid width %d, max height %d)%n",
chunks.size(), ZOOM, gridWidth, maxHeight);
return chunks;
}
/** Compute travel times from one origin to all destinations across all chunks. */
static short[] computeTravelTimes(
private static short[] computeTravelTimes(
TransportNetwork network, List<DestinationChunk> chunks,
double originLat, double originLon, String mode, int nDest, LocalDate date) {
@ -125,6 +201,10 @@ public class Router {
return times;
}
// --- Private helpers ---
private record DestinationChunk(FreeFormPointSet pointSet, WebMercatorExtents extents, int[] originalIndices) {}
private static DestinationChunk buildChunk(
double[] lats, double[] lons, Integer[] sorted, int start, int end) {
int size = end - start;

80
server-rs/Cargo.lock generated
View file

@ -302,6 +302,15 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "brotli"
version = "7.0.0"
@ -564,6 +573,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -645,6 +663,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "debug_unsafe"
version = "0.1.3"
@ -662,6 +690,17 @@ dependencies = [
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -942,6 +981,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -1062,6 +1111,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "705f81e042b11734af35c701c7f6b65f8a968a430621fa2c95e72e27f9f8be5c"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "home"
version = "0.5.12"
@ -2368,6 +2426,8 @@ dependencies = [
"axum",
"clap",
"h3o",
"hex",
"hmac",
"lasso",
"metrics",
"metrics-exporter-prometheus",
@ -2381,7 +2441,9 @@ dependencies = [
"rustc-hash",
"serde",
"serde_json",
"sha2",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
@ -2925,6 +2987,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@ -3347,6 +3420,7 @@ dependencies = [
"pin-project-lite",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@ -3461,6 +3535,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"

View file

@ -26,6 +26,10 @@ urlencoding = "2"
rust_xlsxwriter = "0.79"
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
rand = "0.9"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
tower = { version = "0.5", features = ["limit"] }
[lints.clippy]
min_ident_chars = "warn"

View file

@ -19,6 +19,12 @@ pub struct PocketBaseUser {
pub email: String,
#[serde(default)]
pub verified: bool,
#[serde(default)]
pub is_admin: bool,
#[serde(default)]
pub subscription: String,
#[serde(default)]
pub newsletter: bool,
}
#[derive(Clone)]
@ -58,6 +64,12 @@ impl TokenCache {
}
map.insert(token, (user, Instant::now()));
}
/// Remove all cached tokens for a given user ID so the next request re-validates.
pub fn invalidate_by_user_id(&self, user_id: &str) {
let mut map = self.entries.write();
map.retain(|_, (user, _)| user.id != user_id);
}
}
#[derive(Deserialize)]

View file

@ -20,3 +20,7 @@ pub const AREA_SUMMARY_TEMPERATURE: f32 = 0.3;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
/// Inner London free zone bounds (south, west, north, east) — roughly zones 12.
/// Users without a license can only query data within these bounds.
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.48, -0.18, 51.54, -0.02);

View file

@ -2,8 +2,10 @@ mod places;
mod poi;
mod postcodes;
mod property;
pub mod travel_time;
pub use places::PlaceData;
pub use poi::{POICategoryGroup, POIData};
pub use postcodes::PostcodeData;
pub use property::{precompute_h3, FeatureStats, Histogram, PropertyData, RenovationEvent};
pub use travel_time::{slugify, TravelTimeStore};

View file

@ -0,0 +1,168 @@
use std::path::Path;
use anyhow::Context;
use polars::frame::DataFrame;
use polars::lazy::frame::LazyFrame;
use polars::prelude::*;
use tracing::info;
use crate::utils::InternedColumn;
pub struct PlaceData {
pub name: Vec<String>,
pub name_lower: Vec<String>,
pub place_type: InternedColumn,
pub type_rank: Vec<u8>,
pub population: Vec<u32>,
pub lat: Vec<f32>,
pub lon: Vec<f32>,
pub city: Vec<Option<String>>,
}
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,
}
}
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}' in places data"))?;
let string_column = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(string_column
.into_iter()
.map(|value| value.unwrap_or("").to_string())
.collect())
}
fn extract_f32_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<f32>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}' in places data"))?;
let cast = column
.cast(&DataType::Float32)
.with_context(|| format!("Failed to cast column '{name}' to Float32"))?;
let float_column = cast
.f32()
.with_context(|| format!("Column '{name}' is not a float32 column"))?;
Ok(float_column
.into_iter()
.map(|value| value.unwrap_or(0.0))
.collect())
}
impl PlaceData {
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
info!("Loading place data from {:?}...", parquet_path);
let df = LazyFrame::scan_parquet(parquet_path, Default::default())
.context("Failed to scan places parquet")?
.collect()
.context("Failed to read places parquet")?;
let row_count = df.height();
info!("Loaded {} places", row_count);
let name = extract_str_col(&df, "name")?;
let place_type_raw = extract_str_col(&df, "place_type")?;
let lat = extract_f32_col(&df, "lat")?;
let lon = extract_f32_col(&df, "lon")?;
let population: Vec<u32> = if df.column("population").is_ok() {
let pop_f32 = extract_f32_col(&df, "population")?;
pop_f32.iter().map(|&val| val.max(0.0) as u32).collect()
} else {
vec![0; row_count]
};
let name_lower: Vec<String> = name.iter().map(|nm| nm.to_lowercase()).collect();
let type_rank_vec: Vec<u8> = place_type_raw.iter().map(|pt| type_rank(pt)).collect();
let place_type = InternedColumn::build(&place_type_raw);
// Precompute nearest city for each non-city place
let city_indices: Vec<usize> = type_rank_vec
.iter()
.enumerate()
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
.collect();
let city: Vec<Option<String>> = (0..row_count)
.map(|idx| {
if type_rank_vec[idx] == 0 {
return None; // Cities don't need a city label
}
let plat = lat[idx];
let plon = lon[idx];
let cos_lat = (plat.to_radians()).cos();
let mut best_dist_sq = f32::MAX;
let mut best_city: Option<&str> = None;
for &ci in &city_indices {
let dlat = lat[ci] - plat;
let dlon = (lon[ci] - plon) * cos_lat;
let dist_sq = dlat * dlat + dlon * dlon;
if dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_city = Some(&name[ci]);
}
}
// ~100km threshold: 1° ≈ 111km, so 0.9° ≈ 100km → 0.81 squared
if best_dist_sq < 0.81 {
best_city.map(|s| s.to_string())
} else {
None
}
})
.collect();
let with_pop = population.iter().filter(|&&pop| pop > 0).count();
let with_city = city.iter().filter(|c| c.is_some()).count();
info!(
places = row_count,
types = place_type.values.len(),
with_population = with_pop,
with_city = with_city,
"Place data loaded"
);
Ok(PlaceData {
name,
name_lower,
place_type,
type_rank: type_rank_vec,
population,
lat,
lon,
city,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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"));
}
}

149
server-rs/src/data/poi.rs Normal file
View file

@ -0,0 +1,149 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::{bail, Context};
use polars::frame::DataFrame;
use polars::lazy::frame::LazyFrame;
use polars::prelude::*;
use serde::Serialize;
use tracing::info;
use crate::features::POI_GROUP_ORDER;
use crate::utils::{generate_priorities, InternedColumn};
#[derive(Serialize, Clone)]
pub struct POICategoryGroup {
pub name: String,
pub categories: Vec<String>,
}
pub struct POIData {
pub id: Vec<String>,
pub group: InternedColumn,
pub category: InternedColumn,
pub name: Vec<String>,
pub lat: Vec<f32>,
pub lng: Vec<f32>,
pub emoji: InternedColumn,
/// Deterministic pseudo-random priority per row, used to select a spatially
/// uniform subset when the POI count exceeds the per-request limit.
/// Computed once at load time so the same POIs are always chosen for a given viewport.
pub priority: Vec<u32>,
}
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}' in POI data"))?;
let string_column = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(string_column
.into_iter()
.map(|value| value.unwrap_or("").to_string())
.collect())
}
fn extract_f32_col(df: &DataFrame, name: &str, default: f32) -> anyhow::Result<Vec<f32>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}' in POI data"))?;
let cast = column
.cast(&DataType::Float32)
.with_context(|| format!("Failed to cast column '{name}' to Float32"))?;
let float_column = cast
.f32()
.with_context(|| format!("Column '{name}' is not a float32 column"))?;
Ok(float_column
.into_iter()
.map(|value| value.unwrap_or(default))
.collect())
}
impl POIData {
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
info!("Loading POI data from {:?}...", parquet_path);
let df = LazyFrame::scan_parquet(parquet_path, Default::default())
.context("Failed to scan POI parquet")?
.collect()
.context("Failed to read POI parquet")?;
let row_count = df.height();
info!("Loaded {} POIs", row_count);
let id: Vec<String> = extract_str_col(&df, "id")?;
let name = extract_str_col(&df, "name")?;
let category_raw = extract_str_col(&df, "category")?;
let group_raw = extract_str_col(&df, "group")?;
let lat = extract_f32_col(&df, "lat", 0.0)?;
let lng = extract_f32_col(&df, "lng", 0.0)?;
let emoji_raw = extract_str_col(&df, "emoji")?;
let category = InternedColumn::build(&category_raw);
let group = InternedColumn::build(&group_raw);
let emoji = InternedColumn::build(&emoji_raw);
info!(
category_unique = category.values.len(),
group_unique = group.values.len(),
emoji_unique = emoji.values.len(),
"POI string columns interned"
);
// Assign a deterministic pseudo-random priority to each row.
// This ensures the same POIs are selected across requests,
// preventing visual "shuffling" when panning the map.
let priority = generate_priorities(row_count);
info!("POI data loading complete.");
Ok(POIData {
id,
name,
category,
group,
lat,
lng,
emoji,
priority,
})
}
/// Build category groups from the loaded POI data, validated against POI_GROUP_ORDER.
pub fn category_groups(&self) -> anyhow::Result<Vec<POICategoryGroup>> {
let mut group_cats: HashMap<String, HashSet<String>> = HashMap::new();
let num_pois = self.category.indices.len();
for row in 0..num_pois {
let category = self.category.get(row).to_string();
let group = self.group.get(row).to_string();
group_cats.entry(group).or_default().insert(category);
}
// Validate that data groups match the hardcoded order exactly
let expected: HashSet<&str> = POI_GROUP_ORDER.iter().copied().collect();
let actual: HashSet<&str> = group_cats.keys().map(|key| key.as_str()).collect();
let missing_from_data: Vec<&&str> = expected.difference(&actual).collect();
let missing_from_order: Vec<&&str> = actual.difference(&expected).collect();
if !missing_from_data.is_empty() || !missing_from_order.is_empty() {
bail!(
"POI group mismatch!\n In POI_GROUP_ORDER but not in data: {:?}\n In data but not in POI_GROUP_ORDER: {:?}",
missing_from_data, missing_from_order
);
}
POI_GROUP_ORDER
.iter()
.map(|group_name| {
let name = group_name.to_string();
let mut categories: Vec<String> = group_cats
.remove(&name)
.context("POI group validated but missing from map")?
.into_iter()
.collect();
categories.sort();
Ok(POICategoryGroup { name, categories })
})
.collect()
}
}

View file

@ -0,0 +1,179 @@
use anyhow::Context;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use serde::Deserialize;
use std::fs;
use std::path::Path;
use tracing::{debug, info};
/// GeoJSON structures for parsing postcode boundary files
#[derive(Deserialize)]
struct FeatureCollection {
features: Vec<Feature>,
}
#[derive(Deserialize)]
struct Feature {
geometry: Geometry,
properties: Properties,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum Geometry {
Polygon {
coordinates: Vec<Vec<[f64; 2]>>,
},
MultiPolygon {
coordinates: Vec<Vec<Vec<[f64; 2]>>>,
},
}
#[derive(Deserialize)]
struct Properties {
postcodes: String,
}
/// Postcode boundary data: polygon vertices and spatial index for fast queries.
pub struct PostcodeData {
/// Postcode strings
pub postcodes: Vec<String>,
/// All polygon parts per postcode: polygons[i] = list of outer rings
/// Single Polygon → 1 ring, MultiPolygon → N rings
pub polygons: Vec<Vec<Vec<[f32; 2]>>>,
/// Centroid (lat, lon) for lookups
pub centroids: Vec<(f32, f32)>,
/// Lookup from postcode string to index
pub postcode_to_idx: FxHashMap<String, usize>,
}
impl PostcodeData {
/// Load postcode boundaries from a directory of GeoJSON files.
/// Expects the directory to have a `units/` subdirectory containing .geojson files.
pub fn load(dir_path: &Path) -> anyhow::Result<Self> {
info!("Loading postcode boundaries from {:?}", dir_path);
let units_dir = dir_path.join("units");
if !units_dir.exists() {
anyhow::bail!(
"Expected 'units' subdirectory in postcode boundaries path: {:?}",
dir_path
);
}
let mut postcodes: Vec<String> = Vec::new();
let mut polygons: Vec<Vec<Vec<[f32; 2]>>> = Vec::new();
let mut centroids: Vec<(f32, f32)> = Vec::new();
// Read all .geojson files in the units directory
let mut entries: Vec<_> = fs::read_dir(&units_dir)
.with_context(|| format!("Failed to read directory: {:?}", units_dir))?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "geojson")
.unwrap_or(false)
})
.collect();
entries.sort_by_key(|entry| entry.path());
info!(files = entries.len(), "Found GeoJSON files to process");
// Parse files in parallel
let file_results: Vec<_> = entries
.into_par_iter()
.map(|entry| {
let file_path = entry.path();
let content = fs::read_to_string(&file_path)
.with_context(|| format!("Failed to read file: {:?}", file_path))?;
let collection: FeatureCollection = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse GeoJSON: {:?}", file_path))?;
let mut local_postcodes = Vec::new();
let mut local_polygons = Vec::new();
let mut local_centroids = Vec::new();
for feature in collection.features {
let postcode = feature.properties.postcodes;
// Extract all outer rings from the geometry
let rings: Vec<Vec<[f32; 2]>> = match feature.geometry {
Geometry::Polygon { coordinates } => coordinates
.first()
.map(|ring| {
vec![ring
.iter()
.map(|[lon, lat]| [*lon as f32, *lat as f32])
.collect()]
})
.unwrap_or_default(),
Geometry::MultiPolygon { coordinates } => coordinates
.iter()
.filter_map(|poly| {
poly.first().map(|ring| {
ring.iter()
.map(|[lon, lat]| [*lon as f32, *lat as f32])
.collect()
})
})
.collect(),
};
// Compute centroid across all vertices from all rings
let total_vertices: usize = rings.iter().map(|ring| ring.len()).sum();
let centroid = if total_vertices == 0 {
(0.0, 0.0)
} else {
let mut sum_lat: f32 = 0.0;
let mut sum_lon: f32 = 0.0;
for ring in &rings {
for &[lon, lat] in ring {
sum_lat += lat;
sum_lon += lon;
}
}
let count = total_vertices as f32;
(sum_lat / count, sum_lon / count)
};
local_postcodes.push(postcode);
local_polygons.push(rings);
local_centroids.push(centroid);
}
Ok::<_, anyhow::Error>((local_postcodes, local_polygons, local_centroids))
})
.collect::<Result<Vec<_>, _>>()?;
// Flatten results
for (local_postcodes, local_polygons, local_centroids) in file_results {
postcodes.extend(local_postcodes);
polygons.extend(local_polygons);
centroids.extend(local_centroids);
}
debug!(
postcodes = postcodes.len(),
"Extracted postcodes from GeoJSON"
);
// Build postcode -> index lookup
let mut postcode_to_idx: FxHashMap<String, usize> = FxHashMap::default();
for (idx, postcode) in postcodes.iter().enumerate() {
postcode_to_idx.insert(postcode.clone(), idx);
}
info!(postcodes = postcodes.len(), "Postcode boundary data ready");
Ok(PostcodeData {
postcodes,
polygons,
centroids,
postcode_to_idx,
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,232 @@
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{bail, Context};
use parking_lot::Mutex;
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>>;
/// Simple LRU cache for travel time data, limited to `capacity` entries.
struct LruCache {
map: FxHashMap<(String, String), TravelData>,
order: VecDeque<(String, String)>,
capacity: usize,
}
impl LruCache {
fn new(capacity: usize) -> Self {
Self {
map: FxHashMap::default(),
order: VecDeque::with_capacity(capacity),
capacity,
}
}
fn get(&mut self, key: &(String, String)) -> Option<TravelData> {
if let Some(data) = self.map.get(key) {
// Move to front (most recently used)
if let Some(pos) = self.order.iter().position(|k| k == key) {
self.order.remove(pos);
self.order.push_front(key.clone());
}
Some(data.clone())
} else {
None
}
}
fn insert(&mut self, key: (String, String), data: TravelData) {
if self.map.contains_key(&key) {
self.map.insert(key.clone(), data);
if let Some(pos) = self.order.iter().position(|k| k == &key) {
self.order.remove(pos);
}
self.order.push_front(key);
} else {
while self.map.len() >= self.capacity {
if let Some(old_key) = self.order.pop_back() {
self.map.remove(&old_key);
}
}
self.map.insert(key.clone(), data);
self.order.push_front(key);
}
}
}
/// Manages on-demand loading and caching of precomputed travel time parquet files.
///
/// Directory structure: `{base_dir}/{mode}/{slug}.parquet`
/// 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)
pub destinations: FxHashMap<String, FxHashSet<String>>,
cache: Mutex<LruCache>,
}
impl TravelTimeStore {
/// Scan the travel-times directory to discover available modes and destinations.
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();
for entry in std::fs::read_dir(base_dir)
.with_context(|| format!("Failed to read travel-times dir: {}", base_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let mode = entry.file_name().to_string_lossy().to_string();
let mut slugs = FxHashSet::default();
for file_entry in std::fs::read_dir(&path)
.with_context(|| format!("Failed to read mode dir: {}", path.display()))?
{
let file_entry = file_entry?;
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();
slugs.insert(slug);
}
}
if !slugs.is_empty() {
info!(
mode = mode.as_str(),
destinations = slugs.len(),
"Travel time mode discovered"
);
available_modes.push(mode.clone());
destinations.insert(mode, slugs);
}
}
available_modes.sort();
Ok(Self {
base_dir: base_dir.to_path_buf(),
available_modes,
destinations,
cache: Mutex::new(LruCache::new(cache_capacity)),
})
}
/// Load travel time data for a given mode and destination slug.
/// Returns a cached or freshly-loaded postcode → travel_minutes mapping.
pub fn get(&self, mode: &str, slug: &str) -> anyhow::Result<TravelData> {
let key = (mode.to_string(), slug.to_string());
// Check cache first
{
let mut cache = self.cache.lock();
if let Some(data) = cache.get(&key) {
return Ok(data);
}
}
// Load from file (no lock held — harmless if two threads load the same file)
let path = self
.base_dir
.join(mode)
.join(format!("{}.parquet", slug));
if !path.exists() {
bail!("Travel time file not found: {}", path.display());
}
let df = LazyFrame::scan_parquet(&path, Default::default())
.with_context(|| format!("Failed to scan: {}", path.display()))?
.collect()
.with_context(|| format!("Failed to read: {}", path.display()))?;
let postcodes = df
.column("pcds")
.context("Missing 'pcds' column")?
.str()
.context("'pcds' is not string")?;
let minutes = df
.column("travel_minutes")
.context("Missing 'travel_minutes' column")?
.i16()
.context("'travel_minutes' is not i16")?;
let mut map = FxHashMap::default();
map.reserve(df.height());
for (pc, min) in postcodes.into_iter().zip(minutes.into_iter()) {
if let (Some(pc), Some(min)) = (pc, min) {
map.insert(pc.to_string(), min);
}
}
let data: TravelData = Arc::new(map);
// Insert into cache
{
let mut cache = self.cache.lock();
cache.insert(key, data.clone());
}
Ok(data)
}
/// Check if a mode + slug combination is available.
pub fn has_destination(&self, mode: &str, slug: &str) -> bool {
self.destinations
.get(mode)
.map(|slugs| slugs.contains(slug))
.unwrap_or(false)
}
}
/// Slugify a place name to match travel time file naming convention.
/// "Abbey Hey" → "abbey-hey", "A'Bhuaile Ghlas" → "a-bhuaile-ghlas"
pub fn slugify(name: &str) -> String {
let mut result = String::with_capacity(name.len());
let mut last_was_hyphen = true; // Start true to skip leading hyphens
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
result.push(ch.to_ascii_lowercase());
last_was_hyphen = false;
} else if !last_was_hyphen {
result.push('-');
last_was_hyphen = true;
}
}
if result.ends_with('-') {
result.pop();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[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");
}
}

View file

@ -32,7 +32,8 @@ pub struct FeatureConfig {
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
/// p1/p99 are snapped to integer boundaries before binning.
pub const INTEGER_BIN_FEATURES: &[&str] = &["Number of bedrooms & living rooms"];
pub const INTEGER_BIN_FEATURES: &[&str] =
&["Number of bedrooms & living rooms", "Bedrooms", "Bathrooms"];
pub struct FeatureGroup {
pub name: &'static str,
@ -68,6 +69,9 @@ pub const IGNORED_COLUMNS: &[&str] = &[
"Is construction date approximate",
"Current energy rating",
"Potential energy rating",
"Property sub-type",
"Listing URL",
"Price qualifier",
];
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
@ -221,6 +225,81 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: true,
absolute: false,
},
FeatureConfig {
name: "Asking price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_000_000.0,
},
step: 10000.0,
description: "Listed asking price for properties currently for sale",
detail: "The advertised asking price for properties currently listed for sale on online property portals. Only populated for 'For sale' listings; null for historical sales and rentals.",
source: "online-listings",
prefix: "£",
suffix: "",
raw: false,
absolute: true,
},
FeatureConfig {
name: "Asking rent (monthly)",
bounds: Bounds::Fixed {
min: 0.0,
max: 10_000.0,
},
step: 50.0,
description: "Listed monthly rent for properties currently for rent",
detail: "The advertised rental price normalized to monthly for properties currently listed for rent on online property portals. Weekly rents are converted (×52/12), yearly (/12), daily (×365.25/12), and quarterly (/3). Only populated for 'For rent' listings.",
source: "online-listings",
prefix: "£",
suffix: "/mo",
raw: false,
absolute: true,
},
FeatureConfig {
name: "Bedrooms",
bounds: Bounds::Fixed {
min: 0.0,
max: 10.0,
},
step: 1.0,
description: "Number of bedrooms from online listing",
detail: "Number of bedrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
source: "online-listings",
prefix: "",
suffix: "",
raw: false,
absolute: true,
},
FeatureConfig {
name: "Bathrooms",
bounds: Bounds::Fixed {
min: 0.0,
max: 10.0,
},
step: 1.0,
description: "Number of bathrooms from online listing",
detail: "Number of bathrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
source: "online-listings",
prefix: "",
suffix: "",
raw: false,
absolute: true,
},
FeatureConfig {
name: "Listing date",
bounds: Bounds::Fixed {
min: 2006.0,
max: 2026.0,
},
step: 1.0,
description: "Date the property was first listed online",
detail: "The date when the property listing first appeared on the online property portal. Stored as a datetime; converted to fractional year for filtering. Only populated for online listings.",
source: "online-listings",
prefix: "",
suffix: "",
raw: true,
absolute: false,
},
],
},
FeatureGroup {
@ -442,7 +521,43 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
],
},
FeatureGroup {
FeatureGroup {
name: "Crime summary",
features: &[
FeatureConfig {
name: "Serious crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of serious crime categories per year",
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.",
source: "crime",
prefix: "",
suffix: "/yr",
raw: false,
absolute: false,
},
FeatureConfig {
name: "Minor crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of minor crime categories per year",
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.",
source: "crime",
prefix: "",
suffix: "/yr",
raw: false,
absolute: false,
},
],
},
FeatureGroup {
name: "Crime",
features: &[
FeatureConfig {
@ -655,36 +770,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
},
FeatureConfig {
name: "Serious crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of serious crime categories per year",
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.",
source: "crime",
prefix: "",
suffix: "/yr",
raw: false,
absolute: false,
},
FeatureConfig {
name: "Minor crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of minor crime categories per year",
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.",
source: "crime",
prefix: "",
suffix: "/yr",
raw: false,
absolute: false,
},
],
},
FeatureGroup {
@ -858,6 +943,13 @@ pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
EnumFeatureGroup {
name: "Property",
features: &[
EnumFeatureConfig {
name: "Listing status",
order: Some(&["Historical sale", "For sale", "For rent"]),
description: "Whether the property is from historical sales, currently for sale, or for rent",
detail: "Indicates the source of the property record: 'Historical sale' from HM Land Registry Price Paid data, 'For sale' from current online buy listings, or 'For rent' from current online rental listings.",
source: "online-listings",
},
EnumFeatureConfig {
name: "Leashold/Freehold",
order: Some(&["Freehold", "Leasehold"]),

View file

@ -0,0 +1,55 @@
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
use crate::auth::PocketBaseUser;
use crate::consts::FREE_ZONE_BOUNDS;
/// Check whether the user is allowed to query data at the given bounds.
/// Licensed users and admins bypass the check entirely.
/// Free/anonymous users get 403 if bounds exceed the free zone.
pub fn check_license_bounds(
user: &Option<PocketBaseUser>,
bounds: (f64, f64, f64, f64),
) -> Result<(), (StatusCode, axum::response::Response)> {
// Licensed users and admins can query anywhere
if let Some(u) = user {
if u.is_admin || u.subscription == "licensed" {
return Ok(());
}
}
let (south, west, north, east) = bounds;
let (fz_south, fz_west, fz_north, fz_east) = FREE_ZONE_BOUNDS;
// Check if requested bounds are fully within the free zone
if south >= fz_south && west >= fz_west && north <= fz_north && east <= fz_east {
return Ok(());
}
let body = json!({
"error": "license_required",
"message": "A license is required to view data outside inner London",
"free_zone": {
"south": fz_south,
"west": fz_west,
"north": fz_north,
"east": fz_east,
}
});
Err((
StatusCode::FORBIDDEN,
(StatusCode::FORBIDDEN, axum::Json(body)).into_response(),
))
}
/// Convenience wrapper that takes a point (lat, lon) instead of bounds.
/// Used for endpoints that operate on a single location (e.g. postcode stats).
pub fn check_license_point(
user: &Option<PocketBaseUser>,
lat: f64,
lon: f64,
) -> Result<(), (StatusCode, axum::response::Response)> {
check_license_bounds(user, (lat, lon, lat, lon))
}

View file

@ -3,6 +3,7 @@ mod auth;
mod consts;
mod data;
mod features;
mod licensing;
mod metrics;
mod og_middleware;
pub mod parsing;
@ -13,15 +14,17 @@ pub mod utils;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context};
use axum::middleware;
use axum::routing::{any, get, post};
use axum::routing::{any, get, patch, post};
use axum::Router;
use clap::Parser;
use tower::limit::ConcurrencyLimitLayer;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::{AllowHeaders, AllowMethods, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing::info;
@ -52,7 +55,7 @@ struct Cli {
#[arg(long)]
tiles: PathBuf,
/// Path to the frontend dist directory
/// Path to the frontend dist directory (optional; disables static serving and OG injection when omitted)
#[arg(long)]
dist: Option<PathBuf>,
@ -70,11 +73,11 @@ struct Cli {
/// PocketBase superuser email (for auto-creating collections at startup)
#[arg(long, env = "POCKETBASE_ADMIN_EMAIL")]
pocketbase_admin_email: Option<String>,
pocketbase_admin_email: String,
/// PocketBase superuser password (for auto-creating collections at startup)
#[arg(long, env = "POCKETBASE_ADMIN_PASSWORD")]
pocketbase_admin_password: Option<String>,
pocketbase_admin_password: String,
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
#[arg(long, env = "OLLAMA_URL")]
@ -84,13 +87,41 @@ struct Cli {
#[arg(long, env = "OLLAMA_MODEL")]
ollama_model: String,
/// R5 routing service URL for all travel times (e.g. http://r5:8003)
#[arg(long, env = "R5_URL")]
r5_url: Option<String>,
/// Path to precomputed travel times directory (contains mode subdirs with parquet files)
#[arg(long, env = "TRAVEL_TIMES")]
travel_times: PathBuf,
/// Google Maps API key for Street View metadata lookups
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
google_maps_api_key: String,
/// Stripe secret key for checkout sessions
#[arg(long, env = "STRIPE_SECRET_KEY")]
stripe_secret_key: String,
/// Stripe webhook signing secret for verifying webhook signatures
#[arg(long, env = "STRIPE_WEBHOOK_SECRET")]
stripe_webhook_secret: String,
/// Stripe Coupon ID applied when a referral code is used
#[arg(long, env = "STRIPE_REFERRAL_COUPON_ID")]
stripe_referral_coupon_id: String,
/// Google OAuth client ID for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_ID")]
google_oauth_client_id: String,
/// Google OAuth client secret for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")]
google_oauth_client_secret: String,
/// Apple OAuth client ID for PocketBase SSO
#[arg(long, env = "APPLE_OAUTH_CLIENT_ID")]
apple_oauth_client_id: String,
/// Apple OAuth client secret for PocketBase SSO
#[arg(long, env = "APPLE_OAUTH_CLIENT_SECRET")]
apple_oauth_client_secret: String,
}
#[tokio::main]
@ -212,19 +243,23 @@ async fn main() -> anyhow::Result<()> {
let poi_category_groups = poi_data.category_groups()?;
// Read index.html at startup for crawler OG injection
let (frontend_dist, index_html) = if let Some(dist) = cli.dist {
// Read index.html at startup for crawler OG injection (only when --dist is provided)
let index_html = if let Some(ref dist) = cli.dist {
let index_path = dist.join("index.html");
let html = std::fs::read_to_string(&index_path)
.with_context(|| format!("Failed to read {}", index_path.display()))?;
info!("Loaded index.html for OG injection");
(Some(dist), Some(html))
Some(html)
} else {
info!("No --dist provided, static serving and OG injection disabled");
(None, None)
info!("No --dist provided; static serving and OG injection disabled");
None
};
let http_client = reqwest::Client::new();
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.build()
.context("Failed to build HTTP client")?;
info!("Screenshot service configured: {}", cli.screenshot_url);
@ -247,23 +282,46 @@ async fn main() -> anyhow::Result<()> {
info!("PocketBase configured: {}", cli.pocketbase_url);
if let (Some(ref email), Some(ref password)) =
(&cli.pocketbase_admin_email, &cli.pocketbase_admin_password)
{
pocketbase::ensure_collections(&http_client, &cli.pocketbase_url, email, password).await?;
} else {
info!("PocketBase admin credentials not set — skipping collection auto-creation");
}
pocketbase::ensure_collections(
&http_client,
&cli.pocketbase_url,
&cli.pocketbase_admin_email,
&cli.pocketbase_admin_password,
)
.await?;
pocketbase::ensure_oauth_providers(
&http_client,
&cli.pocketbase_url,
&cli.pocketbase_admin_email,
&cli.pocketbase_admin_password,
&cli.public_url,
&cli.google_oauth_client_id,
&cli.google_oauth_client_secret,
&cli.apple_oauth_client_id,
&cli.apple_oauth_client_secret,
)
.await?;
info!(
"Ollama configured: {} (model: {})",
cli.ollama_url, cli.ollama_model
);
if let Some(ref url) = cli.r5_url {
info!("R5 routing service configured: {}", url);
} else {
info!("R5 routing service not configured (travel time queries disabled)");
let tt_path = &cli.travel_times;
if !tt_path.exists() {
bail!(
"Travel times directory not found: {}",
tt_path.display()
);
}
info!("Loading travel time data from {}", tt_path.display());
let travel_time_store = {
let store = data::TravelTimeStore::load(tt_path, 50)?;
info!(
modes = store.available_modes.len(),
"Travel time store loaded"
);
Arc::new(store)
};
let token_cache = Arc::new(auth::TokenCache::new());
@ -286,19 +344,30 @@ async fn main() -> anyhow::Result<()> {
index_html,
http_client,
pocketbase_url: cli.pocketbase_url,
pocketbase_admin_email: cli.pocketbase_admin_email,
pocketbase_admin_password: cli.pocketbase_admin_password,
ollama_url: cli.ollama_url,
ollama_model: cli.ollama_model,
r5_url: cli.r5_url,
travel_time_store,
token_cache,
ai_filters_schema,
ai_filters_system_prompt,
google_maps_api_key: cli.google_maps_api_key,
stripe_secret_key: cli.stripe_secret_key,
stripe_webhook_secret: cli.stripe_webhook_secret,
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
.allow_origin(
state
.public_url
.parse::<axum::http::HeaderValue>()
.expect("public_url must be a valid header value"),
)
.allow_methods(AllowMethods::mirror_request())
.allow_headers(AllowHeaders::mirror_request())
.allow_credentials(true);
let state_features = state.clone();
let state_hexagons = state.clone();
@ -319,6 +388,15 @@ async fn main() -> anyhow::Result<()> {
let state_short_url = state.clone();
let state_ai_filters = state.clone();
let state_streetview = state.clone();
let state_subscription = state.clone();
let state_newsletter = state.clone();
let state_travel_modes = state.clone();
let state_checkout = state.clone();
let state_stripe_webhook = state.clone();
let state_pricing = state.clone();
let state_invites_create = state.clone();
let state_invite_get = state.clone();
let state_redeem_invite = state.clone();
let api = Router::new()
.route(
@ -327,11 +405,11 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/hexagons",
get(move |query| routes::get_hexagons(state_hexagons.clone(), query)),
get(move |ext, query| routes::get_hexagons(state_hexagons.clone(), ext, query)),
)
.route(
"/api/postcodes",
get(move |query| routes::get_postcodes(state_postcodes.clone(), query)),
get(move |ext, query| routes::get_postcodes(state_postcodes.clone(), ext, query)),
)
.route(
"/api/postcode/{postcode}",
@ -349,19 +427,23 @@ async fn main() -> anyhow::Result<()> {
"/api/places",
get(move |query| routes::get_places(state_places.clone(), query)),
)
.route(
"/api/travel-modes",
get(move || routes::get_travel_modes(state_travel_modes.clone())),
)
.route(
"/api/hexagon-properties",
get(move |query| {
routes::get_hexagon_properties(state_hexagon_properties.clone(), query)
get(move |ext, query| {
routes::get_hexagon_properties(state_hexagon_properties.clone(), ext, query)
}),
)
.route(
"/api/hexagon-stats",
get(move |query| routes::get_hexagon_stats(state_hexagon_stats.clone(), query)),
get(move |ext, query| routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)),
)
.route(
"/api/postcode-stats",
get(move |query| routes::get_postcode_stats(state_postcode_stats.clone(), query)),
get(move |ext, query| routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)),
)
.route(
"/api/screenshot",
@ -369,12 +451,14 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/export",
get(move |query| routes::get_export(state_export.clone(), query)),
get(move |ext, query| routes::get_export(state_export.clone(), ext, query))
.layer(ConcurrencyLimitLayer::new(3)),
)
.route("/api/me", get(routes::get_me))
.route(
"/api/area-summary",
post(move |body| routes::post_area_summary(state_area_summary.clone(), body)),
post(move |body| routes::post_area_summary(state_area_summary.clone(), body))
.layer(ConcurrencyLimitLayer::new(5)),
)
.route(
"/api/shorten",
@ -382,12 +466,54 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/ai-filters",
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body)),
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body))
.layer(ConcurrencyLimitLayer::new(5)),
)
.route(
"/api/streetview",
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
)
.route(
"/api/subscription",
patch(move |ext, body| {
routes::patch_subscription(state_subscription.clone(), ext, body)
}),
)
.route(
"/api/newsletter",
patch(move |ext, body| {
routes::patch_newsletter(state_newsletter.clone(), ext, body)
}),
)
.route(
"/api/pricing",
get(move || routes::get_pricing(state_pricing.clone())),
)
.route(
"/api/checkout",
post(move |ext, body| routes::post_checkout(state_checkout.clone(), ext, body))
.layer(ConcurrencyLimitLayer::new(10)),
)
.route(
"/api/stripe-webhook",
post(move |headers, body| {
routes::post_stripe_webhook(state_stripe_webhook.clone(), headers, body)
}),
)
.route(
"/api/invites",
post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
)
.route(
"/api/invite/{code}",
get(move |ext, path| routes::get_invite(state_invite_get.clone(), ext, path)),
)
.route(
"/api/redeem-invite",
post(move |ext, body| {
routes::post_redeem_invite(state_redeem_invite.clone(), ext, body)
}),
)
.route(
"/s/{code}",
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
@ -396,6 +522,7 @@ async fn main() -> anyhow::Result<()> {
// Add tile routes
let reader_tile = tile_reader.clone();
let reader_style = tile_reader.clone();
let public_url_tiles = state.public_url.clone();
let api = api
.route(
"/api/tiles/{z}/{x}/{y}",
@ -403,8 +530,9 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/tiles/style.json",
get(move |headers, query| {
routes::get_style(axum::extract::State(reader_style.clone()), headers, query)
get(move |query| {
let pu = public_url_tiles.clone();
routes::get_style(axum::extract::State(reader_style.clone()), pu, query)
}),
)
.route("/health", get(|| async { "ok" }))
@ -417,15 +545,13 @@ async fn main() -> anyhow::Result<()> {
any(move |req| routes::proxy_to_pocketbase(state_pb.clone(), req)),
);
let app = if let Some(ref dist) = frontend_dist {
let app = if let Some(ref dist) = cli.dist {
api.fallback_service(
ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))),
)
} else {
api
};
let app = app
}
.layer(middleware::from_fn(metrics::track_metrics))
.layer(middleware::from_fn(auth::auth_middleware))
.layer(middleware::from_fn(

View file

@ -1,28 +1,38 @@
use std::collections::HashSet;
use axum::http::StatusCode;
use rustc_hash::FxHashMap;
/// Parse an optional `?fields=` query param into feature indices for selective aggregation.
/// Returns `None` if fields is `None` (all features included), or `Some(indices)` if specified.
/// Returns an error if any field name is unknown.
pub fn parse_field_indices(
fields: Option<&str>,
name_to_index: &FxHashMap<String, usize>,
) -> Option<Vec<usize>> {
fields.map(|fields_str| {
if fields_str.is_empty() {
return Vec::new();
) -> Result<Option<Vec<usize>>, (StatusCode, String)> {
let Some(fields_str) = fields else {
return Ok(None);
};
if fields_str.is_empty() {
return Ok(None);
}
let mut indices = Vec::new();
for name in fields_str.split(',') {
let name = name.trim();
if name.is_empty() {
continue;
}
fields_str
.split(',')
.filter_map(|name| {
let name = name.trim();
if name.is_empty() {
return None;
}
name_to_index.get(name).copied()
})
.collect()
})
match name_to_index.get(name) {
Some(&idx) => indices.push(idx),
None => {
return Err((
StatusCode::BAD_REQUEST,
format!("Unknown field: {}", name),
))
}
}
}
Ok(Some(indices))
}
/// Parse an optional `?fields=` query param into a HashSet for stats filtering.

View file

@ -1,6 +1,6 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::info;
use tracing::{info, warn};
#[derive(Deserialize)]
struct AuthResponse {
@ -79,7 +79,7 @@ impl Field {
}
}
async fn auth_superuser(
pub async fn auth_superuser(
client: &Client,
base_url: &str,
email: &str,
@ -177,7 +177,82 @@ async fn find_users_collection_id(
Ok(id.to_string())
}
/// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase.
/// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection.
/// PocketBase PATCH replaces the entire `fields` array, so we must preserve existing fields.
async fn ensure_user_fields(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/users");
let resp = client
.get(&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 users collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("users collection has no fields array"))?;
let has_is_admin = fields.iter().any(|f| f["name"] == "is_admin");
let has_subscription = fields.iter().any(|f| f["name"] == "subscription");
let has_newsletter = fields.iter().any(|f| f["name"] == "newsletter");
if has_is_admin && has_subscription && has_newsletter {
info!("PocketBase users collection already has is_admin, subscription, and newsletter fields");
return Ok(());
}
let mut new_fields = fields.clone();
if !has_is_admin {
new_fields.push(serde_json::json!({
"name": "is_admin",
"type": "bool",
}));
}
if !has_subscription {
new_fields.push(serde_json::json!({
"name": "subscription",
"type": "text",
}));
}
if !has_newsletter {
new_fields.push(serde_json::json!({
"name": "newsletter",
"type": "bool",
}));
}
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "fields": new_fields }))
.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 patch users collection ({status}): {text}");
}
info!("Added missing fields to PocketBase users collection");
Ok(())
}
/// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase,
/// and that the `users` collection has `is_admin` and `subscription` fields.
/// Authenticates as superuser, checks existing collections, and creates any that are missing.
pub async fn ensure_collections(
client: &Client,
@ -190,6 +265,8 @@ pub async fn ensure_collections(
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
let existing = list_collections(client, base_url, &token).await?;
ensure_user_fields(client, base_url, &token).await?;
if !existing.iter().any(|n| n == "saved_searches") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
create_collection(
@ -212,6 +289,28 @@ pub async fn ensure_collections(
info!("PocketBase collection 'saved_searches' already exists");
}
if !existing.iter().any(|n| n == "invites") {
create_collection(
client,
base_url,
&token,
CreateCollection {
name: "invites".to_string(),
r#type: "base".to_string(),
fields: vec![
Field::text("code", true),
Field::text("created_by", true),
Field::text("invite_type", true),
Field::text("used_by_id", false),
Field::text("used_at", false),
],
},
)
.await?;
} else {
info!("PocketBase collection 'invites' already exists");
}
if !existing.iter().any(|n| n == "short_urls") {
create_collection(
client,
@ -233,3 +332,94 @@ pub async fn ensure_collections(
Ok(())
}
/// Configure Google and Apple OAuth2 providers in PocketBase settings.
/// Also sets `meta.appUrl` so OAuth callbacks route to `{public_url}/pb`.
pub async fn ensure_oauth_providers(
client: &Client,
base_url: &str,
admin_email: &str,
admin_password: &str,
public_url: &str,
google_client_id: &str,
google_client_secret: &str,
apple_client_id: &str,
apple_client_secret: &str,
) -> anyhow::Result<()> {
let base_url = base_url.trim_end_matches('/');
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
// GET current settings
let settings_url = format!("{base_url}/api/settings");
let resp = client
.get(&settings_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}");
}
let mut settings: 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 });
}
// Update OAuth2 providers
let providers = settings
.pointer_mut("/oauth2/providers")
.and_then(|v| v.as_array_mut());
if let Some(providers) = providers {
for provider in providers.iter_mut() {
let name = provider
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("");
match name {
"google" => {
provider["clientId"] = serde_json::json!(google_client_id);
provider["clientSecret"] = serde_json::json!(google_client_secret);
provider["enabled"] = serde_json::json!(true);
info!("Configured Google OAuth provider");
}
"apple" => {
provider["clientId"] = serde_json::json!(apple_client_id);
provider["clientSecret"] = serde_json::json!(apple_client_secret);
provider["enabled"] = serde_json::json!(true);
info!("Configured Apple OAuth provider");
}
_ => {}
}
}
} else {
warn!("PocketBase settings missing oauth2.providers array — cannot configure OAuth");
return Ok(());
}
// PATCH settings back
let patch_resp = client
.patch(&settings_url)
.header("Authorization", format!("Bearer {token}"))
.json(&settings)
.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}");
}
info!("PocketBase OAuth settings updated (appUrl: {app_url})");
Ok(())
}

View file

@ -1,9 +1,11 @@
mod ai_filters;
mod area_summary;
mod checkout;
mod export;
mod features;
mod hexagon_stats;
pub(crate) mod hexagons;
mod invites;
mod me;
mod pb_proxy;
mod places;
@ -15,11 +17,17 @@ mod screenshot;
mod shorten;
mod stats;
mod streetview;
mod stripe_webhook;
mod newsletter;
pub(crate) mod pricing;
mod subscription;
mod tiles;
pub(crate) mod travel_time;
mod travel_modes;
pub use ai_filters::{build_ollama_schema, build_system_prompt, post_ai_filters};
pub use area_summary::post_area_summary;
pub use checkout::post_checkout;
pub use export::get_export;
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
pub use hexagon_stats::get_hexagon_stats;
@ -34,4 +42,10 @@ pub use properties::get_hexagon_properties;
pub use screenshot::get_screenshot;
pub use shorten::{get_short_url, post_shorten};
pub use streetview::get_streetview;
pub use invites::{get_invite, post_invites, post_redeem_invite};
pub use newsletter::patch_newsletter;
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 travel_modes::get_travel_modes;

View file

@ -0,0 +1,178 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
use super::pricing::{count_licensed_users, price_for_count};
#[derive(Deserialize)]
pub struct CheckoutRequest {
referral_code: Option<String>,
}
#[derive(Serialize)]
struct CheckoutResponse {
url: String,
}
/// Create a Stripe Checkout session for the lifetime license (or grant for free if in free tier).
/// Requires authentication. Optionally accepts a referral code to apply a coupon.
pub async fn post_checkout(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<CheckoutRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let count = match count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users at checkout: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let price_pence = price_for_count(count);
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
// Free tier — grant license directly without Stripe
if price_pence == 0 {
if let Err(err) = grant_license(&state, &user.id).await {
warn!(user_id = %user.id, "Failed to grant free license: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
info!(user_id = %user.id, "Granted free early-bird license");
return Json(CheckoutResponse { url: success_url }).into_response();
}
// Paid tier — create Stripe checkout with dynamic price
let secret_key = &state.stripe_secret_key;
let cancel_url = format!("{public_url}/pricing");
let mut form_params = vec![
("mode", "payment".to_string()),
(
"line_items[0][price_data][unit_amount]",
price_pence.to_string(),
),
("line_items[0][price_data][currency]", "gbp".to_string()),
(
"line_items[0][price_data][product_data][name]",
"Perfect Postcodes Lifetime License".to_string(),
),
("line_items[0][quantity]", "1".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
];
// If a referral code is provided and valid, look it up and apply the coupon
if let Some(ref code) = req.referral_code {
if validate_referral_invite(&state, code).await {
form_params.push(("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()));
info!(code = %code, "Applying referral coupon to checkout");
}
}
let res = state
.http_client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(secret_key, None::<&str>)
.form(&form_params)
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse Stripe response: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let url = body["url"].as_str().unwrap_or_default().to_string();
if url.is_empty() {
warn!("Stripe session missing URL");
return StatusCode::BAD_GATEWAY.into_response();
}
info!(user_id = %user.id, price_pence, "Created Stripe checkout session");
Json(CheckoutResponse { url }).into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("Stripe checkout failed ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("Stripe request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("PocketBase update failed ({status}): {text}");
}
state.token_cache.invalidate_by_user_id(user_id);
Ok(())
}
/// Check if a referral invite code exists and is unused.
async fn validate_referral_invite(state: &AppState, code: &str) -> bool {
// Only allow alphanumeric codes to prevent PocketBase filter injection
if code.is_empty()
|| code.len() > 20
|| !code.bytes().all(|b| b.is_ascii_alphanumeric())
{
return false;
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!(
"code=\"{}\" && invite_type=\"referral\" && used_by_id=\"\"",
code
);
let url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
match state.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
body["totalItems"].as_u64().unwrap_or(0) > 0
}
_ => false,
}
}

View file

@ -5,11 +5,14 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::Extension;
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Image, Url, Workbook};
use rustc_hash::{FxHashMap, FxHashSet};
use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::licensing::check_license_bounds;
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
use crate::routes::FeatureInfo;
use crate::state::AppState;
@ -150,9 +153,14 @@ async fn fetch_screenshot(
pub async fn get_export(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<ExportParams>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let (south, west, north, east) = require_bounds(params.bounds)?;
) -> Result<impl IntoResponse, axum::response::Response> {
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
let filters_str = params.filters.clone();
let fields_str = params.fields.clone();
@ -161,7 +169,7 @@ pub async fn get_export(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let public_url = state.public_url.clone();
@ -269,7 +277,8 @@ pub async fn get_export(
let filter_feature_names = extract_filter_feature_names(filters_str.as_deref());
let field_indices =
parse_field_indices(fields_str.as_deref(), &state.feature_name_to_index);
parse_field_indices(fields_str.as_deref(), &state.feature_name_to_index)
.map_err(|err| err.1)?;
let all_feature_indices: Vec<usize> = if let Some(ref indices) = field_indices {
indices.clone()
@ -564,8 +573,8 @@ pub async fn get_export(
Ok(buf)
})
.await
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err))?;
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())?
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err).into_response())?;
Ok((
[

View file

@ -4,10 +4,13 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::licensing::check_license_bounds;
use crate::parsing::{
cell_for_row, h3_cell_bounds, needs_parent, parse_field_set, parse_filters, row_passes_filters,
validate_h3_resolution,
@ -70,19 +73,25 @@ pub struct HexagonStatsParams {
pub async fn get_hexagon_stats(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonStatsParams>,
) -> Result<Json<HexagonStatsResponse>, (StatusCode, String)> {
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(
StatusCode::BAD_REQUEST,
format!("Invalid H3 cell: {}", error),
)
.into_response()
})?;
let cell_u64: u64 = cell.into();
let resolution = params.resolution;
validate_h3_resolution(resolution)?;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
@ -91,7 +100,7 @@ pub async fn get_hexagon_stats(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
@ -164,8 +173,8 @@ pub async fn get_hexagon_stats(
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}

View file

@ -2,19 +2,23 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::info;
use crate::aggregation::Aggregator;
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::parsing::{
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
};
use crate::routes::travel_time::fetch_travel_times;
use crate::routes::travel_time::TravelTimeAgg;
use crate::state::AppState;
#[derive(Serialize)]
@ -27,64 +31,69 @@ pub struct HexagonParams {
resolution: u8,
bounds: Option<String>,
/// Comma-separated filters: `name:min:max,...`
/// Rows must have non-NaN values within [min,max] for each filter.
filters: Option<String>,
/// Comma-separated feature names to include in min/max aggregation.
/// When present (even if empty), only listed features are aggregated and written.
/// When absent, all features are included (backward compatible).
fields: Option<String>,
/// Pipe-separated travel time entries: `lat,lon,mode|lat,lon,mode`
/// Each entry requests travel time from hex centroids to that destination via the given mode.
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`
/// Each entry requests travel time aggregation for that mode+destination.
/// Optional min:max applies as a filter (exclude properties outside range).
travel: Option<String>,
}
struct TravelEntry {
lat: f64,
lon: f64,
mode: String,
slug: String,
filter_min: Option<f32>,
filter_max: Option<f32>,
}
const VALID_MODES: &[&str] = &["car", "bicycle", "walking", "transit"];
/// Parse `travel` param into a list of travel entries.
/// Format: `lat,lon,mode|lat,lon,mode`
fn parse_travel_entries(s: &str) -> Result<Vec<TravelEntry>, String> {
/// Format: `mode:slug` or `mode:slug:min:max`
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
let mut entries = Vec::new();
let mut seen_modes = Vec::new();
for segment in s.split('|') {
let parts: Vec<&str> = segment.split(',').collect();
if parts.len() != 3 {
let mut seen_keys = Vec::new();
for segment in travel_str.split('|') {
let parts: Vec<&str> = segment.split(':').collect();
if parts.len() < 2 {
return Err(format!(
"each travel entry must be 'lat,lon,mode', got '{}'",
"each travel entry must be 'mode:slug' or 'mode:slug:min:max', got '{}'",
segment
));
}
let lat: f64 = parts[0]
.trim()
.parse()
.map_err(|_| format!("invalid travel latitude in '{}'", segment))?;
let lon: f64 = parts[1]
.trim()
.parse()
.map_err(|_| format!("invalid travel longitude in '{}'", segment))?;
let mode = parts[2].trim().to_string();
if !VALID_MODES.contains(&mode.as_str()) {
return Err(format!(
"invalid travel mode '{}', must be one of: {}",
mode,
VALID_MODES.join(", ")
));
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]
.trim()
.parse()
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
let max: f32 = parts[3]
.trim()
.parse()
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
(Some(min), Some(max))
} else {
(None, None)
};
let key = format!("{}:{}", mode, slug);
if seen_keys.contains(&key) {
return Err(format!("duplicate travel entry '{}'", key));
}
if seen_modes.contains(&mode) {
return Err(format!("duplicate travel mode '{}'", mode));
}
seen_modes.push(mode.clone());
entries.push(TravelEntry { lat, lon, mode });
seen_keys.push(key);
entries.push(TravelEntry {
mode,
slug,
filter_min,
filter_max,
});
}
Ok(entries)
}
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
#[allow(clippy::too_many_arguments)]
fn build_feature_maps(
groups: &FxHashMap<u64, Aggregator>,
min_keys: &[String],
@ -92,7 +101,9 @@ fn build_feature_maps(
avg_keys: &[String],
num_features: usize,
indices: Option<&[usize]>,
query_bounds: (f64, f64, f64, f64), // (south, west, north, east)
query_bounds: (f64, f64, f64, f64),
travel_aggs: &[FxHashMap<u64, TravelTimeAgg>],
travel_field_keys: &[String],
) -> Vec<Map<String, Value>> {
let mut features = Vec::with_capacity(groups.len());
let (q_south, q_west, q_north, q_east) = query_bounds;
@ -143,6 +154,25 @@ fn build_feature_maps(
}
}
// Add travel time aggregation fields
for (ti, agg_map) in travel_aggs.iter().enumerate() {
if let Some(agg) = agg_map.get(&cell_id) {
if agg.count > 0 {
let key = &travel_field_keys[ti];
let avg = agg.sum / agg.count as f64;
if let Some(nm) = serde_json::Number::from_f64(agg.min as f64) {
map.insert(format!("min_{key}"), Value::Number(nm));
}
if let Some(nm) = serde_json::Number::from_f64(agg.max as f64) {
map.insert(format!("max_{key}"), Value::Number(nm));
}
if let Some(nm) = serde_json::Number::from_f64(avg) {
map.insert(format!("avg_{key}"), Value::Number(nm));
}
}
}
}
features.push(map);
}
@ -151,12 +181,21 @@ fn build_feature_maps(
pub async fn get_hexagons(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonParams>,
) -> Result<Json<HexagonsResponse>, (StatusCode, String)> {
) -> Result<Json<HexagonsResponse>, axum::response::Response> {
let resolution = params.resolution;
validate_h3_resolution(resolution)?;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
let (south, west, north, east) = require_bounds(params.bounds)?;
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
// Skip license check at low resolutions (≤5) — data is too aggregated to be
// commercially useful, and the homepage demo needs country-wide access.
if resolution > 5 {
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
}
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
@ -164,30 +203,49 @@ pub async fn get_hexagons(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
.map_err(|err| (err.0, err.1).into_response())?;
// Parse travel entries
let travel_entries = params
.travel
.as_deref()
.filter(|s| !s.is_empty())
.filter(|val| !val.is_empty())
.map(parse_travel_entries)
.transpose()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
.unwrap_or_default();
// Capture what we need for the R5 calls before moving state into spawn_blocking
let r5_url = state.r5_url.clone();
let http_client = state.http_client.clone();
let mut response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
let t0 = std::time::Instant::now();
// Load travel time data from precomputed parquet files
let travel_data: Vec<TravelData> = if !travel_entries.is_empty() {
let store = &state.travel_time_store;
travel_entries
.iter()
.map(|entry| {
store
.get(&entry.mode, &entry.slug)
.map_err(|err| format!("Failed to load travel data: {}", err))
})
.collect::<Result<Vec<_>, _>>()?
} else {
Vec::new()
};
let has_travel = !travel_entries.is_empty();
let travel_field_keys: Vec<String> = travel_entries
.iter()
.map(|te| format!("tt_{}_{}", te.mode, te.slug))
.collect();
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let (pc_interner, pc_keys) = state.data.postcode_parts();
let min_keys = &state.min_keys;
let max_keys = &state.max_keys;
let avg_keys = &state.avg_keys;
@ -198,49 +256,70 @@ pub async fn get_hexagons(
let need_parent = needs_parent(resolution);
let mut groups: FxHashMap<u64, Aggregator> = FxHashMap::default();
let mut travel_aggs: Vec<FxHashMap<u64, TravelTimeAgg>> =
(0..travel_entries.len()).map(|_| FxHashMap::default()).collect();
// Hoist has_selective branch outside the hot loop to avoid per-row branching
if let Some(sel_indices) = field_indices.as_deref() {
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
let row = row_idx as usize;
if !row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) {
return;
// Main aggregation loop
let aggregate_row =
|row: usize,
groups: &mut FxHashMap<u64, Aggregator>,
travel_aggs: &mut [FxHashMap<u64, TravelTimeAgg>]| {
// Regular filters
if !row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) {
return;
}
// Travel time filter: check each entry with a range
let mut travel_minutes: Vec<Option<i16>> = Vec::new();
if has_travel {
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();
travel_minutes.push(minutes);
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
match minutes {
Some(mins) if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
_ => return, // Filtered out
}
}
}
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
let aggregation = groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
}
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
// Aggregate regular features
let aggregation = groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
if let Some(sel_indices) = field_indices.as_deref() {
aggregation.add_row_selective(feature_data, row, num_features, sel_indices);
});
} else {
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
let row = row_idx as usize;
if !row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) {
return;
}
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
let aggregation = groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
} else {
aggregation.add_row(feature_data, row, num_features);
});
}
}
// Aggregate travel time
for (ti, minutes) in travel_minutes.iter().enumerate() {
if let Some(mins) = minutes {
let agg = travel_aggs[ti]
.entry(cell_id)
.or_insert_with(TravelTimeAgg::new);
agg.add(*mins as f32);
}
}
};
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
aggregate_row(row_idx as usize, &mut groups, &mut travel_aggs);
});
let t_agg = t0.elapsed();
@ -252,6 +331,8 @@ pub async fn get_hexagons(
num_features,
field_indices.as_deref(),
(south, west, north, east),
&travel_aggs,
&travel_field_keys,
);
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
@ -268,6 +349,7 @@ pub async fn get_hexagons(
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
travel_entries = travel_entries.len(),
agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
"GET /api/hexagons"
@ -276,76 +358,8 @@ pub async fn get_hexagons(
Ok(HexagonsResponse { features })
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
// If travel entries were requested and R5 is configured, fetch travel times concurrently.
if !travel_entries.is_empty() {
let url = r5_url.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Travel time queries require routing service (R5_URL not configured)".into(),
))?;
// Collect hex centroids
let origins: Vec<[f64; 2]> = response
.features
.iter()
.map(|f| {
let lat = f
.get("lat")
.and_then(|v| v.as_f64())
.expect("lat must be present in feature map");
let lon = f
.get("lon")
.and_then(|v| v.as_f64())
.expect("lon must be present in feature map");
[lat, lon]
})
.collect();
// Fire concurrent R5 calls for each travel entry
let mut handles = Vec::with_capacity(travel_entries.len());
for entry in &travel_entries {
let client = http_client.clone();
let url = url.to_string();
let origins = origins.clone();
let dest = [entry.lat, entry.lon];
let mode = entry.mode.clone();
handles.push(tokio::spawn(async move {
fetch_travel_times(&client, &url, origins, dest, &mode).await
}));
}
let mut results = Vec::with_capacity(handles.len());
for handle in handles {
results.push(handle.await);
}
for (entry, result) in travel_entries.iter().zip(results) {
let travel_times = result
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|err| (StatusCode::BAD_GATEWAY, err))?;
let field_name = format!("travel_time_{}", entry.mode);
for (feature, tt) in response.features.iter_mut().zip(&travel_times) {
match tt {
Some(minutes) => {
if let Some(num) = serde_json::Number::from_f64(*minutes) {
feature.insert(field_name.clone(), Value::Number(num));
}
}
None => {
feature.insert(field_name.clone(), Value::Null);
}
}
}
info!(
hexagons = response.features.len(),
destination = format_args!("{},{}", entry.lat, entry.lon),
mode = entry.mode,
"Travel times merged"
);
}
}
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}

View file

@ -0,0 +1,374 @@
use std::sync::Arc;
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
#[derive(Serialize)]
struct InviteResponse {
code: String,
url: String,
invite_type: String,
}
#[derive(Serialize)]
struct InviteValidation {
valid: bool,
invite_type: String,
used: bool,
}
#[derive(Deserialize)]
pub struct RedeemRequest {
code: String,
}
#[derive(Serialize)]
struct RedeemResponse {
/// "licensed" if admin invite was redeemed directly, or a checkout URL for referral
result: String,
/// For referral invites: the Stripe checkout URL with coupon
checkout_url: Option<String>,
}
/// Validate that an invite code contains only safe characters (alphanumeric, lowercase).
/// Rejects any code that could be used for PocketBase filter injection.
fn validate_invite_code(code: &str) -> Result<(), &'static str> {
if code.is_empty() || code.len() > 20 {
return Err("Invalid invite code length");
}
if !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err("Invalid invite code characters");
}
Ok(())
}
fn generate_invite_code() -> String {
use rand::Rng;
let mut rng = rand::rng();
let chars: Vec<char> = (0..12)
.map(|_| {
let idx: u8 = rng.random_range(0..36);
if idx < 10 {
(b'0' + idx) as char
} else {
(b'a' + idx - 10) as char
}
})
.collect();
chars.into_iter().collect()
}
/// Create an invite. Admins create "admin" invites (free license).
/// Licensed non-admin users create "referral" invites (30% off).
pub async fn post_invites(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
_body: Json<serde_json::Value>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let invite_type = if user.is_admin {
"admin"
} else if user.subscription == "licensed" {
"referral"
} else {
return (StatusCode::FORBIDDEN, "Only licensed users can create invites").into_response();
};
let code = generate_invite_code();
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let create_url = format!("{pb_url}/api/collections/invites/records");
let res = state
.http_client
.post(&create_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"code": code,
"created_by": user.id,
"invite_type": invite_type,
"used_by_id": "",
"used_at": "",
}))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
let public_url = &state.public_url;
let url = format!("{public_url}/invite/{code}");
info!(code = %code, invite_type, user_id = %user.id, "Created invite");
Json(InviteResponse {
code,
url,
invite_type: invite_type.to_string(),
})
.into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("Failed to create invite ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("PocketBase request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
/// Validate an invite code. Requires authentication to prevent enumeration.
pub async fn get_invite(
state: Arc<AppState>,
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();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!("code=\"{}\"", code);
let url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = match state.http_client.get(&url).send().await {
Ok(r) => r,
Err(err) => {
warn!("Failed to look up invite: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
if !res.status().is_success() {
return StatusCode::BAD_GATEWAY.into_response();
}
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
};
let items = body["items"].as_array();
match items.and_then(|arr| arr.first()) {
Some(invite) => {
let invite_type = invite["invite_type"].as_str().unwrap_or("").to_string();
let used_by = invite["used_by_id"].as_str().unwrap_or("");
let used = !used_by.is_empty();
Json(InviteValidation {
valid: true,
invite_type,
used,
})
.into_response()
}
None => Json(InviteValidation {
valid: false,
invite_type: String::new(),
used: false,
})
.into_response(),
}
}
/// Redeem an invite code. Requires authentication.
/// Admin invite: sets subscription to "licensed" directly.
/// Referral invite: returns a discounted Stripe checkout URL.
pub async fn post_redeem_invite(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<RedeemRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
if let Err(msg) = validate_invite_code(&req.code) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
// Look up invite
let filter = format!(
"code=\"{}\" && used_by_id=\"\"",
req.code
);
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = match state.http_client.get(&lookup_url)
.header("Authorization", format!("Bearer {token}"))
.send().await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to look up invite: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
};
let invite = match body["items"].as_array().and_then(|arr| arr.first()) {
Some(inv) => inv.clone(),
None => {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
}
};
let invite_id = invite["id"].as_str().unwrap_or("");
let invite_type = invite["invite_type"].as_str().unwrap_or("");
// Mark invite as used
let now = {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
dur.as_secs().to_string()
};
let _ = state
.http_client
.patch(&format!(
"{pb_url}/api/collections/invites/records/{invite_id}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"used_by_id": user.id,
"used_at": now,
}))
.send()
.await;
if invite_type == "admin" {
// Grant license directly
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
.patch(&update_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(&user.id);
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed — user licensed");
Json(RedeemResponse {
result: "licensed".to_string(),
checkout_url: None,
})
.into_response()
}
_ => {
warn!("Failed to update user subscription for admin invite");
StatusCode::BAD_GATEWAY.into_response()
}
}
} else {
// Referral invite — create discounted checkout with dynamic pricing
let count = match super::pricing::count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users for invite checkout: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let price_pence = super::pricing::price_for_count(count);
let secret_key = &state.stripe_secret_key;
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
let cancel_url = format!("{public_url}/pricing");
let form_params = vec![
("mode", "payment".to_string()),
(
"line_items[0][price_data][unit_amount]",
price_pence.to_string(),
),
("line_items[0][price_data][currency]", "gbp".to_string()),
(
"line_items[0][price_data][product_data][name]",
"Perfect Postcodes Lifetime License".to_string(),
),
("line_items[0][quantity]", "1".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()),
];
let stripe_res = state
.http_client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(secret_key, None::<&str>)
.form(&form_params)
.send()
.await;
match stripe_res {
Ok(resp) if resp.status().is_success() => {
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
let checkout_url = stripe_body["url"]
.as_str()
.unwrap_or_default()
.to_string();
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
Json(RedeemResponse {
result: "checkout".to_string(),
checkout_url: Some(checkout_url),
})
.into_response()
}
_ => {
warn!("Failed to create Stripe checkout for referral invite");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
}

View file

@ -0,0 +1,64 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::Deserialize;
use tracing::warn;
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct UpdateNewsletterRequest {
newsletter: bool,
}
pub async fn patch_newsletter(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<UpdateNewsletterRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to authenticate as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "newsletter": req.newsletter }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(&user.id);
StatusCode::OK.into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase user update failed ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("PocketBase request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}

View file

@ -23,9 +23,16 @@ pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl Int
let method = req.method().clone();
let mut builder = state.http_client.request(method, &url);
// Forward headers except host
// Forward only safe headers (allowlist)
const ALLOWED_HEADERS: &[&str] = &[
"content-type",
"accept",
"authorization",
"cookie",
"accept-language",
];
for (name, value) in req.headers() {
if name != "host" {
if ALLOWED_HEADERS.contains(&name.as_str()) {
builder = builder.header(name.clone(), value.clone());
}
}

View file

@ -6,11 +6,13 @@ use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::data::slugify;
use crate::state::AppState;
#[derive(Serialize)]
pub struct PlaceResult {
name: String,
slug: String,
place_type: String,
lat: f32,
lon: f32,
@ -28,6 +30,8 @@ pub struct PlacesResponse {
pub struct PlacesParams {
q: String,
limit: Option<usize>,
/// If set, only return places that have travel time data for this mode.
mode: Option<String>,
}
pub async fn get_places(
@ -41,33 +45,44 @@ pub async fn get_places(
};
let limit = params.limit.unwrap_or(7).min(20);
let mode_filter = params.mode;
let places = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
let query_lower = query.to_lowercase();
let pd = &state.place_data;
let tt_store = &state.travel_time_store;
// Linear scan — ~50-100k rows, <1ms
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len)
let mut matches: Vec<(usize, bool, bool, u8, u32, usize)> = pd
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len, slug)
let mut matches: Vec<(usize, bool, bool, u8, u32, usize, String)> = pd
.name_lower
.iter()
.enumerate()
.filter_map(|(idx, name)| {
if name.contains(&query_lower) {
let is_exact = name.len() == query_lower.len();
let is_prefix = name.starts_with(&query_lower);
Some((
idx,
is_exact,
is_prefix,
pd.type_rank[idx],
pd.population[idx],
pd.name[idx].len(),
))
} else {
None
if !name.contains(&query_lower) {
return None;
}
let slug = slugify(&pd.name[idx]);
// If mode filter is set, only include places with travel data
if let Some(ref mode) = mode_filter {
if !tt_store.has_destination(mode, &slug) {
return None;
}
}
let is_exact = name.len() == query_lower.len();
let is_prefix = name.starts_with(&query_lower);
Some((
idx,
is_exact,
is_prefix,
pd.type_rank[idx],
pd.population[idx],
pd.name[idx].len(),
slug,
))
})
.collect();
@ -85,12 +100,13 @@ pub async fn get_places(
let results: Vec<PlaceResult> = matches
.iter()
.map(|&(idx, ..)| PlaceResult {
name: pd.name[idx].clone(),
place_type: pd.place_type.get(idx).to_string(),
lat: pd.lat[idx],
lon: pd.lon[idx],
city: pd.city[idx].clone(),
.map(|(idx, .., slug)| PlaceResult {
name: pd.name[*idx].clone(),
slug: slug.clone(),
place_type: pd.place_type.get(*idx).to_string(),
lat: pd.lat[*idx],
lon: pd.lon[*idx],
city: pd.city[*idx].clone(),
})
.collect();
@ -99,6 +115,7 @@ pub async fn get_places(
query = query.as_str(),
results = results.len(),
scanned = pd.name_lower.len(),
mode = mode_filter.as_deref().unwrap_or("-"),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/places"
);

View file

@ -2,11 +2,14 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::check_license_point;
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
use crate::state::AppState;
@ -24,8 +27,9 @@ pub struct PostcodeStatsParams {
pub async fn get_postcode_stats(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodeStatsParams>,
) -> Result<Json<HexagonStatsResponse>, (StatusCode, String)> {
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
// Normalize postcode: uppercase, collapse whitespace
let normalized = params
.postcode
@ -42,18 +46,23 @@ pub async fn get_postcode_stats(
return Err((
StatusCode::NOT_FOUND,
format!("Postcode not found: {}", normalized),
));
)
.into_response());
}
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
// License check using postcode centroid
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)
.map_err(|(_, resp)| resp)?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
@ -129,8 +138,8 @@ pub async fn get_postcode_stats(
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}

View file

@ -2,14 +2,17 @@ use std::sync::Arc;
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::info;
use crate::aggregation::Aggregator;
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::licensing::check_license_bounds;
use crate::parsing::{
bounds_intersect, parse_field_indices, parse_filters, require_bounds, row_passes_filters,
};
@ -60,9 +63,14 @@ fn build_postcode_geometry(rings: &[Vec<[f32; 2]>]) -> Value {
pub async fn get_postcodes(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodeParams>,
) -> Result<Json<PostcodesResponse>, (StatusCode, String)> {
let (south, west, north, east) = require_bounds(params.bounds)?;
) -> Result<Json<PostcodesResponse>, axum::response::Response> {
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
@ -70,10 +78,11 @@ pub async fn get_postcodes(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
.map_err(|err| (err.0, err.1).into_response())?;
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
let postcode_data = &state.postcode_data;
@ -222,7 +231,7 @@ pub async fn get_postcodes(
}
}
let truncated = features.len() >= MAX_CELLS_PER_REQUEST;
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
let t_total = t0.elapsed();
info!(
postcodes_before_filter,
@ -242,8 +251,8 @@ pub async fn get_postcodes(
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}

View file

@ -0,0 +1,105 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use tracing::warn;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
/// Pricing tiers: (cumulative user cap, price in pence).
const TIERS: &[(u64, u64)] = &[
(10, 0), // First 10 users: free
(20, 1000), // Next 10: £10
(45, 2500), // Next 25: £25
(95, 5000), // Next 50: £50
];
const FINAL_PRICE_PENCE: u64 = 10000; // £100 after 95
#[derive(Serialize)]
pub struct Tier {
up_to: Option<u64>,
price_pence: u64,
slots: u64,
}
#[derive(Serialize)]
pub struct PricingResponse {
licensed_count: u64,
current_price_pence: u64,
tiers: Vec<Tier>,
}
/// Determine the price (in pence) for the next user given `count` existing licensed users.
pub fn price_for_count(count: u64) -> u64 {
for &(cap, price) in TIERS {
if count < cap {
return price;
}
}
FINAL_PRICE_PENCE
}
/// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
let filter = "subscription=\"licensed\"";
let url = format!(
"{pb_url}/api/collections/users/records?filter={}&perPage=1",
urlencoding::encode(filter)
);
let resp = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("PocketBase returned {}", resp.status());
}
let body: serde_json::Value = resp.json().await?;
let total = body["totalItems"].as_u64().unwrap_or(0);
Ok(total)
}
pub async fn get_pricing(state: Arc<AppState>) -> Response {
let count = match count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let current_price = price_for_count(count);
let mut tiers = Vec::new();
let mut prev_cap = 0u64;
for &(cap, price) in TIERS {
tiers.push(Tier {
up_to: Some(cap),
price_pence: price,
slots: cap - prev_cap,
});
prev_cap = cap;
}
tiers.push(Tier {
up_to: None,
price_pence: FINAL_PRICE_PENCE,
slots: 0,
});
Json(PricingResponse {
licensed_count: count,
current_price_pence: current_price,
tiers,
})
.into_response()
}

View file

@ -3,12 +3,15 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
use crate::licensing::check_license_bounds;
use crate::parsing::{
cell_for_row, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
validate_h3_resolution,
@ -90,19 +93,25 @@ fn lookup_enum_value(
pub async fn get_hexagon_properties(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, (StatusCode, String)> {
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(
StatusCode::BAD_REQUEST,
format!("Invalid H3 cell: {}", error),
)
.into_response()
})?;
let cell_u64: u64 = cell.into();
let resolution = params.resolution;
validate_h3_resolution(resolution)?;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
@ -111,7 +120,7 @@ pub async fn get_hexagon_properties(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let result = tokio::task::spawn_blocking(move || {
@ -249,8 +258,8 @@ pub async fn get_hexagon_properties(
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(result))
}

View file

@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use rustc_hash::FxHashMap;
use tracing::warn;
use crate::consts::MAX_PRICE_HISTORY_POINTS;
use crate::data::FeatureStats;
@ -78,6 +79,13 @@ pub fn compute_feature_stats(
let idx = value as usize;
if idx < value_counts.len() {
value_counts[idx] += 1;
} else {
warn!(
feature = feature_name.as_str(),
idx,
max = value_counts.len(),
"Enum index out of bounds — possible data/schema mismatch"
);
}
}
}

View file

@ -0,0 +1,129 @@
use std::sync::Arc;
use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tracing::{info, warn};
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
type HmacSha256 = Hmac<Sha256>;
/// Verify Stripe webhook signature (v1 scheme).
fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
// Parse timestamp and signature from header: "t=TIMESTAMP,v1=SIGNATURE"
let mut timestamp = None;
let mut signature = None;
for part in sig_header.split(',') {
if let Some(ts) = part.strip_prefix("t=") {
timestamp = Some(ts);
} else if let Some(sig) = part.strip_prefix("v1=") {
signature = Some(sig);
}
}
let (ts, sig_hex) = match (timestamp, signature) {
(Some(t), Some(s)) => (t, s),
_ => return false,
};
// Compute expected signature: HMAC-SHA256(secret, "TIMESTAMP.PAYLOAD")
let signed_payload = format!("{ts}.{}", String::from_utf8_lossy(payload));
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(signed_payload.as_bytes());
// Decode the provided hex signature and verify with constant-time comparison
let sig_bytes = match hex::decode(sig_hex) {
Ok(bytes) => bytes,
Err(_) => return false,
};
mac.verify_slice(&sig_bytes).is_ok()
}
/// Handle Stripe webhook events.
/// On `checkout.session.completed`, updates the user's subscription to "licensed".
pub async fn post_stripe_webhook(
state: Arc<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let webhook_secret = &state.stripe_webhook_secret;
let sig_header = match headers.get("stripe-signature").and_then(|h| h.to_str().ok()) {
Some(s) => s,
None => {
warn!("Missing Stripe-Signature header");
return StatusCode::BAD_REQUEST.into_response();
}
};
if !verify_signature(&body, sig_header, webhook_secret) {
warn!("Invalid Stripe webhook signature");
return StatusCode::BAD_REQUEST.into_response();
}
let event: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse webhook body: {err}");
return StatusCode::BAD_REQUEST.into_response();
}
};
let event_type = event["type"].as_str().unwrap_or("");
info!(event_type, "Received Stripe webhook");
if event_type == "checkout.session.completed" {
let user_id = event["data"]["object"]["client_reference_id"]
.as_str()
.unwrap_or("");
if user_id.is_empty() {
warn!("checkout.session.completed missing client_reference_id");
return StatusCode::OK.into_response();
}
// Update user subscription to "licensed" via PocketBase superuser auth
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password)
.await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser in webhook: {err}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let res = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(user_id);
info!(user_id, "User subscription updated to licensed via Stripe webhook");
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!(user_id, "Failed to update user subscription ({status}): {text}");
}
Err(err) => {
warn!(user_id, "PocketBase request error in webhook: {err}");
}
}
}
StatusCode::OK.into_response()
}

View file

@ -0,0 +1,78 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::Deserialize;
use tracing::warn;
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
const VALID_SUBSCRIPTIONS: &[&str] = &["free", "licensed"];
#[derive(Deserialize)]
pub struct UpdateSubscriptionRequest {
subscription: String,
}
pub async fn patch_subscription(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<UpdateSubscriptionRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
if !user.is_admin {
return StatusCode::FORBIDDEN.into_response();
}
if !VALID_SUBSCRIPTIONS.contains(&req.subscription.as_str()) {
return (
StatusCode::BAD_REQUEST,
format!("Invalid subscription: {}", req.subscription),
)
.into_response();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to authenticate as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": req.subscription }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(&user.id);
StatusCode::OK.into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase user update failed ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("PocketBase request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use pmtiles::async_reader::AsyncPmTilesReader;
use pmtiles::MmapBackend;
@ -40,7 +40,7 @@ pub struct StyleParams {
pub async fn get_style(
State(reader): State<Arc<TileReader>>,
headers: HeaderMap,
public_url: String,
Query(params): Query<StyleParams>,
) -> Result<Response, (StatusCode, String)> {
let is_dark = params.theme.as_deref() == Some("dark");
@ -50,7 +50,7 @@ pub async fn get_style(
warn!(error = %err, "Failed to get PMTiles metadata");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get PMTiles metadata: {err}"),
"Failed to read tile metadata".to_string(),
)
})?;
@ -59,7 +59,7 @@ pub async fn get_style(
warn!(error = %err, "Failed to parse PMTiles metadata JSON");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse PMTiles metadata: {err}"),
"Failed to parse tile metadata".to_string(),
)
})?;
@ -70,15 +70,8 @@ pub async fn get_style(
.cloned()
.unwrap_or_default();
// Build absolute tile URL using the request host
let host = headers
.get(header::HOST)
.and_then(|hv| hv.to_str().ok())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing Host header".into(),
))?;
let tile_url = format!("http://{}/api/tiles/{{z}}/{{x}}/{{y}}", host);
// Build absolute tile URL using the configured public URL (not the Host header)
let tile_url = format!("{}/api/tiles/{{z}}/{{x}}/{{y}}", public_url.trim_end_matches('/'));
let style = build_style(is_dark, &layers, &tile_url);
Ok((

View file

@ -0,0 +1,38 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::Json;
use serde::Serialize;
use crate::state::AppState;
#[derive(Serialize)]
pub struct TravelModeInfo {
mode: String,
destinations: usize,
}
#[derive(Serialize)]
pub struct TravelModesResponse {
modes: Vec<TravelModeInfo>,
}
pub async fn get_travel_modes(
state: Arc<AppState>,
) -> Result<Json<TravelModesResponse>, (StatusCode, String)> {
let store = &state.travel_time_store;
let modes = store
.available_modes
.iter()
.map(|mode| TravelModeInfo {
mode: mode.clone(),
destinations: store
.destinations
.get(mode)
.map(|slugs| slugs.len())
.unwrap_or(0),
})
.collect();
Ok(Json(TravelModesResponse { modes }))
}

Some files were not shown because too many files have changed in this diff Show more