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/
|
https://xploria.co.uk/data-sources/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- stripe
|
- stripe
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,7 @@ export default function App() {
|
||||||
<MapPage
|
<MapPage
|
||||||
features={features}
|
features={features}
|
||||||
poiCategoryGroups={poiCategoryGroups}
|
poiCategoryGroups={poiCategoryGroups}
|
||||||
initialFilters={urlState.filters || {}}
|
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
initialPOICategories={urlState.poiCategories || new Set()}
|
initialPOICategories={urlState.poiCategories || new Set()}
|
||||||
initialTab={urlState.tab || 'area'}
|
initialTab={urlState.tab || 'area'}
|
||||||
|
|
@ -326,7 +326,7 @@ export default function App() {
|
||||||
<MapPage
|
<MapPage
|
||||||
features={features}
|
features={features}
|
||||||
poiCategoryGroups={poiCategoryGroups}
|
poiCategoryGroups={poiCategoryGroups}
|
||||||
initialFilters={urlState.filters || {}}
|
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
initialPOICategories={urlState.poiCategories || new Set()}
|
initialPOICategories={urlState.poiCategories || new Set()}
|
||||||
initialTab={urlState.tab || 'area'}
|
initialTab={urlState.tab || 'area'}
|
||||||
|
|
@ -371,7 +371,7 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showLicenseSuccess && (
|
{showLicenseSuccess && (
|
||||||
<LicenseSuccessModal onClose={() => setShowLicenseSuccess(false)} />
|
<LicenseSuccessModal onClose={() => { setShowLicenseSuccess(false); navigateTo('dashboard'); }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||||
import type { AuthUser } from '../../hooks/useAuth';
|
import type { AuthUser } from '../../hooks/useAuth';
|
||||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||||
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
|
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
|
||||||
|
import { copyToClipboard } from '../../lib/clipboard';
|
||||||
import { formatRelativeTime } from '../../lib/format';
|
import { formatRelativeTime } from '../../lib/format';
|
||||||
import { summarizeParams } from '../../lib/url-state';
|
import { summarizeParams } from '../../lib/url-state';
|
||||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||||
|
|
@ -42,37 +43,24 @@ function SavedSearchesContent({
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}, [deleteConfirmId, onDelete]);
|
}, [deleteConfirmId, onDelete]);
|
||||||
|
|
||||||
const copyToClipboard = useCallback((text: string, id: string) => {
|
const doCopy = useCallback((text: string, id: string) => {
|
||||||
const onSuccess = () => {
|
copyToClipboard(text, () => {
|
||||||
setCopiedId(id);
|
setCopiedId(id);
|
||||||
setTimeout(() => setCopiedId(null), 2000);
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
};
|
});
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
navigator.clipboard.writeText(text).then(onSuccess);
|
|
||||||
} else {
|
|
||||||
const ta = document.createElement('textarea');
|
|
||||||
ta.value = text;
|
|
||||||
ta.style.position = 'fixed';
|
|
||||||
ta.style.opacity = '0';
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleShare = useCallback(async (params: string, id: string) => {
|
const handleShare = useCallback(async (params: string, id: string) => {
|
||||||
setSharingId(id);
|
setSharingId(id);
|
||||||
try {
|
try {
|
||||||
const shortUrl = await shortenUrl(params);
|
const shortUrl = await shortenUrl(params);
|
||||||
copyToClipboard(shortUrl, id);
|
doCopy(shortUrl, id);
|
||||||
} catch {
|
} catch {
|
||||||
copyToClipboard(`${window.location.origin}/?${params}`, id);
|
doCopy(`${window.location.origin}/?${params}`, id);
|
||||||
} finally {
|
} finally {
|
||||||
setSharingId(null);
|
setSharingId(null);
|
||||||
}
|
}
|
||||||
}, [copyToClipboard]);
|
}, [doCopy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -270,7 +258,7 @@ function SettingsContent({
|
||||||
|
|
||||||
const handleCopyInvite = () => {
|
const handleCopyInvite = () => {
|
||||||
if (!inviteUrl) return;
|
if (!inviteUrl) return;
|
||||||
navigator.clipboard.writeText(inviteUrl).then(() => {
|
copyToClipboard(inviteUrl, () => {
|
||||||
setInviteCopied(true);
|
setInviteCopied(true);
|
||||||
setTimeout(() => setInviteCopied(false), 2000);
|
setTimeout(() => setInviteCopied(false), 2000);
|
||||||
});
|
});
|
||||||
|
|
@ -284,7 +272,7 @@ function SettingsContent({
|
||||||
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
|
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="max-w-lg mx-auto space-y-6">
|
||||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
|
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div className="px-5 py-4 flex items-center justify-between">
|
<div className="px-5 py-4 flex items-center justify-between">
|
||||||
|
|
@ -455,6 +443,20 @@ function SettingsContent({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Support */}
|
||||||
|
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||||
|
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
|
||||||
|
<a
|
||||||
|
href="mailto:support@propertymap.co.uk"
|
||||||
|
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||||
|
>
|
||||||
|
support@propertymap.co.uk
|
||||||
|
</a>
|
||||||
|
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||||
|
We typically respond within 24 hours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LearnPage() {
|
export default function LearnPage() {
|
||||||
const [tab, setTab] = useState<LearnTab>('data-sources');
|
const [tab, setTab] = useState<LearnTab>('faq');
|
||||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -299,12 +299,12 @@ export default function LearnPage() {
|
||||||
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
|
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
|
||||||
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
|
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
|
||||||
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
|
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
|
||||||
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
|
|
||||||
Data Sources
|
|
||||||
</button>
|
|
||||||
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
|
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
|
||||||
FAQ
|
FAQ
|
||||||
</button>
|
</button>
|
||||||
|
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
|
||||||
|
Data Sources
|
||||||
|
</button>
|
||||||
<button className={tabClass('support')} onClick={() => setTab('support')}>
|
<button className={tabClass('support')} onClick={() => setTab('support')}>
|
||||||
Support
|
Support
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<form onSubmit={handleSubmit} className="flex gap-1.5">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Describe your ideal property..."
|
placeholder="Describe your ideal property and area..."
|
||||||
className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !query.trim()}
|
disabled={loading || !query.trim()}
|
||||||
className="shrink-0 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5"
|
className="w-full px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center gap-1.5"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
'AI'
|
'Set filters with AI'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,11 @@ export default function AreaPane({
|
||||||
{propertyCount.toLocaleString()} properties
|
{propertyCount.toLocaleString()} properties
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||||
|
Stats for {isPostcode ? 'current and historical' : 'all'} properties
|
||||||
|
in this {isPostcode ? 'postcode' : 'area'}
|
||||||
|
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
|
||||||
|
</p>
|
||||||
{!isPostcode && stats && (
|
{!isPostcode && stats && (
|
||||||
<button
|
<button
|
||||||
onClick={onViewProperties}
|
onClick={onViewProperties}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,35 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
import type { FeatureFilters } from '../../types';
|
import type { FeatureFilters } from '../../types';
|
||||||
import {
|
import {
|
||||||
buildPropertySearchUrls,
|
buildPropertySearchUrls,
|
||||||
H3_RADIUS_MILES,
|
H3_RADIUS_MILES,
|
||||||
type HexagonLocation,
|
type HexagonLocation,
|
||||||
} from '../../lib/external-search';
|
} from '../../lib/external-search';
|
||||||
|
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||||
|
|
||||||
|
function useRightmoveLocationId(postcode: string | undefined): string | undefined {
|
||||||
|
const [locationId, setLocationId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!postcode) {
|
||||||
|
setLocationId(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocationId(undefined);
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.location_identifier) setLocationId(data.location_identifier);
|
||||||
|
})
|
||||||
|
.catch((err) => logNonAbortError('rightmove-location', err));
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [postcode]);
|
||||||
|
|
||||||
|
return locationId;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExternalSearchLinks({
|
export default function ExternalSearchLinks({
|
||||||
location,
|
location,
|
||||||
|
|
@ -13,29 +38,46 @@ export default function ExternalSearchLinks({
|
||||||
location: HexagonLocation;
|
location: HexagonLocation;
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
}) {
|
}) {
|
||||||
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
|
const rightmoveLocationId = useRightmoveLocationId(location.postcode);
|
||||||
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
|
const urls = useMemo(
|
||||||
|
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
|
||||||
|
[location, filters, rightmoveLocationId]
|
||||||
|
);
|
||||||
|
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||||
const label = `${radiusMiles}mi radius`;
|
const label = `${radiusMiles}mi radius`;
|
||||||
|
|
||||||
|
if (!urls) return null;
|
||||||
|
|
||||||
|
const linkClass =
|
||||||
|
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium';
|
||||||
|
const disabledClass =
|
||||||
|
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 font-medium cursor-default';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||||
Search {label} on
|
Search {label} on
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<a
|
{urls.rightmove ? (
|
||||||
href={urls.rightmove}
|
<a
|
||||||
target="_blank"
|
href={urls.rightmove}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
rel="noopener noreferrer"
|
||||||
>
|
className={linkClass}
|
||||||
Rightmove
|
>
|
||||||
</a>
|
Rightmove
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className={disabledClass} title="Loading...">
|
||||||
|
Rightmove
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href={urls.onthemarket}
|
href={urls.onthemarket}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
className={linkClass}
|
||||||
>
|
>
|
||||||
OnTheMarket
|
OnTheMarket
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -43,7 +85,7 @@ export default function ExternalSearchLinks({
|
||||||
href={urls.zoopla}
|
href={urls.zoopla}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
className={linkClass}
|
||||||
>
|
>
|
||||||
Zoopla
|
Zoopla
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ interface FeatureBrowserProps {
|
||||||
onClearOpenInfoFeature?: () => void;
|
onClearOpenInfoFeature?: () => void;
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
||||||
|
isLicensed: boolean;
|
||||||
|
onUpgradeClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeatureBrowser({
|
export default function FeatureBrowser({
|
||||||
|
|
@ -45,6 +47,8 @@ export default function FeatureBrowser({
|
||||||
onClearOpenInfoFeature,
|
onClearOpenInfoFeature,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
onAddTravelTimeEntry,
|
onAddTravelTimeEntry,
|
||||||
|
isLicensed,
|
||||||
|
onUpgradeClick,
|
||||||
}: FeatureBrowserProps) {
|
}: FeatureBrowserProps) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||||
|
|
@ -183,10 +187,31 @@ export default function FeatureBrowser({
|
||||||
}
|
}
|
||||||
className="px-3 py-4"
|
className="px-3 py-4"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : isLicensed ? (
|
||||||
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
||||||
Everyone cares about different things. Pick the filters that matter most to you.
|
Everyone cares about different things. Pick the filters that matter most to you.
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
|
||||||
|
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
||||||
|
The biggest financial decision of your life deserves proper tools behind it.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
||||||
|
Don't leave it to chance.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onUpgradeClick}
|
||||||
|
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
Upgrade to full map
|
||||||
|
</button>
|
||||||
|
<svg viewBox="0 120 1600 230" className="w-full mt-4 block shrink-0" preserveAspectRatio="xMidYMax meet">
|
||||||
|
<path d="M0,350 C400,150 1200,150 1600,350 Z" className="fill-green-500 dark:fill-green-600" />
|
||||||
|
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
|
||||||
|
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
|
||||||
|
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{infoFeature && (
|
{infoFeature && (
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,15 @@ function SliderLabels({
|
||||||
max,
|
max,
|
||||||
value,
|
value,
|
||||||
displayValues,
|
displayValues,
|
||||||
absoluteMax,
|
isAtMin,
|
||||||
|
isAtMax,
|
||||||
}: {
|
}: {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
value: [number, number];
|
value: [number, number];
|
||||||
displayValues?: [number, number];
|
displayValues?: [number, number];
|
||||||
absoluteMax?: boolean;
|
isAtMin?: boolean;
|
||||||
|
isAtMax?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const range = max - min || 1;
|
const range = max - min || 1;
|
||||||
const leftPct = ((value[0] - min) / range) * 100;
|
const leftPct = ((value[0] - min) / range) * 100;
|
||||||
|
|
@ -57,13 +59,13 @@ function SliderLabels({
|
||||||
className="absolute -translate-x-1/2"
|
className="absolute -translate-x-1/2"
|
||||||
style={{ left: `${leftPct}%` }}
|
style={{ left: `${leftPct}%` }}
|
||||||
>
|
>
|
||||||
{formatFilterValue(labels[0])}
|
{isAtMin ? 'min' : formatFilterValue(labels[0])}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="absolute -translate-x-1/2"
|
className="absolute -translate-x-1/2"
|
||||||
style={{ left: `${rightPct}%` }}
|
style={{ left: `${rightPct}%` }}
|
||||||
>
|
>
|
||||||
{formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''}
|
{isAtMax ? 'max' : formatFilterValue(labels[1])}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -92,10 +94,14 @@ interface FiltersProps {
|
||||||
onTravelTimeRemoveEntry: (index: number) => void;
|
onTravelTimeRemoveEntry: (index: number) => void;
|
||||||
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
|
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
|
||||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||||
|
onTravelTimeToggleBest: (index: number) => void;
|
||||||
aiFilterLoading: boolean;
|
aiFilterLoading: boolean;
|
||||||
aiFilterError: string | null;
|
aiFilterError: string | null;
|
||||||
aiFilterNotes: string | null;
|
aiFilterNotes: string | null;
|
||||||
onAiFilterSubmit: (query: string) => void;
|
onAiFilterSubmit: (query: string) => void;
|
||||||
|
isLicensed: boolean;
|
||||||
|
onUpgradeClick?: () => void;
|
||||||
|
onResetTutorial?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function Filters({
|
export default memo(function Filters({
|
||||||
|
|
@ -121,10 +127,14 @@ export default memo(function Filters({
|
||||||
onTravelTimeRemoveEntry,
|
onTravelTimeRemoveEntry,
|
||||||
onTravelTimeSetDestination,
|
onTravelTimeSetDestination,
|
||||||
onTravelTimeRangeChange,
|
onTravelTimeRangeChange,
|
||||||
|
onTravelTimeToggleBest,
|
||||||
aiFilterLoading,
|
aiFilterLoading,
|
||||||
aiFilterError,
|
aiFilterError,
|
||||||
aiFilterNotes,
|
aiFilterNotes,
|
||||||
onAiFilterSubmit,
|
onAiFilterSubmit,
|
||||||
|
isLicensed,
|
||||||
|
onUpgradeClick,
|
||||||
|
onResetTutorial,
|
||||||
}: FiltersProps) {
|
}: FiltersProps) {
|
||||||
const activeListingType = useMemo((): ListingType => {
|
const activeListingType = useMemo((): ListingType => {
|
||||||
const val = filters['Listing status'] as string[] | undefined;
|
const val = filters['Listing status'] as string[] | undefined;
|
||||||
|
|
@ -145,16 +155,11 @@ export default memo(function Filters({
|
||||||
|
|
||||||
const handleListingSelect = useCallback(
|
const handleListingSelect = useCallback(
|
||||||
(type: ListingType) => {
|
(type: ListingType) => {
|
||||||
if (type === activeListingType && !filters['Listing status']) return;
|
|
||||||
for (const name of Object.keys(filters)) {
|
for (const name of Object.keys(filters)) {
|
||||||
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
|
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
|
||||||
onRemoveFilter(name);
|
onRemoveFilter(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type === 'historical' && !filters['Listing status']) {
|
|
||||||
onFilterChange('Listing status', ['Historical sale']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const valueMap: Record<string, string> = {
|
const valueMap: Record<string, string> = {
|
||||||
historical: 'Historical sale',
|
historical: 'Historical sale',
|
||||||
buy: 'For sale',
|
buy: 'For sale',
|
||||||
|
|
@ -162,7 +167,7 @@ export default memo(function Filters({
|
||||||
};
|
};
|
||||||
onFilterChange('Listing status', [valueMap[type]]);
|
onFilterChange('Listing status', [valueMap[type]]);
|
||||||
},
|
},
|
||||||
[activeListingType, filters, onFilterChange, onRemoveFilter]
|
[filters, onFilterChange, onRemoveFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -205,7 +210,7 @@ export default memo(function Filters({
|
||||||
<div className="flex items-center gap-2 px-3 pb-2">
|
<div className="flex items-center gap-2 px-3 pb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPhilosophy(true)}
|
onClick={() => setShowPhilosophy(true)}
|
||||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
className="flex-1 px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<LightbulbIcon />
|
<LightbulbIcon />
|
||||||
Finding the Perfect Postcode
|
Finding the Perfect Postcode
|
||||||
|
|
@ -269,11 +274,13 @@ export default memo(function Filters({
|
||||||
slug={entry.slug}
|
slug={entry.slug}
|
||||||
label={entry.label}
|
label={entry.label}
|
||||||
timeRange={entry.timeRange}
|
timeRange={entry.timeRange}
|
||||||
|
useBest={entry.useBest}
|
||||||
dataRange={travelTimeDataRanges.get(index) ?? null}
|
dataRange={travelTimeDataRanges.get(index) ?? null}
|
||||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||||
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -344,14 +351,25 @@ export default memo(function Filters({
|
||||||
|
|
||||||
const isActive = activeFeature === feature.name;
|
const isActive = activeFeature === feature.name;
|
||||||
const isPinned = pinnedFeature === feature.name;
|
const isPinned = pinnedFeature === feature.name;
|
||||||
|
const hist = feature.histogram;
|
||||||
const displayValue =
|
const displayValue =
|
||||||
isActive && dragValue
|
isActive && dragValue
|
||||||
? dragValue
|
? dragValue
|
||||||
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
|
: (filters[feature.name] as [number, number]) || [hist?.min ?? feature.min!, hist?.max ?? feature.max!];
|
||||||
const scale = percentileScales.get(feature.name);
|
const scale = percentileScales.get(feature.name);
|
||||||
|
const dataMin = hist?.min ?? feature.min!;
|
||||||
|
const dataMax = hist?.max ?? feature.max!;
|
||||||
|
const isAtMin = displayValue[0] <= dataMin;
|
||||||
|
const isAtMax = displayValue[1] >= dataMax;
|
||||||
const sliderValue: [number, number] = scale
|
const sliderValue: [number, number] = scale
|
||||||
? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))]
|
? [
|
||||||
: displayValue;
|
isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||||
|
isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
isAtMin ? feature.min! : displayValue[0],
|
||||||
|
isAtMax ? feature.max! : displayValue[1],
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -375,8 +393,18 @@ export default memo(function Filters({
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
onValueChange={
|
onValueChange={
|
||||||
scale
|
scale
|
||||||
? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)])
|
? ([pMin, pMax]) => {
|
||||||
: ([min, max]) => onDragChange([min, max])
|
const step = feature.step ?? 1;
|
||||||
|
const snap = (v: number) => Math.round(v / step) * step;
|
||||||
|
onDragChange([
|
||||||
|
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
|
||||||
|
pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
: ([min, max]) => onDragChange([
|
||||||
|
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
|
||||||
|
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
onPointerDown={() => onDragStart(feature.name)}
|
onPointerDown={() => onDragStart(feature.name)}
|
||||||
onPointerUp={() => onDragEnd()}
|
onPointerUp={() => onDragEnd()}
|
||||||
|
|
@ -386,7 +414,8 @@ export default memo(function Filters({
|
||||||
max={scale ? 100 : feature.max!}
|
max={scale ? 100 : feature.max!}
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
displayValues={scale ? displayValue : undefined}
|
displayValues={scale ? displayValue : undefined}
|
||||||
absoluteMax={feature.absolute}
|
isAtMin={isAtMin}
|
||||||
|
isAtMax={isAtMax}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -416,6 +445,8 @@ export default memo(function Filters({
|
||||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||||
travelTimeEntries={travelTimeEntries}
|
travelTimeEntries={travelTimeEntries}
|
||||||
onAddTravelTimeEntry={onTravelTimeAddEntry}
|
onAddTravelTimeEntry={onTravelTimeAddEntry}
|
||||||
|
isLicensed={isLicensed}
|
||||||
|
onUpgradeClick={onUpgradeClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -423,59 +454,95 @@ export default memo(function Filters({
|
||||||
{showPhilosophy && (
|
{showPhilosophy && (
|
||||||
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
||||||
<div className="space-y-4 text-sm">
|
<div className="space-y-4 text-sm">
|
||||||
|
<p className="text-warm-600 dark:text-warm-300">
|
||||||
|
Start with your must-haves, then layer on nice-to-haves.
|
||||||
|
The map narrows down as you add filters — the areas that survive are your best matches.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||||
Be intentional, not reactive
|
1. Budget & property basics
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-warm-600 dark:text-warm-300">
|
<p className="text-warm-600 dark:text-warm-300">
|
||||||
Your future home isn't a box of cereal you grab because it's on sale.
|
Set your price range, minimum floor area, and property type.
|
||||||
Don't let a seemingly good deal turn into lifelong regret. Instead of waiting
|
If you need a lease over freehold (or vice versa), filter for that too.
|
||||||
for listings to appear, define what you actually want and go find it.
|
This eliminates most of the map immediately.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||||
See the full picture
|
2. Commute & transport
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-warm-600 dark:text-warm-300">
|
<p className="text-warm-600 dark:text-warm-300">
|
||||||
Current listings show only a fraction of the market. There are too few to give you a
|
Add a travel time filter to your workplace — choose public transport or cycling
|
||||||
complete picture, yet too many to evaluate one by one. We aggregate millions of
|
and set your maximum tolerable commute. You can also filter by
|
||||||
historical sales so you can understand what's truly available in any area.
|
how many stations are within walking distance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||||
Your priorities, your filters
|
3. Safety & environment
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-warm-600 dark:text-warm-300">
|
<p className="text-warm-600 dark:text-warm-300">
|
||||||
We all care about different things. Some want peace and quiet; others want to be
|
Use the crime filters to cap serious or minor crime rates.
|
||||||
near the action. Use our filters to define exactly what matters to you and discover
|
Check road noise levels if you're a light sleeper, and
|
||||||
postcodes that match.
|
environmental risk filters for ground stability concerns.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||||
Find the right place, not just the right listing
|
4. Schools & education
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-warm-600 dark:text-warm-300">
|
<p className="text-warm-600 dark:text-warm-300">
|
||||||
The best areas to live don't always have properties listed right now. We help
|
Filter by the number of Ofsted-rated Good or Outstanding primary and
|
||||||
you identify where you should be looking, so when something does come up,
|
secondary schools nearby. The education deprivation score captures
|
||||||
you're ready.
|
broader area-level attainment.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||||
Know what's possible
|
5. Lifestyle & amenities
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-warm-600 dark:text-warm-300">
|
<p className="text-warm-600 dark:text-warm-300">
|
||||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
Want restaurants, parks, or grocery shops within walking distance?
|
||||||
spend months searching for something that doesn't exist.
|
Filter by nearby amenity counts. Broadband speed filters help if
|
||||||
|
you work from home.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||||
|
6. Energy & running costs
|
||||||
|
</h4>
|
||||||
|
<p className="text-warm-600 dark:text-warm-300">
|
||||||
|
EPC ratings from A to G indicate energy efficiency.
|
||||||
|
Filter for better ratings to find homes with lower bills and
|
||||||
|
fewer upgrade headaches.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-1 border-t border-warm-200 dark:border-warm-700">
|
||||||
|
<p className="text-warm-500 dark:text-warm-400 italic">
|
||||||
|
Tip: if nothing survives your filters, relax one constraint at a time
|
||||||
|
to see which compromise unlocks the most options.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onResetTutorial && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowPhilosophy(false);
|
||||||
|
onResetTutorial();
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm"
|
||||||
|
>
|
||||||
|
Replay interactive tutorial
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</InfoPopup>
|
</InfoPopup>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import type { FeatureFilters } from '../../types';
|
import type { FeatureFilters, FeatureMeta } from '../../types';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
|
|
||||||
interface HoverCardData {
|
interface HoverCardData {
|
||||||
|
|
@ -14,11 +14,17 @@ interface HoverCardProps {
|
||||||
isPostcode: boolean;
|
isPostcode: boolean;
|
||||||
data: HoverCardData | null;
|
data: HoverCardData | null;
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
|
features: FeatureMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) {
|
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) {
|
||||||
const activeFilterNames = Object.keys(filters);
|
const activeFilterNames = Object.keys(filters);
|
||||||
|
|
||||||
|
const featureMap = useMemo(
|
||||||
|
() => new Map(features.map((f) => [f.name, f])),
|
||||||
|
[features]
|
||||||
|
);
|
||||||
|
|
||||||
// Get key stats to show from local data (min_<feature> values)
|
// Get key stats to show from local data (min_<feature> values)
|
||||||
const getDisplayStats = () => {
|
const getDisplayStats = () => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
|
||||||
// Show stats for active filters (up to 4)
|
// Show stats for active filters (up to 4)
|
||||||
for (const name of activeFilterNames.slice(0, 4)) {
|
for (const name of activeFilterNames.slice(0, 4)) {
|
||||||
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
const val = data[`avg_${name}`] ?? data[`min_${name}`];
|
||||||
if (val != null && typeof val === 'number') {
|
if (val == null || typeof val !== 'number') continue;
|
||||||
results.push({ name, value: formatValue(val) });
|
const meta = featureMap.get(name);
|
||||||
|
if (meta?.type === 'enum' && meta.values) {
|
||||||
|
const label = meta.values[Math.round(val)];
|
||||||
|
if (label) results.push({ name, value: label });
|
||||||
|
} else {
|
||||||
|
results.push({ name, value: formatValue(val, meta) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,7 @@ export default memo(function Map({
|
||||||
: data.find((d) => d.h3 === hoveredHexagonId) || null
|
: data.find((d) => d.h3 === hoveredHexagonId) || null
|
||||||
}
|
}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
features={features}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -229,16 +229,21 @@ export default function MapPage({
|
||||||
const isPostcode = selection.selectedHexagon?.type === 'postcode';
|
const isPostcode = selection.selectedHexagon?.type === 'postcode';
|
||||||
|
|
||||||
if (isPostcode) {
|
if (isPostcode) {
|
||||||
// For postcodes, get centroid from postcodeData
|
// For postcodes, get centroid from postcodeData; postcode string is the selection id
|
||||||
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
|
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
|
||||||
if (!postcodeFeature?.properties.centroid) return null;
|
if (!postcodeFeature?.properties.centroid) return null;
|
||||||
const [lon, lat] = postcodeFeature.properties.centroid;
|
const [lon, lat] = postcodeFeature.properties.centroid;
|
||||||
return { lat, lon, resolution: mapData.resolution };
|
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
|
||||||
} else {
|
} else {
|
||||||
// For hexagons, get lat/lon from hexagon data
|
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
|
||||||
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
|
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
|
||||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||||
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
return {
|
||||||
|
lat: hex.lat as number,
|
||||||
|
lon: hex.lon as number,
|
||||||
|
resolution: mapData.resolution,
|
||||||
|
postcode: selection.areaStats?.central_postcode,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
selection.selectedHexagon?.id,
|
selection.selectedHexagon?.id,
|
||||||
|
|
@ -246,6 +251,7 @@ export default function MapPage({
|
||||||
mapData.data,
|
mapData.data,
|
||||||
mapData.postcodeData,
|
mapData.postcodeData,
|
||||||
mapData.resolution,
|
mapData.resolution,
|
||||||
|
selection.areaStats?.central_postcode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tutorial = useTutorial(initialLoading, isMobile);
|
const tutorial = useTutorial(initialLoading, isMobile);
|
||||||
|
|
@ -400,10 +406,14 @@ export default function MapPage({
|
||||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||||
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
||||||
|
onTravelTimeToggleBest={travelTime.handleToggleBest}
|
||||||
aiFilterLoading={aiFilters.loading}
|
aiFilterLoading={aiFilters.loading}
|
||||||
aiFilterError={aiFilters.error}
|
aiFilterError={aiFilters.error}
|
||||||
aiFilterNotes={aiFilters.notes}
|
aiFilterNotes={aiFilters.notes}
|
||||||
onAiFilterSubmit={handleAiFilterSubmit}
|
onAiFilterSubmit={handleAiFilterSubmit}
|
||||||
|
isLicensed={user?.subscription === 'licensed'}
|
||||||
|
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||||
|
onResetTutorial={tutorial.resetTutorial}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -560,6 +570,7 @@ export default function MapPage({
|
||||||
callback={tutorial.handleCallback}
|
callback={tutorial.handleCallback}
|
||||||
styles={getTutorialStyles(theme)}
|
styles={getTutorialStyles(theme)}
|
||||||
disableScrolling
|
disableScrolling
|
||||||
|
locale={{ last: 'Finish' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -626,38 +637,40 @@ export default function MapPage({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{selection.selectedHexagon && (
|
||||||
data-tutorial="right-pane"
|
|
||||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
|
||||||
style={{ width: rightPaneWidth }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
data-tutorial="right-pane"
|
||||||
{...rightPaneHandlers}
|
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||||
|
style={{ width: rightPaneWidth }}
|
||||||
>
|
>
|
||||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
<div
|
||||||
</div>
|
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
{...rightPaneHandlers}
|
||||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
>
|
||||||
<TabButton
|
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||||
label="Area"
|
|
||||||
isActive={selection.rightPaneTab === 'area'}
|
|
||||||
onClick={() => selection.setRightPaneTab('area')}
|
|
||||||
/>
|
|
||||||
<TabButton
|
|
||||||
label="Properties"
|
|
||||||
isActive={selection.rightPaneTab === 'properties'}
|
|
||||||
onClick={selection.handlePropertiesTabClick}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||||
|
<TabButton
|
||||||
|
label="Area"
|
||||||
|
isActive={selection.rightPaneTab === 'area'}
|
||||||
|
onClick={() => selection.setRightPaneTab('area')}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
label="Properties"
|
||||||
|
isActive={selection.rightPaneTab === 'properties'}
|
||||||
|
onClick={selection.handlePropertiesTabClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{selection.rightPaneTab === 'properties'
|
{selection.rightPaneTab === 'properties'
|
||||||
? renderPropertiesPane()
|
? renderPropertiesPane()
|
||||||
: renderAreaPane()}
|
: renderAreaPane()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{mapData.licenseRequired && (
|
{mapData.licenseRequired && (
|
||||||
<UpgradeModal
|
<UpgradeModal
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,13 @@ interface TravelTimeCardProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
label: string;
|
label: string;
|
||||||
timeRange: [number, number] | null;
|
timeRange: [number, number] | null;
|
||||||
|
useBest: boolean;
|
||||||
dataRange: [number, number] | null;
|
dataRange: [number, number] | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
onTogglePin: () => void;
|
onTogglePin: () => void;
|
||||||
onSetDestination: (slug: string, label: string) => void;
|
onSetDestination: (slug: string, label: string) => void;
|
||||||
onTimeRangeChange: (range: [number, number]) => void;
|
onTimeRangeChange: (range: [number, number]) => void;
|
||||||
|
onToggleBest: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,11 +41,13 @@ export function TravelTimeCard({
|
||||||
slug,
|
slug,
|
||||||
label,
|
label,
|
||||||
timeRange,
|
timeRange,
|
||||||
|
useBest,
|
||||||
dataRange,
|
dataRange,
|
||||||
isPinned,
|
isPinned,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onSetDestination,
|
onSetDestination,
|
||||||
onTimeRangeChange,
|
onTimeRangeChange,
|
||||||
|
onToggleBest,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: TravelTimeCardProps) {
|
}: TravelTimeCardProps) {
|
||||||
const search = useLocationSearch(mode);
|
const search = useLocationSearch(mode);
|
||||||
|
|
@ -119,6 +123,24 @@ export function TravelTimeCard({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||||
|
{slug && mode === 'transit' && (
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useBest}
|
||||||
|
onChange={onToggleBest}
|
||||||
|
className="accent-teal-600 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||||
|
Best case
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-warm-400 dark:text-warm-500">
|
||||||
|
(optimal departure)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Time range slider — only show when we have data */}
|
{/* Time range slider — only show when we have data */}
|
||||||
{slug && dataRange && (
|
{slug && dataRange && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||||
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
|
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
|
||||||
|
import { cellToBoundary } from 'h3-js';
|
||||||
import type { PickingInfo } from '@deck.gl/core';
|
import type { PickingInfo } from '@deck.gl/core';
|
||||||
import type {
|
import type {
|
||||||
HexagonData,
|
HexagonData,
|
||||||
|
|
@ -80,13 +81,13 @@ export function useDeckLayers({
|
||||||
|
|
||||||
// Marching ants animation
|
// Marching ants animation
|
||||||
const [marchTime, setMarchTime] = useState(0);
|
const [marchTime, setMarchTime] = useState(0);
|
||||||
const hasPostcodeGeometry = selectedPostcodeGeometry != null;
|
const hasSelection = selectedPostcodeGeometry != null || selectedHexagonId != null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasPostcodeGeometry) return;
|
if (!hasSelection) return;
|
||||||
setMarchTime(0);
|
setMarchTime(0);
|
||||||
const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
|
const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [hasPostcodeGeometry]);
|
}, [hasSelection]);
|
||||||
|
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||||
|
|
@ -332,14 +333,11 @@ export function useDeckLayers({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getLineColor: (d) => {
|
getLineColor: (d) => {
|
||||||
if (d.h3 === selectedHexagonIdRef.current)
|
|
||||||
return [255, 255, 255, 255] as [number, number, number, number];
|
|
||||||
if (d.h3 === hoveredHexagonIdRef.current)
|
if (d.h3 === hoveredHexagonIdRef.current)
|
||||||
return [29, 228, 195, 200] as [number, number, number, number];
|
return [29, 228, 195, 200] as [number, number, number, number];
|
||||||
return [0, 0, 0, 0] as [number, number, number, number];
|
return [0, 0, 0, 0] as [number, number, number, number];
|
||||||
},
|
},
|
||||||
getLineWidth: (d) => {
|
getLineWidth: (d) => {
|
||||||
if (d.h3 === selectedHexagonIdRef.current) return 3;
|
|
||||||
if (d.h3 === hoveredHexagonIdRef.current) return 2;
|
if (d.h3 === hoveredHexagonIdRef.current) return 2;
|
||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
|
|
@ -481,15 +479,22 @@ export function useDeckLayers({
|
||||||
[pois, stablePoiHover]
|
[pois, stablePoiHover]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Marching ants highlight layer for selected postcode
|
// Marching ants highlight layer for selected hexagon or postcode
|
||||||
const marchingAntsLayer = useMemo(() => {
|
const marchingAntsLayer = useMemo(() => {
|
||||||
if (!selectedPostcodeGeometry) return null;
|
let geometry: PostcodeGeometry | null = null;
|
||||||
|
if (selectedPostcodeGeometry) {
|
||||||
|
geometry = selectedPostcodeGeometry;
|
||||||
|
} else if (selectedHexagonId) {
|
||||||
|
const boundary = cellToBoundary(selectedHexagonId, true);
|
||||||
|
geometry = { type: 'Polygon', coordinates: [boundary] };
|
||||||
|
}
|
||||||
|
if (!geometry) return null;
|
||||||
return new GeoJsonLayer({
|
return new GeoJsonLayer({
|
||||||
id: 'marching-ants',
|
id: 'marching-ants',
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
geometry: selectedPostcodeGeometry,
|
geometry,
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -502,7 +507,7 @@ export function useDeckLayers({
|
||||||
marchTime,
|
marchTime,
|
||||||
extensions: [new MarchingAntsExtension()],
|
extensions: [new MarchingAntsExtension()],
|
||||||
});
|
});
|
||||||
}, [selectedPostcodeGeometry, marchTime]);
|
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
|
||||||
|
|
||||||
const layers = useMemo(() => {
|
const layers = useMemo(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
if (meta.type === 'enum' && meta.values) {
|
if (meta.type === 'enum' && meta.values) {
|
||||||
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
||||||
|
} else if (meta.type === 'numeric' && meta.histogram) {
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] }));
|
||||||
} else if (meta.min != null && meta.max != null) {
|
} else if (meta.min != null && meta.max != null) {
|
||||||
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
|
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export interface TravelTimeEntry {
|
||||||
slug: string;
|
slug: string;
|
||||||
label: string;
|
label: string;
|
||||||
timeRange: [number, number] | null;
|
timeRange: [number, number] | null;
|
||||||
|
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
|
||||||
|
useBest: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Field key matching the backend response: tt_{mode}_{slug} */
|
/** Field key matching the backend response: tt_{mode}_{slug} */
|
||||||
|
|
@ -33,7 +35,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
const handleAddEntry = useCallback((mode: TransportMode) => {
|
const handleAddEntry = useCallback((mode: TransportMode) => {
|
||||||
setEntries((prev) => [
|
setEntries((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ mode, slug: '', label: '', timeRange: null },
|
{ mode, slug: '', label: '', timeRange: null, useBest: false },
|
||||||
]);
|
]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -63,6 +65,17 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleToggleBest = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((entry, i) =>
|
||||||
|
i === index ? { ...entry, useBest: !entry.useBest, timeRange: null } : entry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
/** Entries that have a destination selected (slug is set) */
|
/** Entries that have a destination selected (slug is set) */
|
||||||
const activeEntries = useMemo(
|
const activeEntries = useMemo(
|
||||||
() => entries.filter((e) => e.slug !== ''),
|
() => entries.filter((e) => e.slug !== ''),
|
||||||
|
|
@ -76,5 +89,6 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
handleRemoveEntry,
|
handleRemoveEntry,
|
||||||
handleSetDestination,
|
handleSetDestination,
|
||||||
handleTimeRangeChange,
|
handleTimeRangeChange,
|
||||||
|
handleToggleBest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const STEPS: Step[] = [
|
||||||
target: '[data-tutorial="filters"]',
|
target: '[data-tutorial="filters"]',
|
||||||
title: 'Filter Properties',
|
title: 'Filter Properties',
|
||||||
content:
|
content:
|
||||||
'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.',
|
'Use filters to narrow down to areas which contain matching properties. Filter by crime rate, number of schools around, or filter to an area with detached houses. Pin a filter with the eye icon to colour the map by that feature.',
|
||||||
placement: 'right',
|
placement: 'right',
|
||||||
disableBeacon: true,
|
disableBeacon: true,
|
||||||
},
|
},
|
||||||
|
|
@ -17,7 +17,7 @@ const STEPS: Step[] = [
|
||||||
target: '[data-tutorial="map"]',
|
target: '[data-tutorial="map"]',
|
||||||
title: 'Explore the Map',
|
title: 'Explore the Map',
|
||||||
content:
|
content:
|
||||||
'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.',
|
'Pan and zoom to explore property data across England. Click any area (hexagon or postcode boundary) to see detailed stats of historical or currently sold properties matching your filters.',
|
||||||
placement: 'bottom',
|
placement: 'bottom',
|
||||||
disableBeacon: true,
|
disableBeacon: true,
|
||||||
},
|
},
|
||||||
|
|
@ -44,6 +44,11 @@ const STEPS: Step[] = [
|
||||||
'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
|
'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
|
||||||
placement: 'left',
|
placement: 'left',
|
||||||
disableBeacon: true,
|
disableBeacon: true,
|
||||||
|
styles: {
|
||||||
|
tooltip: {
|
||||||
|
transform: 'translateY(-50px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ uniform marchingAntsUniforms {
|
||||||
} marchingAnts;`,
|
} marchingAnts;`,
|
||||||
'fs:DECKGL_FILTER_COLOR': `\
|
'fs:DECKGL_FILTER_COLOR': `\
|
||||||
float marchSegLen = 4.0;
|
float marchSegLen = 4.0;
|
||||||
float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0);
|
float marchPos = mod(geometry.uv.y - marchingAnts.marchTime, marchSegLen * 2.0);
|
||||||
if (marchPos < marchSegLen) {
|
if (marchPos < marchSegLen) {
|
||||||
color = vec4(1.0, 1.0, 1.0, color.a);
|
color = vec4(1.0, 1.0, 1.0, color.a);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta
|
||||||
return `${name}:${(value as string[]).join('|')}`;
|
return `${name}:${(value as string[]).join('|')}`;
|
||||||
}
|
}
|
||||||
const [min, max] = value as [number, number];
|
const [min, max] = value as [number, number];
|
||||||
const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max);
|
const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max;
|
||||||
|
const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max);
|
||||||
return `${name}:${min}:${maxStr}`;
|
return `${name}:${min}:${maxStr}`;
|
||||||
})
|
})
|
||||||
.join(';;');
|
.join(';;');
|
||||||
|
|
|
||||||
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 = {
|
export const INITIAL_VIEW_STATE: ViewState = {
|
||||||
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
|
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
|
||||||
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
|
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
|
||||||
zoom: 14,
|
zoom: 15,
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -33,10 +33,9 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
||||||
{ maxZoom: 10.5, resolution: 7 },
|
{ maxZoom: 10.5, resolution: 7 },
|
||||||
{ maxZoom: 11.5, resolution: 8 },
|
{ maxZoom: 11.5, resolution: 8 },
|
||||||
{ maxZoom: 13, resolution: 9 },
|
{ maxZoom: 13, resolution: 9 },
|
||||||
{ maxZoom: Infinity, resolution: 10 },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const POSTCODE_ZOOM_THRESHOLD = 16;
|
export const POSTCODE_ZOOM_THRESHOLD = 14.5;
|
||||||
|
|
||||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
{ t: 0, color: [46, 204, 113] },
|
{ t: 0, color: [46, 204, 113] },
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ export interface HexagonLocation {
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
resolution: number;
|
resolution: number;
|
||||||
|
postcode?: string;
|
||||||
|
isPostcode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROPERTY_TYPE_MAP: Record<
|
const PROPERTY_TYPE_MAP: Record<
|
||||||
|
|
@ -32,10 +34,10 @@ export const H3_RADIUS_MILES: Record<number, number> = {
|
||||||
6: 3,
|
6: 3,
|
||||||
7: 1,
|
7: 1,
|
||||||
8: 0.5,
|
8: 0.5,
|
||||||
9: 0.25,
|
9: 1,
|
||||||
10: 0.25,
|
10: 1,
|
||||||
11: 0.25,
|
11: 1,
|
||||||
12: 0.25,
|
12: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||||
|
|
@ -46,13 +48,21 @@ function nearestRadius(target: number, allowed: number[]): number {
|
||||||
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
|
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPropertySearchUrls(
|
interface SearchUrlOptions {
|
||||||
location: HexagonLocation,
|
location: HexagonLocation;
|
||||||
filters: FeatureFilters
|
filters: FeatureFilters;
|
||||||
): { rightmove: string; onthemarket: string; zoopla: string } {
|
rightmoveLocationId?: string;
|
||||||
const { lat, lon, resolution } = location;
|
}
|
||||||
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
|
|
||||||
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
export function buildPropertySearchUrls({
|
||||||
|
location,
|
||||||
|
filters,
|
||||||
|
rightmoveLocationId,
|
||||||
|
}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
|
||||||
|
const { postcode, resolution, isPostcode } = location;
|
||||||
|
if (!postcode) return null;
|
||||||
|
|
||||||
|
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||||
|
|
||||||
const priceFilter = filters['Last known price'];
|
const priceFilter = filters['Last known price'];
|
||||||
const minPrice =
|
const minPrice =
|
||||||
|
|
@ -66,43 +76,51 @@ export function buildPropertySearchUrls(
|
||||||
? (propertyTypes as string[])
|
? (propertyTypes as string[])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const rmParams = new URLSearchParams();
|
// Rightmove — requires locationIdentifier from typeahead API
|
||||||
rmParams.set('searchLocation', coordStr);
|
let rightmove: string | null = null;
|
||||||
rmParams.set('channel', 'BUY');
|
if (rightmoveLocationId) {
|
||||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
const rmParams = new URLSearchParams();
|
||||||
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
|
rmParams.set('searchLocation', postcode);
|
||||||
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
|
rmParams.set('useLocationIdentifier', 'true');
|
||||||
if (selectedTypes.length > 0) {
|
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||||
const rmTypes = [
|
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||||
...new Set(
|
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
|
||||||
selectedTypes.flatMap((t) => {
|
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
|
||||||
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
|
if (selectedTypes.length > 0) {
|
||||||
return mapped ? mapped.split(',') : [];
|
const rmTypes = [
|
||||||
})
|
...new Set(
|
||||||
),
|
selectedTypes.flatMap((t) => {
|
||||||
];
|
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
|
||||||
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
return mapped ? mapped.split(',') : [];
|
||||||
|
})
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
||||||
|
}
|
||||||
|
rmParams.set('_includeSSTC', 'on');
|
||||||
|
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||||
}
|
}
|
||||||
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
|
||||||
|
|
||||||
let otmType = 'property';
|
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
|
||||||
if (selectedTypes.length > 0) {
|
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
|
||||||
const otmTypes = [
|
|
||||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
|
||||||
];
|
|
||||||
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
|
|
||||||
}
|
|
||||||
const otmParams = new URLSearchParams();
|
const otmParams = new URLSearchParams();
|
||||||
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
||||||
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
|
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
|
||||||
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
|
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
|
||||||
otmParams.set('search-site', 'geo');
|
if (selectedTypes.length > 0) {
|
||||||
otmParams.set('geo-lat', String(lat));
|
const otmTypes = [
|
||||||
otmParams.set('geo-lng', String(lon));
|
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||||
const onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
|
];
|
||||||
|
for (const ot of otmTypes) {
|
||||||
|
otmParams.append('prop-types', ot!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
otmParams.set('view', 'map-list');
|
||||||
|
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
|
||||||
|
|
||||||
|
// Zoopla
|
||||||
const zParams = new URLSearchParams();
|
const zParams = new URLSearchParams();
|
||||||
zParams.set('q', coordStr);
|
zParams.set('q', postcode);
|
||||||
zParams.set('search_source', 'for-sale');
|
zParams.set('search_source', 'for-sale');
|
||||||
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
||||||
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
|
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
|
||||||
|
|
@ -115,7 +133,6 @@ export function buildPropertySearchUrls(
|
||||||
zParams.append('property_sub_type', zt!);
|
zParams.append('property_sub_type', zt!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
|
|
||||||
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
||||||
|
|
||||||
return { rightmove, onthemarket, zoopla };
|
return { rightmove, onthemarket, zoopla };
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export function parseUrlState(): {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Travel time: repeated `tt` params
|
// Travel time: repeated `tt` params
|
||||||
// Format: mode:slug:label or mode:slug:label:min:max
|
// Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
|
||||||
const ttParams = params.getAll('tt');
|
const ttParams = params.getAll('tt');
|
||||||
if (ttParams.length > 0) {
|
if (ttParams.length > 0) {
|
||||||
const entries: TravelTimeEntry[] = [];
|
const entries: TravelTimeEntry[] = [];
|
||||||
|
|
@ -82,15 +82,17 @@ export function parseUrlState(): {
|
||||||
if (!TRANSPORT_MODES.includes(mode)) continue;
|
if (!TRANSPORT_MODES.includes(mode)) continue;
|
||||||
const slug = parts[1];
|
const slug = parts[1];
|
||||||
const label = decodeURIComponent(parts[2]);
|
const label = decodeURIComponent(parts[2]);
|
||||||
|
const useBest = parts.length >= 4 && parts[3] === 'b';
|
||||||
|
const rangeOffset = useBest ? 1 : 0;
|
||||||
let timeRange: [number, number] | null = null;
|
let timeRange: [number, number] | null = null;
|
||||||
if (parts.length >= 5) {
|
if (parts.length >= 5 + rangeOffset) {
|
||||||
const min = Number(parts[3]);
|
const min = Number(parts[3 + rangeOffset]);
|
||||||
const max = Number(parts[4]);
|
const max = Number(parts[4 + rangeOffset]);
|
||||||
if (!isNaN(min) && !isNaN(max)) {
|
if (!isNaN(min) && !isNaN(max)) {
|
||||||
timeRange = [min, max];
|
timeRange = [min, max];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entries.push({ mode, slug, label, timeRange });
|
entries.push({ mode, slug, label, timeRange, useBest });
|
||||||
}
|
}
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
result.travelTime = { entries };
|
result.travelTime = { entries };
|
||||||
|
|
@ -139,6 +141,7 @@ export function stateToParams(
|
||||||
for (const entry of travelTimeEntries) {
|
for (const entry of travelTimeEntries) {
|
||||||
if (!entry.slug) continue;
|
if (!entry.slug) continue;
|
||||||
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||||
|
if (entry.useBest) val += ':b';
|
||||||
if (entry.timeRange) {
|
if (entry.timeRange) {
|
||||||
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,4 +172,5 @@ export interface HexagonStatsResponse {
|
||||||
numeric_features: NumericFeatureStats[];
|
numeric_features: NumericFeatureStats[];
|
||||||
enum_features: EnumFeatureStats[];
|
enum_features: EnumFeatureStats[];
|
||||||
price_history?: PricePoint[];
|
price_history?: PricePoint[];
|
||||||
|
central_postcode?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ set -euo pipefail
|
||||||
# Batch-compute travel times from all places to all England postcodes
|
# Batch-compute travel times from all places to all England postcodes
|
||||||
# for all transport modes (car, bicycle, walking, transit).
|
# for all transport modes (car, bicycle, walking, transit).
|
||||||
#
|
#
|
||||||
|
# Uses full England OSM + 2 GTFS feeds (BODS buses, National Rail).
|
||||||
|
# R5's TransportNetwork.fromDirectory() picks up all .osm.pbf and .zip files.
|
||||||
|
#
|
||||||
# Uses each place as origin with all postcodes as destinations — R5 does one
|
# Uses each place as origin with all postcodes as destinations — R5 does one
|
||||||
# routing computation per place, then reads off travel times to all postcodes.
|
# routing computation per place, then reads off travel times to all postcodes.
|
||||||
# For car/bicycle/walking this is symmetric (place->postcode = postcode->place).
|
# For car/bicycle/walking this is symmetric (place->postcode = postcode->place).
|
||||||
|
|
@ -15,11 +18,10 @@ set -euo pipefail
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./r5-java/run.sh
|
# ./r5-java/run.sh
|
||||||
# ./r5-java/run.sh --threads 8 --heap 24g --output-dir property-data/travel-times
|
|
||||||
|
|
||||||
# --- Defaults ---
|
# --- Defaults ---
|
||||||
THREADS=16
|
THREADS=4
|
||||||
HEAP=16g
|
HEAP=12g
|
||||||
NETWORK_DIR=property-data/r5-network
|
NETWORK_DIR=property-data/r5-network
|
||||||
OUTPUT_BASE=property-data/travel-times
|
OUTPUT_BASE=property-data/travel-times
|
||||||
R5_DIR=r5-java
|
R5_DIR=r5-java
|
||||||
|
|
@ -102,25 +104,26 @@ fi
|
||||||
# R5 writes .mapdb temp files next to OSM/GTFS files during network construction.
|
# R5 writes .mapdb temp files next to OSM/GTFS files during network construction.
|
||||||
# Copy source data to a writable build dir to avoid polluting the originals.
|
# Copy source data to a writable build dir to avoid polluting the originals.
|
||||||
mkdir -p "$NETWORK_DIR"
|
mkdir -p "$NETWORK_DIR"
|
||||||
DATA_DIR="property-data/transit"
|
TRANSIT_SRC="property-data/transit"
|
||||||
|
NETWORK_DATA_DIR="$TRANSIT_SRC"
|
||||||
|
|
||||||
if [ ! -f "$NETWORK_DIR/network.dat" ]; then
|
if [ ! -f "$NETWORK_DIR/network.dat" ]; then
|
||||||
BUILD_DIR="$NETWORK_DIR/build"
|
BUILD_DIR="$NETWORK_DIR/build"
|
||||||
echo "--- No cached network — copying transit data to build dir ---"
|
echo "--- No cached network — copying transit data to build dir ---"
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
if ! cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then
|
if ! cp "$TRANSIT_SRC"/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then
|
||||||
echo "Warning: no .osm.pbf files found in property-data/transit/raw/"
|
echo "Warning: no .osm.pbf files found in $TRANSIT_SRC/raw/"
|
||||||
fi
|
fi
|
||||||
if ! cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null; then
|
if ! cp "$TRANSIT_SRC"/*.zip "$BUILD_DIR/" 2>/dev/null; then
|
||||||
echo "Warning: no .zip files found in property-data/transit/"
|
echo "Warning: no .zip files found in $TRANSIT_SRC/"
|
||||||
fi
|
fi
|
||||||
DATA_DIR="$BUILD_DIR"
|
NETWORK_DATA_DIR="$BUILD_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Step 5: Run batch ---
|
# --- Step 5: Run batch ---
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- Starting batch computation ---"
|
echo "--- Starting batch computation ---"
|
||||||
DATA_DIR="$DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
|
DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
|
||||||
java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
|
java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
|
||||||
--postcodes property-data/arcgis_data.parquet \
|
--postcodes property-data/arcgis_data.parquet \
|
||||||
--places property-data/places.parquet \
|
--places property-data/places.parquet \
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,9 @@ public class App {
|
||||||
if (attempt < MAX_RETRIES) {
|
if (attempt < MAX_RETRIES) {
|
||||||
System.err.printf("%n [RETRY %d/%d] %s: %s%n",
|
System.err.printf("%n [RETRY %d/%d] %s: %s%n",
|
||||||
attempt + 1, MAX_RETRIES, name, e.getMessage());
|
attempt + 1, MAX_RETRIES, name, e.getMessage());
|
||||||
|
} else {
|
||||||
|
System.err.printf("%n [FAIL TRACE] %s:%n", name);
|
||||||
|
e.printStackTrace(System.err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +218,7 @@ public class App {
|
||||||
String safe = name.toLowerCase()
|
String safe = name.toLowerCase()
|
||||||
.replaceAll("[^a-z0-9 -]", "")
|
.replaceAll("[^a-z0-9 -]", "")
|
||||||
.replaceAll("\\s+", "-");
|
.replaceAll("\\s+", "-");
|
||||||
return String.format("%04d-%s.parquet", index, safe);
|
return String.format("%06d-%s.parquet", index, safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String requiredArg(String[] args, String name) {
|
private static String requiredArg(String[] args, String name) {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ public class Router {
|
||||||
private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00
|
private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00
|
||||||
private static final int MAX_TRIP_DURATION_MINUTES = 120;
|
private static final int MAX_TRIP_DURATION_MINUTES = 120;
|
||||||
|
|
||||||
|
// Percentile indices in R5 result arrays (order must match task.percentiles in buildTask)
|
||||||
|
private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only)
|
||||||
|
private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0)
|
||||||
|
|
||||||
/** Result of computing travel times for a single origin with spatial pre-filtering. */
|
/** Result of computing travel times for a single origin with spatial pre-filtering. */
|
||||||
record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {}
|
record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {}
|
||||||
|
|
||||||
|
|
@ -102,10 +106,9 @@ public class Router {
|
||||||
boolean isTransit = mode.equals("transit");
|
boolean isTransit = mode.equals("transit");
|
||||||
short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date);
|
short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date);
|
||||||
|
|
||||||
// For transit: allTimes[0]=best (5th percentile), allTimes[1]=median (50th)
|
// Transit requests [5th, 50th] percentiles; others request [50th] only
|
||||||
// For others: allTimes[0]=median (50th), no best
|
short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0];
|
||||||
short[] medianTimes = isTransit ? allTimes[1] : allTimes[0];
|
short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null;
|
||||||
short[] bestTimes = isTransit ? allTimes[0] : null;
|
|
||||||
return new FilteredResult(filtered, medianTimes, bestTimes);
|
return new FilteredResult(filtered, medianTimes, bestTimes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,13 +208,24 @@ public class Router {
|
||||||
OneOriginResult result = computer.computeTravelTimes();
|
OneOriginResult result = computer.computeTravelTimes();
|
||||||
|
|
||||||
TravelTimeResult tt = result.travelTimes;
|
TravelTimeResult tt = result.travelTimes;
|
||||||
if (tt != null) {
|
if (tt == null) {
|
||||||
int[][] values = tt.getValues();
|
throw new RuntimeException("R5 returned null travelTimes for chunk with "
|
||||||
for (int p = 0; p < nPercentiles && p < values.length; p++) {
|
+ chunk.originalIndices.length + " destinations");
|
||||||
for (int i = 0; i < chunk.originalIndices.length && i < values[p].length; i++) {
|
}
|
||||||
if (values[p][i] != Integer.MAX_VALUE) {
|
int[][] values = tt.getValues();
|
||||||
allTimes[p][chunk.originalIndices[i]] = (short) values[p][i];
|
if (values.length < nPercentiles) {
|
||||||
}
|
throw new RuntimeException("R5 returned " + values.length + " percentiles, expected "
|
||||||
|
+ nPercentiles);
|
||||||
|
}
|
||||||
|
for (int p = 0; p < nPercentiles; p++) {
|
||||||
|
if (values[p].length < chunk.originalIndices.length) {
|
||||||
|
throw new RuntimeException("R5 returned " + values[p].length
|
||||||
|
+ " travel times for percentile " + p + ", expected "
|
||||||
|
+ chunk.originalIndices.length);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < chunk.originalIndices.length; i++) {
|
||||||
|
if (values[p][i] != Integer.MAX_VALUE) {
|
||||||
|
allTimes[p][chunk.originalIndices[i]] = (short) values[p][i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
server-rs/Cargo.lock
generated
16
server-rs/Cargo.lock
generated
|
|
@ -2743,6 +2743,7 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
|
@ -2767,12 +2768,14 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
@ -3803,6 +3806,19 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
metrics-exporter-prometheus = "0.16"
|
metrics-exporter-prometheus = "0.16"
|
||||||
reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
rust_xlsxwriter = "0.79"
|
rust_xlsxwriter = "0.79"
|
||||||
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
|
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,8 @@ pub struct PlaceData {
|
||||||
fn type_rank(place_type: &str) -> u8 {
|
fn type_rank(place_type: &str) -> u8 {
|
||||||
match place_type {
|
match place_type {
|
||||||
"city" => 0,
|
"city" => 0,
|
||||||
"borough" => 1,
|
"station" => 1,
|
||||||
"town" => 2,
|
_ => 2,
|
||||||
"suburb" => 3,
|
|
||||||
"quarter" => 4,
|
|
||||||
"neighbourhood" => 5,
|
|
||||||
"village" => 6,
|
|
||||||
"station" => 7,
|
|
||||||
"island" => 8,
|
|
||||||
"hamlet" => 9,
|
|
||||||
"locality" => 10,
|
|
||||||
"isolated_dwelling" => 11,
|
|
||||||
_ => 12,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,10 +149,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn type_rank_ordering() {
|
fn type_rank_ordering() {
|
||||||
assert!(type_rank("city") < type_rank("town"));
|
assert!(type_rank("city") < type_rank("station"));
|
||||||
assert!(type_rank("town") < type_rank("suburb"));
|
assert!(type_rank("station") < type_rank("unknown"));
|
||||||
assert!(type_rank("suburb") < type_rank("village"));
|
|
||||||
assert!(type_rank("village") < type_rank("hamlet"));
|
|
||||||
assert!(type_rank("hamlet") < type_rank("isolated_dwelling"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,15 @@ use polars::lazy::frame::LazyFrame;
|
||||||
use rustc_hash::{FxHashMap, FxHashSet};
|
use rustc_hash::{FxHashMap, FxHashSet};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
/// Cached postcode → travel_minutes mapping for a single destination file.
|
/// Per-postcode travel time data: median and optional best-case (transit only).
|
||||||
pub type TravelData = Arc<FxHashMap<String, i16>>;
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct TravelDataRow {
|
||||||
|
pub minutes: i16,
|
||||||
|
pub best_minutes: Option<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached postcode → travel time data for a single destination file.
|
||||||
|
pub type TravelData = Arc<FxHashMap<String, TravelDataRow>>;
|
||||||
|
|
||||||
/// Simple LRU cache for travel time data, limited to `capacity` entries.
|
/// Simple LRU cache for travel time data, limited to `capacity` entries.
|
||||||
struct LruCache {
|
struct LruCache {
|
||||||
|
|
@ -159,12 +166,23 @@ impl TravelTimeStore {
|
||||||
.context("Missing 'travel_minutes' column")?
|
.context("Missing 'travel_minutes' column")?
|
||||||
.i16()
|
.i16()
|
||||||
.context("'travel_minutes' is not i16")?;
|
.context("'travel_minutes' is not i16")?;
|
||||||
|
let best = df
|
||||||
|
.column("best_minutes")
|
||||||
|
.ok()
|
||||||
|
.map(|col| col.i16().expect("'best_minutes' is not i16"));
|
||||||
|
|
||||||
let mut map = FxHashMap::default();
|
let mut map = FxHashMap::default();
|
||||||
map.reserve(df.height());
|
map.reserve(df.height());
|
||||||
for (pc, min) in postcodes.into_iter().zip(minutes.into_iter()) {
|
for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
|
||||||
if let (Some(pc), Some(min)) = (pc, min) {
|
if let (Some(pc), Some(min)) = (pc, min) {
|
||||||
map.insert(pc.to_string(), min);
|
let best_min = best.as_ref().and_then(|b| b.get(i));
|
||||||
|
map.insert(
|
||||||
|
pc.to_string(),
|
||||||
|
TravelDataRow {
|
||||||
|
minutes: min,
|
||||||
|
best_minutes: best_min,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let state_invites_create = state.clone();
|
let state_invites_create = state.clone();
|
||||||
let state_invite_get = state.clone();
|
let state_invite_get = state.clone();
|
||||||
let state_redeem_invite = state.clone();
|
let state_redeem_invite = state.clone();
|
||||||
|
let state_rightmove = state.clone();
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -495,6 +496,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"/api/streetview",
|
"/api/streetview",
|
||||||
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
|
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/rightmove-location",
|
||||||
|
get(move |query| routes::get_rightmove_typeahead(state_rightmove.clone(), query)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/subscription",
|
"/api/subscription",
|
||||||
patch(move |ext, body| {
|
patch(move |ext, body| {
|
||||||
|
|
@ -569,7 +574,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let app = if let Some(ref dist) = cli.dist {
|
let app = if let Some(ref dist) = cli.dist {
|
||||||
api.fallback_service(
|
api.fallback_service(
|
||||||
ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))),
|
ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
api
|
api
|
||||||
|
|
|
||||||
|
|
@ -405,35 +405,49 @@ pub async fn ensure_oauth_providers(
|
||||||
let base_url = base_url.trim_end_matches('/');
|
let base_url = base_url.trim_end_matches('/');
|
||||||
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
|
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
|
||||||
|
|
||||||
// GET current settings
|
// Set meta.appURL in global settings for OAuth redirects
|
||||||
|
let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
|
||||||
let settings_url = format!("{base_url}/api/settings");
|
let settings_url = format!("{base_url}/api/settings");
|
||||||
|
let patch_resp = client
|
||||||
|
.patch(&settings_url)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.json(&serde_json::json!({ "meta": { "appURL": app_url } }))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if !patch_resp.status().is_success() {
|
||||||
|
let status = patch_resp.status();
|
||||||
|
let text = patch_resp.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Failed to update PocketBase meta.appURL ({status}): {text}");
|
||||||
|
}
|
||||||
|
info!("PocketBase meta.appURL set to {app_url}");
|
||||||
|
|
||||||
|
// PocketBase 0.23+: OAuth providers are configured per-collection, not in global settings.
|
||||||
|
// GET the users collection to update its oauth2 config.
|
||||||
|
let collection_url = format!("{base_url}/api/collections/users");
|
||||||
let resp = client
|
let resp = client
|
||||||
.get(&settings_url)
|
.get(&collection_url)
|
||||||
.header("Authorization", format!("Bearer {token}"))
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
anyhow::bail!("Failed to fetch PocketBase settings ({status}): {text}");
|
anyhow::bail!("Failed to fetch users collection ({status}): {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut settings: serde_json::Value = resp.json().await?;
|
let mut collection: serde_json::Value = resp.json().await?;
|
||||||
|
|
||||||
// Set meta.appUrl for OAuth redirect
|
let oauth2 = collection
|
||||||
let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
|
.get_mut("oauth2")
|
||||||
if let Some(meta) = settings.get_mut("meta") {
|
.ok_or_else(|| anyhow::anyhow!("users collection missing oauth2 field"))?;
|
||||||
meta["appUrl"] = serde_json::json!(app_url);
|
|
||||||
} else {
|
|
||||||
settings["meta"] = serde_json::json!({ "appUrl": app_url });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update OAuth2 providers
|
// Ensure enabled
|
||||||
let providers = settings
|
oauth2["enabled"] = serde_json::json!(true);
|
||||||
.pointer_mut("/oauth2/providers")
|
|
||||||
|
let providers = oauth2
|
||||||
|
.get_mut("providers")
|
||||||
.and_then(|v| v.as_array_mut())
|
.and_then(|v| v.as_array_mut())
|
||||||
.ok_or_else(|| anyhow::anyhow!("PocketBase settings missing oauth2.providers array — cannot configure OAuth"))?;
|
.ok_or_else(|| anyhow::anyhow!("users collection missing oauth2.providers array"))?;
|
||||||
|
|
||||||
let google = match providers
|
let google = match providers
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -441,7 +455,7 @@ pub async fn ensure_oauth_providers(
|
||||||
{
|
{
|
||||||
Some(idx) => &mut providers[idx],
|
Some(idx) => &mut providers[idx],
|
||||||
None => {
|
None => {
|
||||||
info!("Google provider not found in PocketBase settings — adding it");
|
info!("Google provider not found — adding it");
|
||||||
providers.push(serde_json::json!({"name": "google"}));
|
providers.push(serde_json::json!({"name": "google"}));
|
||||||
providers.last_mut().expect("just pushed")
|
providers.last_mut().expect("just pushed")
|
||||||
}
|
}
|
||||||
|
|
@ -449,23 +463,20 @@ pub async fn ensure_oauth_providers(
|
||||||
|
|
||||||
google["clientId"] = serde_json::json!(google_client_id);
|
google["clientId"] = serde_json::json!(google_client_id);
|
||||||
google["clientSecret"] = serde_json::json!(google_client_secret);
|
google["clientSecret"] = serde_json::json!(google_client_secret);
|
||||||
google["enabled"] = serde_json::json!(true);
|
|
||||||
info!("Configured Google OAuth provider");
|
|
||||||
|
|
||||||
// PATCH settings back
|
// PATCH the collection
|
||||||
let patch_resp = client
|
let patch_resp = client
|
||||||
.patch(&settings_url)
|
.patch(&collection_url)
|
||||||
.header("Authorization", format!("Bearer {token}"))
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
.json(&settings)
|
.json(&serde_json::json!({ "oauth2": oauth2 }))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !patch_resp.status().is_success() {
|
if !patch_resp.status().is_success() {
|
||||||
let status = patch_resp.status();
|
let status = patch_resp.status();
|
||||||
let text = patch_resp.text().await.unwrap_or_default();
|
let text = patch_resp.text().await.unwrap_or_default();
|
||||||
anyhow::bail!("Failed to update PocketBase settings ({status}): {text}");
|
anyhow::bail!("Failed to update users collection OAuth ({status}): {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("PocketBase OAuth settings updated (appUrl: {app_url})");
|
info!("PocketBase OAuth configured on users collection");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ mod streetview;
|
||||||
mod stripe_webhook;
|
mod stripe_webhook;
|
||||||
mod newsletter;
|
mod newsletter;
|
||||||
pub(crate) mod pricing;
|
pub(crate) mod pricing;
|
||||||
|
mod rightmove_typeahead;
|
||||||
mod subscription;
|
mod subscription;
|
||||||
mod tiles;
|
mod tiles;
|
||||||
pub(crate) mod travel_time;
|
pub(crate) mod travel_time;
|
||||||
|
|
@ -46,4 +47,5 @@ pub use pricing::get_pricing;
|
||||||
pub use stripe_webhook::post_stripe_webhook;
|
pub use stripe_webhook::post_stripe_webhook;
|
||||||
pub use subscription::patch_subscription;
|
pub use subscription::patch_subscription;
|
||||||
pub use tiles::{get_style, get_tile, init_tile_reader};
|
pub use tiles::{get_style, get_tile, init_tile_reader};
|
||||||
|
pub use rightmove_typeahead::get_rightmove_typeahead;
|
||||||
pub use travel_modes::get_travel_modes;
|
pub use travel_modes::get_travel_modes;
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ pub struct HexagonStatsResponse {
|
||||||
pub enum_features: Vec<EnumFeatureStats>,
|
pub enum_features: Vec<EnumFeatureStats>,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub price_history: Vec<PricePoint>,
|
pub price_history: Vec<PricePoint>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub central_postcode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -136,6 +138,31 @@ pub async fn get_hexagon_stats(
|
||||||
|
|
||||||
let total_count = matching_rows.len();
|
let total_count = matching_rows.len();
|
||||||
|
|
||||||
|
// Find the postcode of the property closest to the hexagon center
|
||||||
|
let central_postcode = if !matching_rows.is_empty() {
|
||||||
|
let center: h3o::LatLng = cell.into();
|
||||||
|
let center_lat = center.lat() as f32;
|
||||||
|
let center_lon = center.lng() as f32;
|
||||||
|
let closest_row = matching_rows
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.min_by(|&a, &b| {
|
||||||
|
let da_lat = state.data.lat[a] - center_lat;
|
||||||
|
let da_lon = state.data.lon[a] - center_lon;
|
||||||
|
let db_lat = state.data.lat[b] - center_lat;
|
||||||
|
let db_lon = state.data.lon[b] - center_lon;
|
||||||
|
let dist_a = da_lat * da_lat + da_lon * da_lon;
|
||||||
|
let dist_b = db_lat * db_lat + db_lon * db_lon;
|
||||||
|
dist_a
|
||||||
|
.partial_cmp(&dist_b)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.expect("matching_rows is non-empty");
|
||||||
|
Some(state.data.postcode(closest_row).to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let price_history = stats::extract_price_history(
|
let price_history = stats::extract_price_history(
|
||||||
&matching_rows,
|
&matching_rows,
|
||||||
feature_data,
|
feature_data,
|
||||||
|
|
@ -170,6 +197,7 @@ pub async fn get_hexagon_stats(
|
||||||
numeric_features,
|
numeric_features,
|
||||||
enum_features: enum_features_out,
|
enum_features: enum_features_out,
|
||||||
price_history,
|
price_history,
|
||||||
|
central_postcode,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,13 @@ pub struct HexagonParams {
|
||||||
struct TravelEntry {
|
struct TravelEntry {
|
||||||
mode: String,
|
mode: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
|
use_best: bool,
|
||||||
filter_min: Option<f32>,
|
filter_min: Option<f32>,
|
||||||
filter_max: Option<f32>,
|
filter_max: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `travel` param into a list of travel entries.
|
/// Parse `travel` param into a list of travel entries.
|
||||||
/// Format: `mode:slug` or `mode:slug:min:max`
|
/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
|
||||||
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
let mut seen_keys = Vec::new();
|
let mut seen_keys = Vec::new();
|
||||||
|
|
@ -63,12 +64,15 @@ fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||||
let mode = parts[0].trim().to_string();
|
let mode = parts[0].trim().to_string();
|
||||||
let slug = parts[1].trim().to_string();
|
let slug = parts[1].trim().to_string();
|
||||||
|
|
||||||
let (filter_min, filter_max) = if parts.len() >= 4 {
|
let use_best = parts.len() >= 3 && parts[2].trim() == "best";
|
||||||
let min: f32 = parts[2]
|
let filter_offset = if use_best { 1 } else { 0 };
|
||||||
|
|
||||||
|
let (filter_min, filter_max) = if parts.len() >= 4 + filter_offset {
|
||||||
|
let min: f32 = parts[2 + filter_offset]
|
||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
|
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
|
||||||
let max: f32 = parts[3]
|
let max: f32 = parts[3 + filter_offset]
|
||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
|
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
|
||||||
|
|
@ -85,6 +89,7 @@ fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||||
entries.push(TravelEntry {
|
entries.push(TravelEntry {
|
||||||
mode,
|
mode,
|
||||||
slug,
|
slug,
|
||||||
|
use_best,
|
||||||
filter_min,
|
filter_min,
|
||||||
filter_max,
|
filter_max,
|
||||||
});
|
});
|
||||||
|
|
@ -286,7 +291,14 @@ pub async fn get_hexagons(
|
||||||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||||
travel_minutes.reserve(travel_entries.len());
|
travel_minutes.reserve(travel_entries.len());
|
||||||
for (ti, entry) in travel_entries.iter().enumerate() {
|
for (ti, entry) in travel_entries.iter().enumerate() {
|
||||||
let minutes = travel_data[ti].get(postcode).copied();
|
let row_data = travel_data[ti].get(postcode);
|
||||||
|
let minutes = row_data.map(|r| {
|
||||||
|
if entry.use_best {
|
||||||
|
r.best_minutes.unwrap_or(r.minutes)
|
||||||
|
} else {
|
||||||
|
r.minutes
|
||||||
|
}
|
||||||
|
});
|
||||||
travel_minutes.push(minutes);
|
travel_minutes.push(minutes);
|
||||||
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
|
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
|
||||||
match minutes {
|
match minutes {
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ use crate::state::AppState;
|
||||||
|
|
||||||
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
|
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
|
||||||
/// responses are passed through to the browser (needed for OAuth flows).
|
/// responses are passed through to the browser (needed for OAuth flows).
|
||||||
|
/// No overall timeout because SSE (Server-Sent Events) connections used by
|
||||||
|
/// PocketBase realtime/OAuth2 are long-lived streams.
|
||||||
static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.connect_timeout(Duration::from_secs(5))
|
.connect_timeout(Duration::from_secs(5))
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to build proxy HTTP client")
|
.expect("Failed to build proxy HTTP client")
|
||||||
|
|
@ -97,16 +98,12 @@ pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl Int
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match upstream.bytes().await {
|
// Stream the response body instead of buffering it entirely.
|
||||||
Ok(bytes) => response.body(Body::from(bytes)).unwrap(),
|
// This is critical for SSE (Server-Sent Events) used by PocketBase's
|
||||||
Err(err) => {
|
// realtime system and OAuth2 flow — buffering would hang forever
|
||||||
warn!("Failed to read upstream response: {err}");
|
// since SSE responses never complete.
|
||||||
Response::builder()
|
let body = Body::from_stream(upstream.bytes_stream());
|
||||||
.status(StatusCode::BAD_GATEWAY)
|
response.body(body).unwrap()
|
||||||
.body(Body::from("Failed to read upstream response"))
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("PocketBase proxy error: {err}");
|
warn!("PocketBase proxy error: {err}");
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ pub async fn get_postcode_stats(
|
||||||
numeric_features,
|
numeric_features,
|
||||||
enum_features: enum_features_out,
|
enum_features: enum_features_out,
|
||||||
price_history,
|
price_history,
|
||||||
|
central_postcode: None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
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