Lots of improvements

This commit is contained in:
Andras Schmelczer 2026-02-22 21:09:07 +00:00
parent 205302dbb8
commit eb02b5832b
39 changed files with 699 additions and 271 deletions

View file

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

View file

@ -230,7 +230,7 @@ export default function App() {
<MapPage <MapPage
features={features} features={features}
poiCategoryGroups={poiCategoryGroups} poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || {}} initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
initialViewState={initialViewState} initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()} initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'} initialTab={urlState.tab || 'area'}
@ -326,7 +326,7 @@ export default function App() {
<MapPage <MapPage
features={features} features={features}
poiCategoryGroups={poiCategoryGroups} poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || {}} initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
initialViewState={initialViewState} initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()} initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'} initialTab={urlState.tab || 'area'}
@ -371,7 +371,7 @@ export default function App() {
/> />
)} )}
{showLicenseSuccess && ( {showLicenseSuccess && (
<LicenseSuccessModal onClose={() => setShowLicenseSuccess(false)} /> <LicenseSuccessModal onClose={() => { setShowLicenseSuccess(false); navigateTo('dashboard'); }} />
)} )}
</div> </div>
); );

View file

@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth'; import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches'; import type { SavedSearch } from '../../hooks/useSavedSearches';
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api'; import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { formatRelativeTime } from '../../lib/format'; import { formatRelativeTime } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state'; import { summarizeParams } from '../../lib/url-state';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -42,37 +43,24 @@ function SavedSearchesContent({
setDeleteConfirmId(null); setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]); }, [deleteConfirmId, onDelete]);
const copyToClipboard = useCallback((text: string, id: string) => { const doCopy = useCallback((text: string, id: string) => {
const onSuccess = () => { copyToClipboard(text, () => {
setCopiedId(id); setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
}; });
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}, []); }, []);
const handleShare = useCallback(async (params: string, id: string) => { const handleShare = useCallback(async (params: string, id: string) => {
setSharingId(id); setSharingId(id);
try { try {
const shortUrl = await shortenUrl(params); const shortUrl = await shortenUrl(params);
copyToClipboard(shortUrl, id); doCopy(shortUrl, id);
} catch { } catch {
copyToClipboard(`${window.location.origin}/?${params}`, id); doCopy(`${window.location.origin}/?${params}`, id);
} finally { } finally {
setSharingId(null); setSharingId(null);
} }
}, [copyToClipboard]); }, [doCopy]);
return ( return (
<> <>
@ -270,7 +258,7 @@ function SettingsContent({
const handleCopyInvite = () => { const handleCopyInvite = () => {
if (!inviteUrl) return; if (!inviteUrl) return;
navigator.clipboard.writeText(inviteUrl).then(() => { copyToClipboard(inviteUrl, () => {
setInviteCopied(true); setInviteCopied(true);
setTimeout(() => setInviteCopied(false), 2000); setTimeout(() => setInviteCopied(false), 2000);
}); });
@ -284,7 +272,7 @@ function SettingsContent({
const isLicensed = user.subscription === 'licensed' || user.isAdmin; const isLicensed = user.subscription === 'licensed' || user.isAdmin;
return ( return (
<div className="max-w-lg mx-auto"> <div className="max-w-lg mx-auto space-y-6">
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700"> <div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
{/* Email */} {/* Email */}
<div className="px-5 py-4 flex items-center justify-between"> <div className="px-5 py-4 flex items-center justify-between">
@ -455,6 +443,20 @@ function SettingsContent({
</div> </div>
)} )}
</div> </div>
{/* Support */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
<a
href="mailto:support@propertymap.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@propertymap.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
We typically respond within 24 hours.
</p>
</div>
</div> </div>
); );
} }

View file

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

View file

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

View file

@ -109,6 +109,11 @@ export default function AreaPane({
{propertyCount.toLocaleString()} properties {propertyCount.toLocaleString()} properties
</p> </p>
)} )}
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
Stats for {isPostcode ? 'current and historical' : 'all'} properties
in this {isPostcode ? 'postcode' : 'area'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
</p>
{!isPostcode && stats && ( {!isPostcode && stats && (
<button <button
onClick={onViewProperties} onClick={onViewProperties}

View file

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

View file

@ -32,6 +32,8 @@ interface FeatureBrowserProps {
onClearOpenInfoFeature?: () => void; onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[]; travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void; onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
} }
export default function FeatureBrowser({ export default function FeatureBrowser({
@ -45,6 +47,8 @@ export default function FeatureBrowser({
onClearOpenInfoFeature, onClearOpenInfoFeature,
travelTimeEntries, travelTimeEntries,
onAddTravelTimeEntry, onAddTravelTimeEntry,
isLicensed,
onUpgradeClick,
}: FeatureBrowserProps) { }: FeatureBrowserProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null); const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -183,10 +187,31 @@ export default function FeatureBrowser({
} }
className="px-3 py-4" className="px-3 py-4"
/> />
) : ( ) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500"> <p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Everyone cares about different things. Pick the filters that matter most to you. Everyone cares about different things. Pick the filters that matter most to you.
</p> </p>
) : (
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
The biggest financial decision of your life deserves proper tools behind it.
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
Don&apos;t leave it to chance.
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
Upgrade to full map
</button>
<svg viewBox="0 120 1600 230" className="w-full mt-4 block shrink-0" preserveAspectRatio="xMidYMax meet">
<path d="M0,350 C400,150 1200,150 1600,350 Z" className="fill-green-500 dark:fill-green-600" />
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)} )}
</div> </div>
{infoFeature && ( {infoFeature && (

View file

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

View file

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

View file

@ -296,6 +296,7 @@ export default memo(function Map({
: data.find((d) => d.h3 === hoveredHexagonId) || null : data.find((d) => d.h3 === hoveredHexagonId) || null
} }
filters={filters} filters={filters}
features={features}
/> />
)} )}
</> </>

View file

@ -229,16 +229,21 @@ export default function MapPage({
const isPostcode = selection.selectedHexagon?.type === 'postcode'; const isPostcode = selection.selectedHexagon?.type === 'postcode';
if (isPostcode) { if (isPostcode) {
// For postcodes, get centroid from postcodeData // For postcodes, get centroid from postcodeData; postcode string is the selection id
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId); const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
if (!postcodeFeature?.properties.centroid) return null; if (!postcodeFeature?.properties.centroid) return null;
const [lon, lat] = postcodeFeature.properties.centroid; const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution }; return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
} else { } else {
// For hexagons, get lat/lon from hexagon data // For hexagons, get lat/lon from hexagon data; central postcode comes from stats
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null; const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; return {
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
postcode: selection.areaStats?.central_postcode,
};
} }
}, [ }, [
selection.selectedHexagon?.id, selection.selectedHexagon?.id,
@ -246,6 +251,7 @@ export default function MapPage({
mapData.data, mapData.data,
mapData.postcodeData, mapData.postcodeData,
mapData.resolution, mapData.resolution,
selection.areaStats?.central_postcode,
]); ]);
const tutorial = useTutorial(initialLoading, isMobile); const tutorial = useTutorial(initialLoading, isMobile);
@ -400,10 +406,14 @@ export default function MapPage({
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange} onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading} aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error} aiFilterError={aiFilters.error}
aiFilterNotes={aiFilters.notes} aiFilterNotes={aiFilters.notes}
onAiFilterSubmit={handleAiFilterSubmit} onAiFilterSubmit={handleAiFilterSubmit}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
/> />
); );
@ -560,6 +570,7 @@ export default function MapPage({
callback={tutorial.handleCallback} callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)} styles={getTutorialStyles(theme)}
disableScrolling disableScrolling
locale={{ last: 'Finish' }}
/> />
<div <div
@ -626,38 +637,40 @@ export default function MapPage({
)} )}
</div> </div>
<div {selection.selectedHexagon && (
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div <div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700" data-tutorial="right-pane"
{...rightPaneHandlers} className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
> >
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" /> <div
</div> className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
<div className="flex-1 flex flex-col overflow-hidden"> {...rightPaneHandlers}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm"> >
<TabButton <div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
</div> </div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
</div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties' {selection.rightPaneTab === 'properties'
? renderPropertiesPane() ? renderPropertiesPane()
: renderAreaPane()} : renderAreaPane()}
</div>
</div> </div>
</div> </div>
</div> )}
{mapData.licenseRequired && ( {mapData.licenseRequired && (
<UpgradeModal <UpgradeModal

View file

@ -26,11 +26,13 @@ interface TravelTimeCardProps {
slug: string; slug: string;
label: string; label: string;
timeRange: [number, number] | null; timeRange: [number, number] | null;
useBest: boolean;
dataRange: [number, number] | null; dataRange: [number, number] | null;
isPinned: boolean; isPinned: boolean;
onTogglePin: () => void; onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void; onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void; onTimeRangeChange: (range: [number, number]) => void;
onToggleBest: () => void;
onRemove: () => void; onRemove: () => void;
} }
@ -39,11 +41,13 @@ export function TravelTimeCard({
slug, slug,
label, label,
timeRange, timeRange,
useBest,
dataRange, dataRange,
isPinned, isPinned,
onTogglePin, onTogglePin,
onSetDestination, onSetDestination,
onTimeRangeChange, onTimeRangeChange,
onToggleBest,
onRemove, onRemove,
}: TravelTimeCardProps) { }: TravelTimeCardProps) {
const search = useLocationSearch(mode); const search = useLocationSearch(mode);
@ -119,6 +123,24 @@ export function TravelTimeCard({
)} )}
</div> </div>
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={useBest}
onChange={onToggleBest}
className="accent-teal-600 rounded"
/>
<span className="text-xs text-warm-600 dark:text-warm-300">
Best case
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
(optimal departure)
</span>
</label>
)}
{/* Time range slider — only show when we have data */} {/* Time range slider — only show when we have data */}
{slug && dataRange && ( {slug && dataRange && (
<div> <div>

View file

@ -1,6 +1,7 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react'; import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers'; import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
import { cellToBoundary } from 'h3-js';
import type { PickingInfo } from '@deck.gl/core'; import type { PickingInfo } from '@deck.gl/core';
import type { import type {
HexagonData, HexagonData,
@ -80,13 +81,13 @@ export function useDeckLayers({
// Marching ants animation // Marching ants animation
const [marchTime, setMarchTime] = useState(0); const [marchTime, setMarchTime] = useState(0);
const hasPostcodeGeometry = selectedPostcodeGeometry != null; const hasSelection = selectedPostcodeGeometry != null || selectedHexagonId != null;
useEffect(() => { useEffect(() => {
if (!hasPostcodeGeometry) return; if (!hasSelection) return;
setMarchTime(0); setMarchTime(0);
const id = setInterval(() => setMarchTime((t) => t + 0.3), 50); const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
return () => clearInterval(id); return () => clearInterval(id);
}, [hasPostcodeGeometry]); }, [hasSelection]);
const isDark = theme === 'dark'; const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
@ -332,14 +333,11 @@ export function useDeckLayers({
); );
}, },
getLineColor: (d) => { getLineColor: (d) => {
if (d.h3 === selectedHexagonIdRef.current)
return [255, 255, 255, 255] as [number, number, number, number];
if (d.h3 === hoveredHexagonIdRef.current) if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number]; return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number]; return [0, 0, 0, 0] as [number, number, number, number];
}, },
getLineWidth: (d) => { getLineWidth: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return 3;
if (d.h3 === hoveredHexagonIdRef.current) return 2; if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0; return 0;
}, },
@ -481,15 +479,22 @@ export function useDeckLayers({
[pois, stablePoiHover] [pois, stablePoiHover]
); );
// Marching ants highlight layer for selected postcode // Marching ants highlight layer for selected hexagon or postcode
const marchingAntsLayer = useMemo(() => { const marchingAntsLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null; let geometry: PostcodeGeometry | null = null;
if (selectedPostcodeGeometry) {
geometry = selectedPostcodeGeometry;
} else if (selectedHexagonId) {
const boundary = cellToBoundary(selectedHexagonId, true);
geometry = { type: 'Polygon', coordinates: [boundary] };
}
if (!geometry) return null;
return new GeoJsonLayer({ return new GeoJsonLayer({
id: 'marching-ants', id: 'marching-ants',
data: [ data: [
{ {
type: 'Feature' as const, type: 'Feature' as const,
geometry: selectedPostcodeGeometry, geometry,
properties: {}, properties: {},
}, },
], ],
@ -502,7 +507,7 @@ export function useDeckLayers({
marchTime, marchTime,
extensions: [new MarchingAntsExtension()], extensions: [new MarchingAntsExtension()],
}); });
}, [selectedPostcodeGeometry, marchTime]); }, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
const layers = useMemo(() => { const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -31,6 +31,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (!meta) return; if (!meta) return;
if (meta.type === 'enum' && meta.values) { if (meta.type === 'enum' && meta.values) {
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] })); setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
} else if (meta.type === 'numeric' && meta.histogram) {
setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] }));
} else if (meta.min != null && meta.max != null) { } else if (meta.min != null && meta.max != null) {
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] })); setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
} }

View file

@ -16,6 +16,8 @@ export interface TravelTimeEntry {
slug: string; slug: string;
label: string; label: string;
timeRange: [number, number] | null; timeRange: [number, number] | null;
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
useBest: boolean;
} }
/** Field key matching the backend response: tt_{mode}_{slug} */ /** Field key matching the backend response: tt_{mode}_{slug} */
@ -33,7 +35,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const handleAddEntry = useCallback((mode: TransportMode) => { const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [ setEntries((prev) => [
...prev, ...prev,
{ mode, slug: '', label: '', timeRange: null }, { mode, slug: '', label: '', timeRange: null, useBest: false },
]); ]);
}, []); }, []);
@ -63,6 +65,17 @@ export function useTravelTime(initial?: TravelTimeInitial) {
[] []
); );
const handleToggleBest = useCallback(
(index: number) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, useBest: !entry.useBest, timeRange: null } : entry
)
);
},
[]
);
/** Entries that have a destination selected (slug is set) */ /** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo( const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''), () => entries.filter((e) => e.slug !== ''),
@ -76,5 +89,6 @@ export function useTravelTime(initial?: TravelTimeInitial) {
handleRemoveEntry, handleRemoveEntry,
handleSetDestination, handleSetDestination,
handleTimeRangeChange, handleTimeRangeChange,
handleToggleBest,
}; };
} }

View file

@ -9,7 +9,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="filters"]', target: '[data-tutorial="filters"]',
title: 'Filter Properties', title: 'Filter Properties',
content: content:
'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.', 'Use filters to narrow down to areas which contain matching properties. Filter by crime rate, number of schools around, or filter to an area with detached houses. Pin a filter with the eye icon to colour the map by that feature.',
placement: 'right', placement: 'right',
disableBeacon: true, disableBeacon: true,
}, },
@ -17,7 +17,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="map"]', target: '[data-tutorial="map"]',
title: 'Explore the Map', title: 'Explore the Map',
content: content:
'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.', 'Pan and zoom to explore property data across England. Click any area (hexagon or postcode boundary) to see detailed stats of historical or currently sold properties matching your filters.',
placement: 'bottom', placement: 'bottom',
disableBeacon: true, disableBeacon: true,
}, },
@ -44,6 +44,11 @@ const STEPS: Step[] = [
'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.', 'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
placement: 'left', placement: 'left',
disableBeacon: true, disableBeacon: true,
styles: {
tooltip: {
transform: 'translateY(-50px)',
},
},
}, },
]; ];

View file

@ -26,7 +26,7 @@ uniform marchingAntsUniforms {
} marchingAnts;`, } marchingAnts;`,
'fs:DECKGL_FILTER_COLOR': `\ 'fs:DECKGL_FILTER_COLOR': `\
float marchSegLen = 4.0; float marchSegLen = 4.0;
float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0); float marchPos = mod(geometry.uv.y - marchingAnts.marchTime, marchSegLen * 2.0);
if (marchPos < marchSegLen) { if (marchPos < marchSegLen) {
color = vec4(1.0, 1.0, 1.0, color.a); color = vec4(1.0, 1.0, 1.0, color.a);
} else { } else {

View file

@ -81,7 +81,8 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta
return `${name}:${(value as string[]).join('|')}`; return `${name}:${(value as string[]).join('|')}`;
} }
const [min, max] = value as [number, number]; const [min, max] = value as [number, number];
const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max); const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max;
const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max);
return `${name}:${min}:${maxStr}`; return `${name}:${min}:${maxStr}`;
}) })
.join(';;'); .join(';;');

View file

@ -0,0 +1,16 @@
/** Copy text to clipboard with execCommand fallback for older browsers. */
export function copyToClipboard(text: string, onSuccess: () => void): void {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}

View file

@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.42, west: -0.34, north: 51.60, east:
export const INITIAL_VIEW_STATE: ViewState = { export const INITIAL_VIEW_STATE: ViewState = {
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2, longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2, latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
zoom: 14, zoom: 15,
pitch: 0, pitch: 0,
}; };
@ -33,10 +33,9 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 10.5, resolution: 7 }, { maxZoom: 10.5, resolution: 7 },
{ maxZoom: 11.5, resolution: 8 }, { maxZoom: 11.5, resolution: 8 },
{ maxZoom: 13, resolution: 9 }, { maxZoom: 13, resolution: 9 },
{ maxZoom: Infinity, resolution: 10 },
] as const; ] as const;
export const POSTCODE_ZOOM_THRESHOLD = 16; export const POSTCODE_ZOOM_THRESHOLD = 14.5;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] }, { t: 0, color: [46, 204, 113] },

View file

@ -4,6 +4,8 @@ export interface HexagonLocation {
lat: number; lat: number;
lon: number; lon: number;
resolution: number; resolution: number;
postcode?: string;
isPostcode?: boolean;
} }
const PROPERTY_TYPE_MAP: Record< const PROPERTY_TYPE_MAP: Record<
@ -32,10 +34,10 @@ export const H3_RADIUS_MILES: Record<number, number> = {
6: 3, 6: 3,
7: 1, 7: 1,
8: 0.5, 8: 0.5,
9: 0.25, 9: 1,
10: 0.25, 10: 1,
11: 0.25, 11: 1,
12: 0.25, 12: 1,
}; };
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40]; const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
@ -46,13 +48,21 @@ function nearestRadius(target: number, allowed: number[]): number {
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best)); return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
} }
export function buildPropertySearchUrls( interface SearchUrlOptions {
location: HexagonLocation, location: HexagonLocation;
filters: FeatureFilters filters: FeatureFilters;
): { rightmove: string; onthemarket: string; zoopla: string } { rightmoveLocationId?: string;
const { lat, lon, resolution } = location; }
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`; export function buildPropertySearchUrls({
location,
filters,
rightmoveLocationId,
}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
const { postcode, resolution, isPostcode } = location;
if (!postcode) return null;
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
const priceFilter = filters['Last known price']; const priceFilter = filters['Last known price'];
const minPrice = const minPrice =
@ -66,43 +76,51 @@ export function buildPropertySearchUrls(
? (propertyTypes as string[]) ? (propertyTypes as string[])
: []; : [];
const rmParams = new URLSearchParams(); // Rightmove — requires locationIdentifier from typeahead API
rmParams.set('searchLocation', coordStr); let rightmove: string | null = null;
rmParams.set('channel', 'BUY'); if (rightmoveLocationId) {
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))); const rmParams = new URLSearchParams();
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice))); rmParams.set('searchLocation', postcode);
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice))); rmParams.set('useLocationIdentifier', 'true');
if (selectedTypes.length > 0) { rmParams.set('locationIdentifier', rightmoveLocationId);
const rmTypes = [ rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
...new Set( if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
selectedTypes.flatMap((t) => { if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove; if (selectedTypes.length > 0) {
return mapped ? mapped.split(',') : []; const rmTypes = [
}) ...new Set(
), selectedTypes.flatMap((t) => {
]; const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(',')); return mapped ? mapped.split(',') : [];
})
),
];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
}
rmParams.set('_includeSSTC', 'on');
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
} }
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
let otmType = 'property'; // OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
if (selectedTypes.length > 0) { const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
];
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
}
const otmParams = new URLSearchParams(); const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII))); otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice))); if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice))); if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
otmParams.set('search-site', 'geo'); if (selectedTypes.length > 0) {
otmParams.set('geo-lat', String(lat)); const otmTypes = [
otmParams.set('geo-lng', String(lon)); ...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
const onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`; ];
for (const ot of otmTypes) {
otmParams.append('prop-types', ot!);
}
}
otmParams.set('view', 'map-list');
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
// Zoopla
const zParams = new URLSearchParams(); const zParams = new URLSearchParams();
zParams.set('q', coordStr); zParams.set('q', postcode);
zParams.set('search_source', 'for-sale'); zParams.set('search_source', 'for-sale');
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII))); zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice))); if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
@ -115,7 +133,6 @@ export function buildPropertySearchUrls(
zParams.append('property_sub_type', zt!); zParams.append('property_sub_type', zt!);
} }
} }
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`; const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
return { rightmove, onthemarket, zoopla }; return { rightmove, onthemarket, zoopla };

View file

@ -71,7 +71,7 @@ export function parseUrlState(): {
} }
// Travel time: repeated `tt` params // Travel time: repeated `tt` params
// Format: mode:slug:label or mode:slug:label:min:max // Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
const ttParams = params.getAll('tt'); const ttParams = params.getAll('tt');
if (ttParams.length > 0) { if (ttParams.length > 0) {
const entries: TravelTimeEntry[] = []; const entries: TravelTimeEntry[] = [];
@ -82,15 +82,17 @@ export function parseUrlState(): {
if (!TRANSPORT_MODES.includes(mode)) continue; if (!TRANSPORT_MODES.includes(mode)) continue;
const slug = parts[1]; const slug = parts[1];
const label = decodeURIComponent(parts[2]); const label = decodeURIComponent(parts[2]);
const useBest = parts.length >= 4 && parts[3] === 'b';
const rangeOffset = useBest ? 1 : 0;
let timeRange: [number, number] | null = null; let timeRange: [number, number] | null = null;
if (parts.length >= 5) { if (parts.length >= 5 + rangeOffset) {
const min = Number(parts[3]); const min = Number(parts[3 + rangeOffset]);
const max = Number(parts[4]); const max = Number(parts[4 + rangeOffset]);
if (!isNaN(min) && !isNaN(max)) { if (!isNaN(min) && !isNaN(max)) {
timeRange = [min, max]; timeRange = [min, max];
} }
} }
entries.push({ mode, slug, label, timeRange }); entries.push({ mode, slug, label, timeRange, useBest });
} }
if (entries.length > 0) { if (entries.length > 0) {
result.travelTime = { entries }; result.travelTime = { entries };
@ -139,6 +141,7 @@ export function stateToParams(
for (const entry of travelTimeEntries) { for (const entry of travelTimeEntries) {
if (!entry.slug) continue; if (!entry.slug) continue;
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`; let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) val += ':b';
if (entry.timeRange) { if (entry.timeRange) {
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`; val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
} }

View file

@ -172,4 +172,5 @@ export interface HexagonStatsResponse {
numeric_features: NumericFeatureStats[]; numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[]; enum_features: EnumFeatureStats[];
price_history?: PricePoint[]; price_history?: PricePoint[];
central_postcode?: string;
} }

View file

@ -4,6 +4,9 @@ set -euo pipefail
# Batch-compute travel times from all places to all England postcodes # Batch-compute travel times from all places to all England postcodes
# for all transport modes (car, bicycle, walking, transit). # for all transport modes (car, bicycle, walking, transit).
# #
# Uses full England OSM + 2 GTFS feeds (BODS buses, National Rail).
# R5's TransportNetwork.fromDirectory() picks up all .osm.pbf and .zip files.
#
# Uses each place as origin with all postcodes as destinations — R5 does one # Uses each place as origin with all postcodes as destinations — R5 does one
# routing computation per place, then reads off travel times to all postcodes. # routing computation per place, then reads off travel times to all postcodes.
# For car/bicycle/walking this is symmetric (place->postcode = postcode->place). # For car/bicycle/walking this is symmetric (place->postcode = postcode->place).
@ -15,11 +18,10 @@ set -euo pipefail
# #
# Usage: # Usage:
# ./r5-java/run.sh # ./r5-java/run.sh
# ./r5-java/run.sh --threads 8 --heap 24g --output-dir property-data/travel-times
# --- Defaults --- # --- Defaults ---
THREADS=16 THREADS=4
HEAP=16g HEAP=12g
NETWORK_DIR=property-data/r5-network NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times OUTPUT_BASE=property-data/travel-times
R5_DIR=r5-java R5_DIR=r5-java
@ -102,25 +104,26 @@ fi
# R5 writes .mapdb temp files next to OSM/GTFS files during network construction. # R5 writes .mapdb temp files next to OSM/GTFS files during network construction.
# Copy source data to a writable build dir to avoid polluting the originals. # Copy source data to a writable build dir to avoid polluting the originals.
mkdir -p "$NETWORK_DIR" mkdir -p "$NETWORK_DIR"
DATA_DIR="property-data/transit" TRANSIT_SRC="property-data/transit"
NETWORK_DATA_DIR="$TRANSIT_SRC"
if [ ! -f "$NETWORK_DIR/network.dat" ]; then if [ ! -f "$NETWORK_DIR/network.dat" ]; then
BUILD_DIR="$NETWORK_DIR/build" BUILD_DIR="$NETWORK_DIR/build"
echo "--- No cached network — copying transit data to build dir ---" echo "--- No cached network — copying transit data to build dir ---"
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
if ! cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then if ! cp "$TRANSIT_SRC"/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then
echo "Warning: no .osm.pbf files found in property-data/transit/raw/" echo "Warning: no .osm.pbf files found in $TRANSIT_SRC/raw/"
fi fi
if ! cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null; then if ! cp "$TRANSIT_SRC"/*.zip "$BUILD_DIR/" 2>/dev/null; then
echo "Warning: no .zip files found in property-data/transit/" echo "Warning: no .zip files found in $TRANSIT_SRC/"
fi fi
DATA_DIR="$BUILD_DIR" NETWORK_DATA_DIR="$BUILD_DIR"
fi fi
# --- Step 5: Run batch --- # --- Step 5: Run batch ---
echo "" echo ""
echo "--- Starting batch computation ---" echo "--- Starting batch computation ---"
DATA_DIR="$DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \ DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \ java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
--postcodes property-data/arcgis_data.parquet \ --postcodes property-data/arcgis_data.parquet \
--places property-data/places.parquet \ --places property-data/places.parquet \

View file

@ -192,6 +192,9 @@ public class App {
if (attempt < MAX_RETRIES) { if (attempt < MAX_RETRIES) {
System.err.printf("%n [RETRY %d/%d] %s: %s%n", System.err.printf("%n [RETRY %d/%d] %s: %s%n",
attempt + 1, MAX_RETRIES, name, e.getMessage()); attempt + 1, MAX_RETRIES, name, e.getMessage());
} else {
System.err.printf("%n [FAIL TRACE] %s:%n", name);
e.printStackTrace(System.err);
} }
} }
} }
@ -215,7 +218,7 @@ public class App {
String safe = name.toLowerCase() String safe = name.toLowerCase()
.replaceAll("[^a-z0-9 -]", "") .replaceAll("[^a-z0-9 -]", "")
.replaceAll("\\s+", "-"); .replaceAll("\\s+", "-");
return String.format("%04d-%s.parquet", index, safe); return String.format("%06d-%s.parquet", index, safe);
} }
private static String requiredArg(String[] args, String name) { private static String requiredArg(String[] args, String name) {

View file

@ -29,6 +29,10 @@ public class Router {
private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00 private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00
private static final int MAX_TRIP_DURATION_MINUTES = 120; private static final int MAX_TRIP_DURATION_MINUTES = 120;
// Percentile indices in R5 result arrays (order must match task.percentiles in buildTask)
private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only)
private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0)
/** Result of computing travel times for a single origin with spatial pre-filtering. */ /** Result of computing travel times for a single origin with spatial pre-filtering. */
record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {} record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {}
@ -102,10 +106,9 @@ public class Router {
boolean isTransit = mode.equals("transit"); boolean isTransit = mode.equals("transit");
short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date);
// For transit: allTimes[0]=best (5th percentile), allTimes[1]=median (50th) // Transit requests [5th, 50th] percentiles; others request [50th] only
// For others: allTimes[0]=median (50th), no best short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0];
short[] medianTimes = isTransit ? allTimes[1] : allTimes[0]; short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null;
short[] bestTimes = isTransit ? allTimes[0] : null;
return new FilteredResult(filtered, medianTimes, bestTimes); return new FilteredResult(filtered, medianTimes, bestTimes);
} }
@ -205,13 +208,24 @@ public class Router {
OneOriginResult result = computer.computeTravelTimes(); OneOriginResult result = computer.computeTravelTimes();
TravelTimeResult tt = result.travelTimes; TravelTimeResult tt = result.travelTimes;
if (tt != null) { if (tt == null) {
int[][] values = tt.getValues(); throw new RuntimeException("R5 returned null travelTimes for chunk with "
for (int p = 0; p < nPercentiles && p < values.length; p++) { + chunk.originalIndices.length + " destinations");
for (int i = 0; i < chunk.originalIndices.length && i < values[p].length; i++) { }
if (values[p][i] != Integer.MAX_VALUE) { int[][] values = tt.getValues();
allTimes[p][chunk.originalIndices[i]] = (short) values[p][i]; if (values.length < nPercentiles) {
} throw new RuntimeException("R5 returned " + values.length + " percentiles, expected "
+ nPercentiles);
}
for (int p = 0; p < nPercentiles; p++) {
if (values[p].length < chunk.originalIndices.length) {
throw new RuntimeException("R5 returned " + values[p].length
+ " travel times for percentile " + p + ", expected "
+ chunk.originalIndices.length);
}
for (int i = 0; i < chunk.originalIndices.length; i++) {
if (values[p][i] != Integer.MAX_VALUE) {
allTimes[p][chunk.originalIndices[i]] = (short) values[p][i];
} }
} }
} }

16
server-rs/Cargo.lock generated
View file

@ -2743,6 +2743,7 @@ dependencies = [
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util",
"h2", "h2",
"http", "http",
"http-body", "http-body",
@ -2767,12 +2768,14 @@ dependencies = [
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
] ]
@ -3803,6 +3806,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.85" version = "0.3.85"

View file

@ -22,7 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tracing-appender = "0.2" tracing-appender = "0.2"
metrics = "0.24" metrics = "0.24"
metrics-exporter-prometheus = "0.16" metrics-exporter-prometheus = "0.16"
reqwest = { version = "0.12", features = ["rustls-tls", "json"] } reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
urlencoding = "2" urlencoding = "2"
rust_xlsxwriter = "0.79" rust_xlsxwriter = "0.79"
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] } pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }

View file

@ -22,18 +22,8 @@ pub struct PlaceData {
fn type_rank(place_type: &str) -> u8 { fn type_rank(place_type: &str) -> u8 {
match place_type { match place_type {
"city" => 0, "city" => 0,
"borough" => 1, "station" => 1,
"town" => 2, _ => 2,
"suburb" => 3,
"quarter" => 4,
"neighbourhood" => 5,
"village" => 6,
"station" => 7,
"island" => 8,
"hamlet" => 9,
"locality" => 10,
"isolated_dwelling" => 11,
_ => 12,
} }
} }
@ -159,10 +149,7 @@ mod tests {
#[test] #[test]
fn type_rank_ordering() { fn type_rank_ordering() {
assert!(type_rank("city") < type_rank("town")); assert!(type_rank("city") < type_rank("station"));
assert!(type_rank("town") < type_rank("suburb")); assert!(type_rank("station") < type_rank("unknown"));
assert!(type_rank("suburb") < type_rank("village"));
assert!(type_rank("village") < type_rank("hamlet"));
assert!(type_rank("hamlet") < type_rank("isolated_dwelling"));
} }
} }

View file

@ -8,8 +8,15 @@ use polars::lazy::frame::LazyFrame;
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
use tracing::info; use tracing::info;
/// Cached postcode → travel_minutes mapping for a single destination file. /// Per-postcode travel time data: median and optional best-case (transit only).
pub type TravelData = Arc<FxHashMap<String, i16>>; #[derive(Clone, Copy)]
pub struct TravelDataRow {
pub minutes: i16,
pub best_minutes: Option<i16>,
}
/// Cached postcode → travel time data for a single destination file.
pub type TravelData = Arc<FxHashMap<String, TravelDataRow>>;
/// Simple LRU cache for travel time data, limited to `capacity` entries. /// Simple LRU cache for travel time data, limited to `capacity` entries.
struct LruCache { struct LruCache {
@ -159,12 +166,23 @@ impl TravelTimeStore {
.context("Missing 'travel_minutes' column")? .context("Missing 'travel_minutes' column")?
.i16() .i16()
.context("'travel_minutes' is not i16")?; .context("'travel_minutes' is not i16")?;
let best = df
.column("best_minutes")
.ok()
.map(|col| col.i16().expect("'best_minutes' is not i16"));
let mut map = FxHashMap::default(); let mut map = FxHashMap::default();
map.reserve(df.height()); map.reserve(df.height());
for (pc, min) in postcodes.into_iter().zip(minutes.into_iter()) { for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
if let (Some(pc), Some(min)) = (pc, min) { if let (Some(pc), Some(min)) = (pc, min) {
map.insert(pc.to_string(), min); let best_min = best.as_ref().and_then(|b| b.get(i));
map.insert(
pc.to_string(),
TravelDataRow {
minutes: min,
best_minutes: best_min,
},
);
} }
} }

View file

@ -424,6 +424,7 @@ async fn main() -> anyhow::Result<()> {
let state_invites_create = state.clone(); let state_invites_create = state.clone();
let state_invite_get = state.clone(); let state_invite_get = state.clone();
let state_redeem_invite = state.clone(); let state_redeem_invite = state.clone();
let state_rightmove = state.clone();
let api = Router::new() let api = Router::new()
.route( .route(
@ -495,6 +496,10 @@ async fn main() -> anyhow::Result<()> {
"/api/streetview", "/api/streetview",
get(move |query| routes::get_streetview(state_streetview.clone(), query)), get(move |query| routes::get_streetview(state_streetview.clone(), query)),
) )
.route(
"/api/rightmove-location",
get(move |query| routes::get_rightmove_typeahead(state_rightmove.clone(), query)),
)
.route( .route(
"/api/subscription", "/api/subscription",
patch(move |ext, body| { patch(move |ext, body| {
@ -569,7 +574,7 @@ async fn main() -> anyhow::Result<()> {
let app = if let Some(ref dist) = cli.dist { let app = if let Some(ref dist) = cli.dist {
api.fallback_service( api.fallback_service(
ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))), ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))),
) )
} else { } else {
api api

View file

@ -405,35 +405,49 @@ pub async fn ensure_oauth_providers(
let base_url = base_url.trim_end_matches('/'); let base_url = base_url.trim_end_matches('/');
let token = auth_superuser(client, base_url, admin_email, admin_password).await?; let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
// GET current settings // Set meta.appURL in global settings for OAuth redirects
let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
let settings_url = format!("{base_url}/api/settings"); let settings_url = format!("{base_url}/api/settings");
let patch_resp = client
.patch(&settings_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "meta": { "appURL": app_url } }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to update PocketBase meta.appURL ({status}): {text}");
}
info!("PocketBase meta.appURL set to {app_url}");
// PocketBase 0.23+: OAuth providers are configured per-collection, not in global settings.
// GET the users collection to update its oauth2 config.
let collection_url = format!("{base_url}/api/collections/users");
let resp = client let resp = client
.get(&settings_url) .get(&collection_url)
.header("Authorization", format!("Bearer {token}")) .header("Authorization", format!("Bearer {token}"))
.send() .send()
.await?; .await?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch PocketBase settings ({status}): {text}"); anyhow::bail!("Failed to fetch users collection ({status}): {text}");
} }
let mut settings: serde_json::Value = resp.json().await?; let mut collection: serde_json::Value = resp.json().await?;
// Set meta.appUrl for OAuth redirect let oauth2 = collection
let app_url = format!("{}/pb", public_url.trim_end_matches('/')); .get_mut("oauth2")
if let Some(meta) = settings.get_mut("meta") { .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2 field"))?;
meta["appUrl"] = serde_json::json!(app_url);
} else {
settings["meta"] = serde_json::json!({ "appUrl": app_url });
}
// Update OAuth2 providers // Ensure enabled
let providers = settings oauth2["enabled"] = serde_json::json!(true);
.pointer_mut("/oauth2/providers")
let providers = oauth2
.get_mut("providers")
.and_then(|v| v.as_array_mut()) .and_then(|v| v.as_array_mut())
.ok_or_else(|| anyhow::anyhow!("PocketBase settings missing oauth2.providers array — cannot configure OAuth"))?; .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2.providers array"))?;
let google = match providers let google = match providers
.iter() .iter()
@ -441,7 +455,7 @@ pub async fn ensure_oauth_providers(
{ {
Some(idx) => &mut providers[idx], Some(idx) => &mut providers[idx],
None => { None => {
info!("Google provider not found in PocketBase settings — adding it"); info!("Google provider not found — adding it");
providers.push(serde_json::json!({"name": "google"})); providers.push(serde_json::json!({"name": "google"}));
providers.last_mut().expect("just pushed") providers.last_mut().expect("just pushed")
} }
@ -449,23 +463,20 @@ pub async fn ensure_oauth_providers(
google["clientId"] = serde_json::json!(google_client_id); google["clientId"] = serde_json::json!(google_client_id);
google["clientSecret"] = serde_json::json!(google_client_secret); google["clientSecret"] = serde_json::json!(google_client_secret);
google["enabled"] = serde_json::json!(true);
info!("Configured Google OAuth provider");
// PATCH settings back // PATCH the collection
let patch_resp = client let patch_resp = client
.patch(&settings_url) .patch(&collection_url)
.header("Authorization", format!("Bearer {token}")) .header("Authorization", format!("Bearer {token}"))
.json(&settings) .json(&serde_json::json!({ "oauth2": oauth2 }))
.send() .send()
.await?; .await?;
if !patch_resp.status().is_success() { if !patch_resp.status().is_success() {
let status = patch_resp.status(); let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default(); let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to update PocketBase settings ({status}): {text}"); anyhow::bail!("Failed to update users collection OAuth ({status}): {text}");
} }
info!("PocketBase OAuth settings updated (appUrl: {app_url})"); info!("PocketBase OAuth configured on users collection");
Ok(()) Ok(())
} }

View file

@ -19,6 +19,7 @@ mod streetview;
mod stripe_webhook; mod stripe_webhook;
mod newsletter; mod newsletter;
pub(crate) mod pricing; pub(crate) mod pricing;
mod rightmove_typeahead;
mod subscription; mod subscription;
mod tiles; mod tiles;
pub(crate) mod travel_time; pub(crate) mod travel_time;
@ -46,4 +47,5 @@ pub use pricing::get_pricing;
pub use stripe_webhook::post_stripe_webhook; pub use stripe_webhook::post_stripe_webhook;
pub use subscription::patch_subscription; pub use subscription::patch_subscription;
pub use tiles::{get_style, get_tile, init_tile_reader}; pub use tiles::{get_style, get_tile, init_tile_reader};
pub use rightmove_typeahead::get_rightmove_typeahead;
pub use travel_modes::get_travel_modes; pub use travel_modes::get_travel_modes;

View file

@ -59,6 +59,8 @@ pub struct HexagonStatsResponse {
pub enum_features: Vec<EnumFeatureStats>, pub enum_features: Vec<EnumFeatureStats>,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub price_history: Vec<PricePoint>, pub price_history: Vec<PricePoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub central_postcode: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -136,6 +138,31 @@ pub async fn get_hexagon_stats(
let total_count = matching_rows.len(); let total_count = matching_rows.len();
// Find the postcode of the property closest to the hexagon center
let central_postcode = if !matching_rows.is_empty() {
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
let closest_row = matching_rows
.iter()
.copied()
.min_by(|&a, &b| {
let da_lat = state.data.lat[a] - center_lat;
let da_lon = state.data.lon[a] - center_lon;
let db_lat = state.data.lat[b] - center_lat;
let db_lon = state.data.lon[b] - center_lon;
let dist_a = da_lat * da_lat + da_lon * da_lon;
let dist_b = db_lat * db_lat + db_lon * db_lon;
dist_a
.partial_cmp(&dist_b)
.unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty");
Some(state.data.postcode(closest_row).to_string())
} else {
None
};
let price_history = stats::extract_price_history( let price_history = stats::extract_price_history(
&matching_rows, &matching_rows,
feature_data, feature_data,
@ -170,6 +197,7 @@ pub async fn get_hexagon_stats(
numeric_features, numeric_features,
enum_features: enum_features_out, enum_features: enum_features_out,
price_history, price_history,
central_postcode,
}) })
}) })
.await .await

View file

@ -43,12 +43,13 @@ pub struct HexagonParams {
struct TravelEntry { struct TravelEntry {
mode: String, mode: String,
slug: String, slug: String,
use_best: bool,
filter_min: Option<f32>, filter_min: Option<f32>,
filter_max: Option<f32>, filter_max: Option<f32>,
} }
/// Parse `travel` param into a list of travel entries. /// Parse `travel` param into a list of travel entries.
/// Format: `mode:slug` or `mode:slug:min:max` /// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> { fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut seen_keys = Vec::new(); let mut seen_keys = Vec::new();
@ -63,12 +64,15 @@ fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
let mode = parts[0].trim().to_string(); let mode = parts[0].trim().to_string();
let slug = parts[1].trim().to_string(); let slug = parts[1].trim().to_string();
let (filter_min, filter_max) = if parts.len() >= 4 { let use_best = parts.len() >= 3 && parts[2].trim() == "best";
let min: f32 = parts[2] let filter_offset = if use_best { 1 } else { 0 };
let (filter_min, filter_max) = if parts.len() >= 4 + filter_offset {
let min: f32 = parts[2 + filter_offset]
.trim() .trim()
.parse() .parse()
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?; .map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
let max: f32 = parts[3] let max: f32 = parts[3 + filter_offset]
.trim() .trim()
.parse() .parse()
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?; .map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
@ -85,6 +89,7 @@ fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
entries.push(TravelEntry { entries.push(TravelEntry {
mode, mode,
slug, slug,
use_best,
filter_min, filter_min,
filter_max, filter_max,
}); });
@ -286,7 +291,14 @@ pub async fn get_hexagons(
let postcode = pc_interner.resolve(&pc_keys[row]); let postcode = pc_interner.resolve(&pc_keys[row]);
travel_minutes.reserve(travel_entries.len()); travel_minutes.reserve(travel_entries.len());
for (ti, entry) in travel_entries.iter().enumerate() { for (ti, entry) in travel_entries.iter().enumerate() {
let minutes = travel_data[ti].get(postcode).copied(); let row_data = travel_data[ti].get(postcode);
let minutes = row_data.map(|r| {
if entry.use_best {
r.best_minutes.unwrap_or(r.minutes)
} else {
r.minutes
}
});
travel_minutes.push(minutes); travel_minutes.push(minutes);
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) { if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
match minutes { match minutes {

View file

@ -11,10 +11,11 @@ use crate::state::AppState;
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx /// Dedicated HTTP client for proxying — does not follow redirects so 3xx
/// responses are passed through to the browser (needed for OAuth flows). /// responses are passed through to the browser (needed for OAuth flows).
/// No overall timeout because SSE (Server-Sent Events) connections used by
/// PocketBase realtime/OAuth2 are long-lived streams.
static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| { static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder() reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5)) .connect_timeout(Duration::from_secs(5))
.build() .build()
.expect("Failed to build proxy HTTP client") .expect("Failed to build proxy HTTP client")
@ -97,16 +98,12 @@ pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl Int
} }
} }
match upstream.bytes().await { // Stream the response body instead of buffering it entirely.
Ok(bytes) => response.body(Body::from(bytes)).unwrap(), // This is critical for SSE (Server-Sent Events) used by PocketBase's
Err(err) => { // realtime system and OAuth2 flow — buffering would hang forever
warn!("Failed to read upstream response: {err}"); // since SSE responses never complete.
Response::builder() let body = Body::from_stream(upstream.bytes_stream());
.status(StatusCode::BAD_GATEWAY) response.body(body).unwrap()
.body(Body::from("Failed to read upstream response"))
.unwrap()
}
}
} }
Err(err) => { Err(err) => {
warn!("PocketBase proxy error: {err}"); warn!("PocketBase proxy error: {err}");

View file

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

View file

@ -0,0 +1,83 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::state::AppState;
const TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
#[derive(Deserialize)]
pub struct TypeaheadParams {
pub postcode: String,
}
#[derive(Serialize)]
pub struct TypeaheadResponse {
pub location_identifier: String,
}
#[derive(Deserialize)]
struct RightmoveMatch {
#[serde(rename = "type")]
match_type: String,
#[serde(rename = "displayName")]
display_name: String,
id: serde_json::Value,
}
#[derive(Deserialize)]
struct RightmoveTypeaheadResponse {
matches: Vec<RightmoveMatch>,
}
pub async fn get_rightmove_typeahead(
state: Arc<AppState>,
Query(params): Query<TypeaheadParams>,
) -> Result<Json<TypeaheadResponse>, axum::response::Response> {
let postcode = params.postcode.trim().to_uppercase();
let resp = state
.http_client
.get(TYPEAHEAD_URL)
.query(&[("query", &postcode), ("limit", &"10".to_string())])
.send()
.await
.map_err(|err| {
warn!(error = %err, "Rightmove typeahead request failed");
(StatusCode::BAD_GATEWAY, "Rightmove typeahead unavailable").into_response()
})?;
let data: RightmoveTypeaheadResponse = resp.json().await.map_err(|err| {
warn!(error = %err, "Failed to parse Rightmove typeahead response");
(StatusCode::BAD_GATEWAY, "Invalid typeahead response").into_response()
})?;
// Look for POSTCODE match first, then OUTCODE
for match_type in &["POSTCODE", "OUTCODE"] {
for m in &data.matches {
if m.match_type == *match_type
&& m.display_name.to_uppercase().replace(' ', "")
== postcode.replace(' ', "")
{
let id = match &m.id {
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
return Ok(Json(TypeaheadResponse {
location_identifier: format!("{}^{}", match_type, id),
}));
}
}
}
Err((
StatusCode::NOT_FOUND,
format!("No Rightmove location found for: {}", postcode),
)
.into_response())
}