Lots of improvements
This commit is contained in:
parent
205302dbb8
commit
eb02b5832b
39 changed files with 699 additions and 271 deletions
|
|
@ -25,9 +25,6 @@ rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip
|
|||
|
||||
https://xploria.co.uk/data-sources/
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
- stripe
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
|||
}
|
||||
|
||||
export default function LearnPage() {
|
||||
const [tab, setTab] = useState<LearnTab>('data-sources');
|
||||
const [tab, setTab] = useState<LearnTab>('faq');
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -299,12 +299,12 @@ export default function LearnPage() {
|
|||
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
|
||||
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
|
||||
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
|
||||
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('support')} onClick={() => setTab('support')}>
|
||||
Support
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
|
|||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<form onSubmit={handleSubmit} className="flex gap-1.5">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Describe your ideal property..."
|
||||
className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
placeholder="Describe your ideal property and area..."
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5"
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{loading ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'AI'
|
||||
'Set filters with AI'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ export default function AreaPane({
|
|||
{propertyCount.toLocaleString()} properties
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
Stats for {isPostcode ? 'current and historical' : 'all'} properties
|
||||
in this {isPostcode ? 'postcode' : 'area'}
|
||||
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
|
||||
</p>
|
||||
{!isPostcode && stats && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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 && (
|
||||
|
|
|
|||
|
|
@ -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 — the areas that survive are your best matches.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Be intentional, not reactive
|
||||
1. Budget & property basics
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Your future home isn't a box of cereal you grab because it's on sale.
|
||||
Don't let a seemingly good deal turn into lifelong regret. Instead of waiting
|
||||
for listings to appear, define what you actually want and go find it.
|
||||
Set your price range, minimum floor area, and property type.
|
||||
If you need a lease over freehold (or vice versa), filter for that too.
|
||||
This eliminates most of the map immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
See the full picture
|
||||
2. Commute & transport
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Current listings show only a fraction of the market. There are too few to give you a
|
||||
complete picture, yet too many to evaluate one by one. We aggregate millions of
|
||||
historical sales so you can understand what's truly available in any area.
|
||||
Add a travel time filter to your workplace — choose public transport or cycling
|
||||
and set your maximum tolerable commute. You can also filter by
|
||||
how many stations are within walking distance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Your priorities, your filters
|
||||
3. Safety & environment
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We all care about different things. Some want peace and quiet; others want to be
|
||||
near the action. Use our filters to define exactly what matters to you and discover
|
||||
postcodes that match.
|
||||
Use the crime filters to cap serious or minor crime rates.
|
||||
Check road noise levels if you're a light sleeper, and
|
||||
environmental risk filters for ground stability concerns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Find the right place, not just the right listing
|
||||
4. Schools & education
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
The best areas to live don't always have properties listed right now. We help
|
||||
you identify where you should be looking, so when something does come up,
|
||||
you're ready.
|
||||
Filter by the number of Ofsted-rated Good or Outstanding primary and
|
||||
secondary schools nearby. The education deprivation score captures
|
||||
broader area-level attainment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Know what's possible
|
||||
5. Lifestyle & amenities
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn't exist.
|
||||
Want restaurants, parks, or grocery shops within walking distance?
|
||||
Filter by nearby amenity counts. Broadband speed filters help if
|
||||
you work from home.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
6. Energy & running costs
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
EPC ratings from A to G indicate energy efficiency.
|
||||
Filter for better ratings to find homes with lower bills and
|
||||
fewer upgrade headaches.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-1 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-warm-500 dark:text-warm-400 italic">
|
||||
Tip: if nothing survives your filters, relax one constraint at a time
|
||||
to see which compromise unlocks the most options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{onResetTutorial && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPhilosophy(false);
|
||||
onResetTutorial();
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm"
|
||||
>
|
||||
Replay interactive tutorial
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
|
||||
interface HoverCardData {
|
||||
|
|
@ -14,11 +14,17 @@ interface HoverCardProps {
|
|||
isPostcode: boolean;
|
||||
data: HoverCardData | null;
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) {
|
||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) {
|
||||
const activeFilterNames = Object.keys(filters);
|
||||
|
||||
const featureMap = useMemo(
|
||||
() => new Map(features.map((f) => [f.name, f])),
|
||||
[features]
|
||||
);
|
||||
|
||||
// Get key stats to show from local data (min_<feature> values)
|
||||
const getDisplayStats = () => {
|
||||
if (!data) return [];
|
||||
|
|
@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
|
|||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
||||
if (val != null && typeof val === 'number') {
|
||||
results.push({ name, value: formatValue(val) });
|
||||
if (val == null || typeof val !== 'number') continue;
|
||||
const meta = featureMap.get(name);
|
||||
if (meta?.type === 'enum' && meta.values) {
|
||||
const label = meta.values[Math.round(val)];
|
||||
if (label) results.push({ name, value: label });
|
||||
} else {
|
||||
results.push({ name, value: formatValue(val, meta) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@ export default memo(function Map({
|
|||
: data.find((d) => d.h3 === hoveredHexagonId) || null
|
||||
}
|
||||
filters={filters}
|
||||
features={features}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!] }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export interface TravelTimeEntry {
|
|||
slug: string;
|
||||
label: string;
|
||||
timeRange: [number, number] | null;
|
||||
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
|
||||
useBest: boolean;
|
||||
}
|
||||
|
||||
/** Field key matching the backend response: tt_{mode}_{slug} */
|
||||
|
|
@ -33,7 +35,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
const handleAddEntry = useCallback((mode: TransportMode) => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ mode, slug: '', label: '', timeRange: null },
|
||||
{ mode, slug: '', label: '', timeRange: null, useBest: false },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
|
|
@ -63,6 +65,17 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
[]
|
||||
);
|
||||
|
||||
const handleToggleBest = useCallback(
|
||||
(index: number) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, useBest: !entry.useBest, timeRange: null } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Entries that have a destination selected (slug is set) */
|
||||
const activeEntries = useMemo(
|
||||
() => entries.filter((e) => e.slug !== ''),
|
||||
|
|
@ -76,5 +89,6 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
handleRemoveEntry,
|
||||
handleSetDestination,
|
||||
handleTimeRangeChange,
|
||||
handleToggleBest,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const STEPS: Step[] = [
|
|||
target: '[data-tutorial="filters"]',
|
||||
title: 'Filter Properties',
|
||||
content:
|
||||
'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.',
|
||||
'Use filters to narrow down to areas which contain matching properties. Filter by crime rate, number of schools around, or filter to an area with detached houses. Pin a filter with the eye icon to colour the map by that feature.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
|
|
@ -17,7 +17,7 @@ const STEPS: Step[] = [
|
|||
target: '[data-tutorial="map"]',
|
||||
title: 'Explore the Map',
|
||||
content:
|
||||
'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.',
|
||||
'Pan and zoom to explore property data across England. Click any area (hexagon or postcode boundary) to see detailed stats of historical or currently sold properties matching your filters.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
|
|
@ -44,6 +44,11 @@ const STEPS: Step[] = [
|
|||
'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
|
||||
placement: 'left',
|
||||
disableBeacon: true,
|
||||
styles: {
|
||||
tooltip: {
|
||||
transform: 'translateY(-50px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ uniform marchingAntsUniforms {
|
|||
} marchingAnts;`,
|
||||
'fs:DECKGL_FILTER_COLOR': `\
|
||||
float marchSegLen = 4.0;
|
||||
float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0);
|
||||
float marchPos = mod(geometry.uv.y - marchingAnts.marchTime, marchSegLen * 2.0);
|
||||
if (marchPos < marchSegLen) {
|
||||
color = vec4(1.0, 1.0, 1.0, color.a);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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(';;');
|
||||
|
|
|
|||
16
frontend/src/lib/clipboard.ts
Normal file
16
frontend/src/lib/clipboard.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Copy text to clipboard with execCommand fallback for older browsers. */
|
||||
export function copyToClipboard(text: string, onSuccess: () => void): void {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.42, west: -0.34, north: 51.60, east:
|
|||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
|
||||
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
|
||||
zoom: 14,
|
||||
zoom: 15,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
|
|
@ -33,10 +33,9 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
|||
{ maxZoom: 10.5, resolution: 7 },
|
||||
{ maxZoom: 11.5, resolution: 8 },
|
||||
{ maxZoom: 13, resolution: 9 },
|
||||
{ maxZoom: Infinity, resolution: 10 },
|
||||
] as const;
|
||||
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 16;
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 14.5;
|
||||
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ export interface HexagonLocation {
|
|||
lat: number;
|
||||
lon: number;
|
||||
resolution: number;
|
||||
postcode?: string;
|
||||
isPostcode?: boolean;
|
||||
}
|
||||
|
||||
const PROPERTY_TYPE_MAP: Record<
|
||||
|
|
@ -32,10 +34,10 @@ export const H3_RADIUS_MILES: Record<number, number> = {
|
|||
6: 3,
|
||||
7: 1,
|
||||
8: 0.5,
|
||||
9: 0.25,
|
||||
10: 0.25,
|
||||
11: 0.25,
|
||||
12: 0.25,
|
||||
9: 1,
|
||||
10: 1,
|
||||
11: 1,
|
||||
12: 1,
|
||||
};
|
||||
|
||||
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
|
|
@ -46,13 +48,21 @@ function nearestRadius(target: number, allowed: number[]): number {
|
|||
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
|
||||
}
|
||||
|
||||
export function buildPropertySearchUrls(
|
||||
location: HexagonLocation,
|
||||
filters: FeatureFilters
|
||||
): { rightmove: string; onthemarket: string; zoopla: string } {
|
||||
const { lat, lon, resolution } = location;
|
||||
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
|
||||
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
||||
interface SearchUrlOptions {
|
||||
location: HexagonLocation;
|
||||
filters: FeatureFilters;
|
||||
rightmoveLocationId?: string;
|
||||
}
|
||||
|
||||
export function buildPropertySearchUrls({
|
||||
location,
|
||||
filters,
|
||||
rightmoveLocationId,
|
||||
}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
|
||||
const { postcode, resolution, isPostcode } = location;
|
||||
if (!postcode) return null;
|
||||
|
||||
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||
|
||||
const priceFilter = filters['Last known price'];
|
||||
const minPrice =
|
||||
|
|
@ -66,43 +76,51 @@ export function buildPropertySearchUrls(
|
|||
? (propertyTypes as string[])
|
||||
: [];
|
||||
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', coordStr);
|
||||
rmParams.set('channel', 'BUY');
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
|
||||
if (selectedTypes.length > 0) {
|
||||
const rmTypes = [
|
||||
...new Set(
|
||||
selectedTypes.flatMap((t) => {
|
||||
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
|
||||
return mapped ? mapped.split(',') : [];
|
||||
})
|
||||
),
|
||||
];
|
||||
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
||||
// Rightmove — requires locationIdentifier from typeahead API
|
||||
let rightmove: string | null = null;
|
||||
if (rightmoveLocationId) {
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', postcode);
|
||||
rmParams.set('useLocationIdentifier', 'true');
|
||||
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
|
||||
if (selectedTypes.length > 0) {
|
||||
const rmTypes = [
|
||||
...new Set(
|
||||
selectedTypes.flatMap((t) => {
|
||||
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
|
||||
return mapped ? mapped.split(',') : [];
|
||||
})
|
||||
),
|
||||
];
|
||||
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
||||
}
|
||||
rmParams.set('_includeSSTC', 'on');
|
||||
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||
}
|
||||
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||
|
||||
let otmType = 'property';
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||
];
|
||||
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
|
||||
}
|
||||
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
|
||||
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
|
||||
const otmParams = new URLSearchParams();
|
||||
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
||||
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
|
||||
otmParams.set('search-site', 'geo');
|
||||
otmParams.set('geo-lat', String(lat));
|
||||
otmParams.set('geo-lng', String(lon));
|
||||
const onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||
];
|
||||
for (const ot of otmTypes) {
|
||||
otmParams.append('prop-types', ot!);
|
||||
}
|
||||
}
|
||||
otmParams.set('view', 'map-list');
|
||||
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
|
||||
|
||||
// Zoopla
|
||||
const zParams = new URLSearchParams();
|
||||
zParams.set('q', coordStr);
|
||||
zParams.set('q', postcode);
|
||||
zParams.set('search_source', 'for-sale');
|
||||
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
||||
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
|
||||
|
|
@ -115,7 +133,6 @@ export function buildPropertySearchUrls(
|
|||
zParams.append('property_sub_type', zt!);
|
||||
}
|
||||
}
|
||||
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
|
||||
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
||||
|
||||
return { rightmove, onthemarket, zoopla };
|
||||
|
|
|
|||
|
|
@ -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]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,4 +172,5 @@ export interface HexagonStatsResponse {
|
|||
numeric_features: NumericFeatureStats[];
|
||||
enum_features: EnumFeatureStats[];
|
||||
price_history?: PricePoint[];
|
||||
central_postcode?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
16
server-rs/Cargo.lock
generated
|
|
@ -2743,6 +2743,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
|
|
@ -2767,12 +2768,14 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
|
@ -3803,6 +3806,19 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
|||
tracing-appender = "0.2"
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||
urlencoding = "2"
|
||||
rust_xlsxwriter = "0.79"
|
||||
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -405,35 +405,49 @@ pub async fn ensure_oauth_providers(
|
|||
let base_url = base_url.trim_end_matches('/');
|
||||
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
|
||||
|
||||
// GET current settings
|
||||
// Set meta.appURL in global settings for OAuth redirects
|
||||
let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
|
||||
let settings_url = format!("{base_url}/api/settings");
|
||||
let patch_resp = client
|
||||
.patch(&settings_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "meta": { "appURL": app_url } }))
|
||||
.send()
|
||||
.await?;
|
||||
if !patch_resp.status().is_success() {
|
||||
let status = patch_resp.status();
|
||||
let text = patch_resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to update PocketBase meta.appURL ({status}): {text}");
|
||||
}
|
||||
info!("PocketBase meta.appURL set to {app_url}");
|
||||
|
||||
// PocketBase 0.23+: OAuth providers are configured per-collection, not in global settings.
|
||||
// GET the users collection to update its oauth2 config.
|
||||
let collection_url = format!("{base_url}/api/collections/users");
|
||||
let resp = client
|
||||
.get(&settings_url)
|
||||
.get(&collection_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to fetch PocketBase settings ({status}): {text}");
|
||||
anyhow::bail!("Failed to fetch users collection ({status}): {text}");
|
||||
}
|
||||
|
||||
let mut settings: serde_json::Value = resp.json().await?;
|
||||
let mut collection: serde_json::Value = resp.json().await?;
|
||||
|
||||
// Set meta.appUrl for OAuth redirect
|
||||
let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
|
||||
if let Some(meta) = settings.get_mut("meta") {
|
||||
meta["appUrl"] = serde_json::json!(app_url);
|
||||
} else {
|
||||
settings["meta"] = serde_json::json!({ "appUrl": app_url });
|
||||
}
|
||||
let oauth2 = collection
|
||||
.get_mut("oauth2")
|
||||
.ok_or_else(|| anyhow::anyhow!("users collection missing oauth2 field"))?;
|
||||
|
||||
// Update OAuth2 providers
|
||||
let providers = settings
|
||||
.pointer_mut("/oauth2/providers")
|
||||
// Ensure enabled
|
||||
oauth2["enabled"] = serde_json::json!(true);
|
||||
|
||||
let providers = oauth2
|
||||
.get_mut("providers")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
.ok_or_else(|| anyhow::anyhow!("PocketBase settings missing oauth2.providers array — cannot configure OAuth"))?;
|
||||
.ok_or_else(|| anyhow::anyhow!("users collection missing oauth2.providers array"))?;
|
||||
|
||||
let google = match providers
|
||||
.iter()
|
||||
|
|
@ -441,7 +455,7 @@ pub async fn ensure_oauth_providers(
|
|||
{
|
||||
Some(idx) => &mut providers[idx],
|
||||
None => {
|
||||
info!("Google provider not found in PocketBase settings — adding it");
|
||||
info!("Google provider not found — adding it");
|
||||
providers.push(serde_json::json!({"name": "google"}));
|
||||
providers.last_mut().expect("just pushed")
|
||||
}
|
||||
|
|
@ -449,23 +463,20 @@ pub async fn ensure_oauth_providers(
|
|||
|
||||
google["clientId"] = serde_json::json!(google_client_id);
|
||||
google["clientSecret"] = serde_json::json!(google_client_secret);
|
||||
google["enabled"] = serde_json::json!(true);
|
||||
info!("Configured Google OAuth provider");
|
||||
|
||||
// PATCH settings back
|
||||
// PATCH the collection
|
||||
let patch_resp = client
|
||||
.patch(&settings_url)
|
||||
.patch(&collection_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&settings)
|
||||
.json(&serde_json::json!({ "oauth2": oauth2 }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !patch_resp.status().is_success() {
|
||||
let status = patch_resp.status();
|
||||
let text = patch_resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to update PocketBase settings ({status}): {text}");
|
||||
anyhow::bail!("Failed to update users collection OAuth ({status}): {text}");
|
||||
}
|
||||
|
||||
info!("PocketBase OAuth settings updated (appUrl: {app_url})");
|
||||
info!("PocketBase OAuth configured on users collection");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ mod streetview;
|
|||
mod stripe_webhook;
|
||||
mod newsletter;
|
||||
pub(crate) mod pricing;
|
||||
mod rightmove_typeahead;
|
||||
mod subscription;
|
||||
mod tiles;
|
||||
pub(crate) mod travel_time;
|
||||
|
|
@ -46,4 +47,5 @@ pub use pricing::get_pricing;
|
|||
pub use stripe_webhook::post_stripe_webhook;
|
||||
pub use subscription::patch_subscription;
|
||||
pub use tiles::{get_style, get_tile, init_tile_reader};
|
||||
pub use rightmove_typeahead::get_rightmove_typeahead;
|
||||
pub use travel_modes::get_travel_modes;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ use crate::state::AppState;
|
|||
|
||||
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
|
||||
/// responses are passed through to the browser (needed for OAuth flows).
|
||||
/// No overall timeout because SSE (Server-Sent Events) connections used by
|
||||
/// PocketBase realtime/OAuth2 are long-lived streams.
|
||||
static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("Failed to build proxy HTTP client")
|
||||
|
|
@ -97,16 +98,12 @@ pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl Int
|
|||
}
|
||||
}
|
||||
|
||||
match upstream.bytes().await {
|
||||
Ok(bytes) => response.body(Body::from(bytes)).unwrap(),
|
||||
Err(err) => {
|
||||
warn!("Failed to read upstream response: {err}");
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.body(Body::from("Failed to read upstream response"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
// Stream the response body instead of buffering it entirely.
|
||||
// This is critical for SSE (Server-Sent Events) used by PocketBase's
|
||||
// realtime system and OAuth2 flow — buffering would hang forever
|
||||
// since SSE responses never complete.
|
||||
let body = Body::from_stream(upstream.bytes_stream());
|
||||
response.body(body).unwrap()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase proxy error: {err}");
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ pub async fn get_postcode_stats(
|
|||
numeric_features,
|
||||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
central_postcode: None,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
83
server-rs/src/routes/rightmove_typeahead.rs
Normal file
83
server-rs/src/routes/rightmove_typeahead.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TypeaheadParams {
|
||||
pub postcode: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TypeaheadResponse {
|
||||
pub location_identifier: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveMatch {
|
||||
#[serde(rename = "type")]
|
||||
match_type: String,
|
||||
#[serde(rename = "displayName")]
|
||||
display_name: String,
|
||||
id: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveTypeaheadResponse {
|
||||
matches: Vec<RightmoveMatch>,
|
||||
}
|
||||
|
||||
pub async fn get_rightmove_typeahead(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<TypeaheadParams>,
|
||||
) -> Result<Json<TypeaheadResponse>, axum::response::Response> {
|
||||
let postcode = params.postcode.trim().to_uppercase();
|
||||
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(TYPEAHEAD_URL)
|
||||
.query(&[("query", &postcode), ("limit", &"10".to_string())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "Rightmove typeahead request failed");
|
||||
(StatusCode::BAD_GATEWAY, "Rightmove typeahead unavailable").into_response()
|
||||
})?;
|
||||
|
||||
let data: RightmoveTypeaheadResponse = resp.json().await.map_err(|err| {
|
||||
warn!(error = %err, "Failed to parse Rightmove typeahead response");
|
||||
(StatusCode::BAD_GATEWAY, "Invalid typeahead response").into_response()
|
||||
})?;
|
||||
|
||||
// Look for POSTCODE match first, then OUTCODE
|
||||
for match_type in &["POSTCODE", "OUTCODE"] {
|
||||
for m in &data.matches {
|
||||
if m.match_type == *match_type
|
||||
&& m.display_name.to_uppercase().replace(' ', "")
|
||||
== postcode.replace(' ', "")
|
||||
{
|
||||
let id = match &m.id {
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
return Ok(Json(TypeaheadResponse {
|
||||
location_identifier: format!("{}^{}", match_type, id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No Rightmove location found for: {}", postcode),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue