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/
---
- stripe

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

16
server-rs/Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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