Lots of improvements
This commit is contained in:
parent
ef921361ec
commit
80a5a2a774
21 changed files with 489 additions and 337 deletions
|
|
@ -20,13 +20,6 @@ const ACCOUNT_TABS = [
|
||||||
{ key: 'settings', label: 'Settings' },
|
{ key: 'settings', label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SUBSCRIPTION_OPTIONS = ['free', 'licensed'] as const;
|
|
||||||
|
|
||||||
const SUBSCRIPTION_LABELS: Record<string, string> = {
|
|
||||||
free: 'Free',
|
|
||||||
licensed: 'Licensed',
|
|
||||||
};
|
|
||||||
|
|
||||||
function SavedSearchesContent({
|
function SavedSearchesContent({
|
||||||
searches,
|
searches,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -196,10 +189,6 @@ function SettingsContent({
|
||||||
onRefreshAuth: () => Promise<void>;
|
onRefreshAuth: () => Promise<void>;
|
||||||
onRequestVerification: (email: string) => Promise<void>;
|
onRequestVerification: (email: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [selectedSubscription, setSelectedSubscription] = useState(user.subscription || 'free');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [newsletterSaving, setNewsletterSaving] = useState(false);
|
const [newsletterSaving, setNewsletterSaving] = useState(false);
|
||||||
const [newsletterError, setNewsletterError] = useState<string | null>(null);
|
const [newsletterError, setNewsletterError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -207,65 +196,50 @@ function SettingsContent({
|
||||||
const [verificationSending, setVerificationSending] = useState(false);
|
const [verificationSending, setVerificationSending] = useState(false);
|
||||||
const [verificationSent, setVerificationSent] = useState(false);
|
const [verificationSent, setVerificationSent] = useState(false);
|
||||||
|
|
||||||
// Invite state
|
// Invite state — keyed by invite type for admins (who can create both kinds)
|
||||||
const [creatingInvite, setCreatingInvite] = useState(false);
|
const [creatingInvite, setCreatingInvite] = useState<Record<string, boolean>>({});
|
||||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
const [inviteUrl, setInviteUrl] = useState<Record<string, string>>({});
|
||||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
const [inviteError, setInviteError] = useState<Record<string, string>>({});
|
||||||
const [inviteCopied, setInviteCopied] = useState(false);
|
const [inviteCopied, setInviteCopied] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleCreateInvite = async (type: string) => {
|
||||||
setSaving(true);
|
setCreatingInvite((prev) => ({ ...prev, [type]: true }));
|
||||||
setError(null);
|
setInviteError((prev) => {
|
||||||
setSaved(false);
|
const next = { ...prev };
|
||||||
try {
|
delete next[type];
|
||||||
const res = await fetch(apiUrl('subscription'), {
|
return next;
|
||||||
method: 'PATCH',
|
});
|
||||||
...authHeaders({
|
setInviteUrl((prev) => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const next = { ...prev };
|
||||||
body: JSON.stringify({ subscription: selectedSubscription }),
|
delete next[type];
|
||||||
}),
|
return next;
|
||||||
});
|
});
|
||||||
assertOk(res, 'Update subscription');
|
setInviteCopied((prev) => ({ ...prev, [type]: false }));
|
||||||
await onRefreshAuth();
|
|
||||||
setSaved(true);
|
|
||||||
setTimeout(() => setSaved(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to update subscription';
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateInvite = async () => {
|
|
||||||
setCreatingInvite(true);
|
|
||||||
setInviteError(null);
|
|
||||||
setInviteUrl(null);
|
|
||||||
setInviteCopied(false);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(apiUrl('invites'), {
|
const res = await fetch(apiUrl('invites'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
...authHeaders({
|
...authHeaders({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({ invite_type: type }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
assertOk(res, 'Create invite');
|
assertOk(res, 'Create invite');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setInviteUrl(data.url);
|
setInviteUrl((prev) => ({ ...prev, [type]: data.url }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to create invite';
|
const msg = err instanceof Error ? err.message : 'Failed to create invite';
|
||||||
setInviteError(msg);
|
setInviteError((prev) => ({ ...prev, [type]: msg }));
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingInvite(false);
|
setCreatingInvite((prev) => ({ ...prev, [type]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyInvite = () => {
|
const handleCopyInvite = (type: string) => {
|
||||||
if (!inviteUrl) return;
|
const url = inviteUrl[type];
|
||||||
copyToClipboard(inviteUrl, () => {
|
if (!url) return;
|
||||||
setInviteCopied(true);
|
copyToClipboard(url, () => {
|
||||||
setTimeout(() => setInviteCopied(false), 2000);
|
setInviteCopied((prev) => ({ ...prev, [type]: true }));
|
||||||
|
setTimeout(() => setInviteCopied((prev) => ({ ...prev, [type]: false })), 2000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -323,7 +297,7 @@ function SettingsContent({
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
|
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
|
||||||
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
|
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
|
||||||
{SUBSCRIPTION_LABELS[user.subscription] || user.subscription || 'Free'}
|
{user.subscription === 'licensed' ? 'Licensed' : 'Free'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -369,83 +343,48 @@ function SettingsContent({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invite friends */}
|
{/* Invite friends */}
|
||||||
{isLicensed && (
|
{isLicensed &&
|
||||||
<div className="px-5 py-4">
|
(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
|
||||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
<div key={type} className="px-5 py-4">
|
||||||
{user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
|
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||||
</p>
|
{type === 'admin' ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
|
||||||
{inviteUrl ? (
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
{inviteUrl[type] ? (
|
||||||
<input
|
<div className="flex items-center gap-2">
|
||||||
type="text"
|
<input
|
||||||
readOnly
|
type="text"
|
||||||
value={inviteUrl}
|
readOnly
|
||||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
value={inviteUrl[type]}
|
||||||
/>
|
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyInvite(type)}
|
||||||
|
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{inviteCopied[type] ? (
|
||||||
|
<CheckIcon className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ClipboardIcon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{inviteCopied[type] ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyInvite}
|
onClick={() => handleCreateInvite(type)}
|
||||||
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
|
disabled={!!creatingInvite[type]}
|
||||||
|
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{inviteCopied ? (
|
{creatingInvite[type] && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||||
<CheckIcon className="w-4 h-4" />
|
{type === 'admin' ? 'Generate free invite link' : 'Generate referral link'}
|
||||||
) : (
|
|
||||||
<ClipboardIcon className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{inviteCopied ? 'Copied' : 'Copy'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
) : (
|
{inviteError[type] && (
|
||||||
<button
|
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError[type]}</p>
|
||||||
onClick={handleCreateInvite}
|
)}
|
||||||
disabled={creatingInvite}
|
|
||||||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
|
||||||
{user.isAdmin ? 'Generate free invite link' : 'Generate referral link'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{inviteError && (
|
|
||||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Admin section */}
|
|
||||||
{user.isAdmin && (
|
|
||||||
<div className="px-5 py-4">
|
|
||||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
|
||||||
Admin: Change subscription
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<select
|
|
||||||
value={selectedSubscription}
|
|
||||||
onChange={(e) => setSelectedSubscription(e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
|
||||||
>
|
|
||||||
{SUBSCRIPTION_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt} value={opt}>
|
|
||||||
{SUBSCRIPTION_LABELS[opt]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || selectedSubscription === user.subscription}
|
|
||||||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
|
||||||
) : saved ? (
|
|
||||||
<CheckIcon className="w-4 h-4" />
|
|
||||||
) : null}
|
|
||||||
{saved ? 'Saved' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
))}
|
||||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const DATA_SOURCES = [
|
||||||
id: 'ethnicity',
|
id: 'ethnicity',
|
||||||
name: 'Population by Ethnicity (2021 Census)',
|
name: 'Population by Ethnicity (2021 Census)',
|
||||||
origin: 'ONS',
|
origin: 'ONS',
|
||||||
use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
|
use: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
|
||||||
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
|
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
|
||||||
license: 'Open Government Licence v3.0',
|
license: 'Open Government Licence v3.0',
|
||||||
},
|
},
|
||||||
|
|
@ -60,14 +60,6 @@ const DATA_SOURCES = [
|
||||||
url: 'https://data.police.uk/data/',
|
url: 'https://data.police.uk/data/',
|
||||||
license: 'Open Government Licence v3.0',
|
license: 'Open Government Licence v3.0',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'tfl-journey-times',
|
|
||||||
name: 'TfL Journey Times',
|
|
||||||
origin: 'Transport for London',
|
|
||||||
use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.",
|
|
||||||
url: 'https://api-portal.tfl.gov.uk/',
|
|
||||||
license: 'Powered by TfL Open Data',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'osm-pois',
|
id: 'osm-pois',
|
||||||
name: 'OpenStreetMap POIs',
|
name: 'OpenStreetMap POIs',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import type { FeatureMeta } from '../../types';
|
||||||
import { InfoIcon } from './icons';
|
import { InfoIcon } from './icons';
|
||||||
import { getGroupIcon } from '../../lib/group-icons';
|
import { getGroupIcon } from '../../lib/group-icons';
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<string, string> = {
|
||||||
|
historical: 'Historical',
|
||||||
|
buy: 'Buy',
|
||||||
|
rent: 'Rent',
|
||||||
|
};
|
||||||
|
|
||||||
interface FeatureLabelProps {
|
interface FeatureLabelProps {
|
||||||
feature: FeatureMeta;
|
feature: FeatureMeta;
|
||||||
onShowInfo?: (feature: FeatureMeta) => void;
|
onShowInfo?: (feature: FeatureMeta) => void;
|
||||||
|
|
@ -17,6 +23,10 @@ export function FeatureLabel({
|
||||||
}: FeatureLabelProps) {
|
}: FeatureLabelProps) {
|
||||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||||
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
|
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
|
||||||
|
const modeTag =
|
||||||
|
feature.modes && feature.modes.length > 0
|
||||||
|
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -30,6 +40,11 @@ export function FeatureLabel({
|
||||||
>
|
>
|
||||||
{feature.name}
|
{feature.name}
|
||||||
</span>
|
</span>
|
||||||
|
{modeTag && (
|
||||||
|
<span className="shrink-0 text-[10px] leading-none font-medium px-1.5 py-0.5 rounded-full bg-warm-100 dark:bg-warm-800 text-warm-500 dark:text-warm-400 border border-warm-200 dark:border-warm-700">
|
||||||
|
{modeTag}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{feature.detail && onShowInfo && (
|
{feature.detail && onShowInfo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onShowInfo(feature)}
|
onClick={() => onShowInfo(feature)}
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
|
||||||
useClickOutside(popupRef, handleClose);
|
useClickOutside(popupRef, handleClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
|
||||||
<div
|
<div
|
||||||
ref={popupRef}
|
ref={popupRef}
|
||||||
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
|
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
|
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,9 @@ export function useMapData({
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
||||||
|
if (travelParam) {
|
||||||
|
params.set('travel', travelParam);
|
||||||
|
}
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
apiUrl('postcodes', params),
|
apiUrl('postcodes', params),
|
||||||
authHeaders({
|
authHeaders({
|
||||||
|
|
@ -261,7 +264,7 @@ export function useMapData({
|
||||||
|
|
||||||
const vals: number[] = [];
|
const vals: number[] = [];
|
||||||
|
|
||||||
if (usePostcodeView && !isTravelTime) {
|
if (usePostcodeView) {
|
||||||
if (effectivePostcodeData.length === 0) return null;
|
if (effectivePostcodeData.length === 0) return null;
|
||||||
for (const feat of effectivePostcodeData) {
|
for (const feat of effectivePostcodeData) {
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ export function useTheme() {
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
}
|
}
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
|
const color = theme === 'dark' ? '#0a0e1a' : '#fafaf9';
|
||||||
|
document.querySelectorAll('meta[name="theme-color"]').forEach((m) => {
|
||||||
|
m.setAttribute('content', color);
|
||||||
|
});
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ body,
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: #fafaf9;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
background-color: #0a0e1a;
|
background-color: #0a0e1a;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export const STACKED_GROUPS: Record<
|
||||||
{
|
{
|
||||||
label: 'Ethnic composition',
|
label: 'Ethnic composition',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
components: ['% White', '% Asian', '% Black', '% Mixed', '% Other'],
|
components: ['% White', '% South Asian', '% East Asian', '% Black', '% Mixed', '% Other'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -153,8 +153,8 @@ export const STACKED_ENUM_GROUPS: Record<
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Leasehold/Freehold',
|
label: 'Leasehold/Freehold',
|
||||||
feature: 'Leashold/Freehold',
|
feature: 'Leasehold/Freehold',
|
||||||
components: ['Leashold/Freehold'],
|
components: ['Leasehold/Freehold'],
|
||||||
valueOrder: ['Freehold', 'Leasehold'],
|
valueOrder: ['Freehold', 'Leasehold'],
|
||||||
valueColors: ['#3b82f6', '#f59e0b'],
|
valueColors: ['#3b82f6', '#f59e0b'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ export function buildPropertySearchUrls({
|
||||||
const maxBathrooms =
|
const maxBathrooms =
|
||||||
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number' ? bathroomFilter[1] : undefined;
|
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number' ? bathroomFilter[1] : undefined;
|
||||||
|
|
||||||
const tenureFilter = filters['Leashold/Freehold'];
|
const tenureFilter = filters['Leasehold/Freehold'];
|
||||||
const selectedTenures =
|
const selectedTenures =
|
||||||
Array.isArray(tenureFilter) && typeof tenureFilter[0] === 'string'
|
Array.isArray(tenureFilter) && typeof tenureFilter[0] === 'string'
|
||||||
? (tenureFilter as string[])
|
? (tenureFilter as string[])
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
|
||||||
return `${p}${value.toFixed(1)}${s}`;
|
return `${p}${value.toFixed(1)}${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFilterValue(value: number): string {
|
export function formatFilterValue(value: number, raw?: boolean): string {
|
||||||
|
if (raw) return Math.round(value).toString();
|
||||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||||
if (Number.isInteger(value)) return value.toString();
|
if (Number.isInteger(value)) return value.toString();
|
||||||
|
|
|
||||||
1
frontend/src/lib/rightmove-outcodes.json
Normal file
1
frontend/src/lib/rightmove-outcodes.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -112,13 +112,19 @@ export interface PlaceResult {
|
||||||
city?: string;
|
city?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JourneyLeg {
|
||||||
|
mode: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenovationEvent {
|
export interface RenovationEvent {
|
||||||
year: number;
|
year: number;
|
||||||
event: string;
|
event: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Property {
|
export interface Property {
|
||||||
// String fields
|
|
||||||
address?: string;
|
address?: string;
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
property_type?: string;
|
property_type?: string;
|
||||||
|
|
|
||||||
|
|
@ -115,15 +115,17 @@ class PlaceHandler(osmium.SimpleHandler):
|
||||||
self._add(name, place_type, lat, lon, population)
|
self._add(name, place_type, lat, lon, population)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Tube stations only (London Underground)
|
# Railway stations (tube, national rail, DLR, overground, Elizabeth line)
|
||||||
if n.tags.get("railway") == "station":
|
if n.tags.get("railway") == "station":
|
||||||
tags = dict(n.tags)
|
tags = dict(n.tags)
|
||||||
station_tag = tags.get("station", "")
|
station_tag = tags.get("station", "")
|
||||||
network = tags.get("network", "").lower()
|
network = tags.get("network", "").lower()
|
||||||
if station_tag == "subway" or "underground" in network:
|
# Skip tram stops
|
||||||
display_name = _station_display_name(name, tags)
|
if station_tag == "light_rail" or "tramlink" in network or "tram" in network:
|
||||||
self._add(display_name, "station", lat, lon, population)
|
|
||||||
return
|
return
|
||||||
|
display_name = _station_display_name(name, tags)
|
||||||
|
self._add(display_name, "station", lat, lon, population)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
@ -137,7 +139,7 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
pbf_file = args.pbf
|
pbf_file = args.pbf
|
||||||
print("Extracting place nodes: cities + tube stations")
|
print("Extracting place nodes: cities + railway stations")
|
||||||
with tqdm(
|
with tqdm(
|
||||||
unit=" elements",
|
unit=" elements",
|
||||||
unit_scale=True,
|
unit_scale=True,
|
||||||
|
|
|
||||||
74
pipeline/download/rightmove_outcodes.py
Normal file
74
pipeline/download/rightmove_outcodes.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Fetch Rightmove outcode→ID mapping for all outcodes in postcode.parquet."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
|
||||||
|
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_outcode_ids(postcodes_path: Path, output: Path) -> None:
|
||||||
|
df = pl.read_parquet(postcodes_path, columns=["Postcode"])
|
||||||
|
outcodes = sorted(
|
||||||
|
set(df["Postcode"].str.split(" ").list.first().to_list()) - {""}
|
||||||
|
)
|
||||||
|
print(f"Querying Rightmove typeahead for {len(outcodes)} outcodes...")
|
||||||
|
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
missed: list[str] = []
|
||||||
|
client = httpx.Client(timeout=10)
|
||||||
|
|
||||||
|
for i, oc in enumerate(outcodes):
|
||||||
|
try:
|
||||||
|
resp = client.get(TYPEAHEAD_URL, params={"query": oc, "limit": "5"})
|
||||||
|
data = resp.json()
|
||||||
|
found = False
|
||||||
|
for m in data.get("matches", []):
|
||||||
|
if (
|
||||||
|
m["type"] == "OUTCODE"
|
||||||
|
and m["displayName"].upper().replace(" ", "")
|
||||||
|
== oc.upper().replace(" ", "")
|
||||||
|
):
|
||||||
|
mapping[oc] = str(m["id"])
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
missed.append(oc)
|
||||||
|
except Exception as e:
|
||||||
|
missed.append(oc)
|
||||||
|
print(f" Error for {oc}: {e}")
|
||||||
|
|
||||||
|
if (i + 1) % 200 == 0:
|
||||||
|
print(f" {i + 1}/{len(outcodes)} done ({len(mapping)} found)")
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output, "w") as f:
|
||||||
|
json.dump(mapping, f, sort_keys=True)
|
||||||
|
|
||||||
|
print(f"Wrote {output} ({len(mapping)} outcodes, {len(missed)} missed)")
|
||||||
|
if missed:
|
||||||
|
print(f"Missed: {missed}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Fetch Rightmove outcode ID mapping"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--postcodes", type=Path, required=True, help="postcode.parquet path"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output JSON file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
fetch_outcode_ids(args.postcodes, args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -8,6 +8,7 @@ Downloads:
|
||||||
|
|
||||||
Then processes for R5 compatibility:
|
Then processes for R5 compatibility:
|
||||||
- Cleans BODS GTFS (fixes stop_times >72h, feed_info year >2100)
|
- Cleans BODS GTFS (fixes stop_times >72h, feed_info year >2100)
|
||||||
|
- Converts high-frequency metro/tram services to frequency-based GTFS
|
||||||
- Converts TfL TransXChange to GTFS via transxchange2gtfs
|
- Converts TfL TransXChange to GTFS via transxchange2gtfs
|
||||||
- Converts National Rail CIF to GTFS via dtd2mysql (requires MariaDB Docker)
|
- Converts National Rail CIF to GTFS via dtd2mysql (requires MariaDB Docker)
|
||||||
|
|
||||||
|
|
@ -20,12 +21,15 @@ Output directory: property-data/transit/
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import statistics
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
@ -184,6 +188,229 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
||||||
print(f" Saved to {dst}")
|
print(f" Saved to {dst}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_gtfs_time(time_str: str) -> int | None:
|
||||||
|
"""Parse HH:MM:SS to seconds since midnight. Returns None on failure."""
|
||||||
|
time_str = time_str.strip('"')
|
||||||
|
if ":" not in time_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
h, m, s = time_str.split(":")
|
||||||
|
return int(h) * 3600 + int(m) * 60 + int(s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _secs_to_gtfs_time(s: int) -> str:
|
||||||
|
"""Convert seconds since midnight to HH:MM:SS."""
|
||||||
|
h = s // 3600
|
||||||
|
m = (s % 3600) // 60
|
||||||
|
sec = s % 60
|
||||||
|
return f"{h:02d}:{m:02d}:{sec:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def convert_high_freq_to_frequency_based(
|
||||||
|
src: Path, dst: Path, *, max_headway_minutes: int = 15
|
||||||
|
) -> None:
|
||||||
|
"""Convert high-frequency scheduled services to frequency-based GTFS entries.
|
||||||
|
|
||||||
|
Identifies metro (route_type=1) and tram (route_type=0) routes with regular
|
||||||
|
headways under max_headway_minutes, then creates frequencies.txt entries and
|
||||||
|
removes redundant trips. R5's RAPTOR produces smoother percentile results for
|
||||||
|
frequency-based services, matching the "just turn up" reality of high-frequency
|
||||||
|
metro/tram services.
|
||||||
|
"""
|
||||||
|
if dst.exists():
|
||||||
|
print(f"Frequency-converted GTFS already exists: {dst}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Converting high-frequency services to frequency-based...")
|
||||||
|
max_headway_secs = max_headway_minutes * 60
|
||||||
|
|
||||||
|
with zipfile.ZipFile(src, "r") as zin:
|
||||||
|
# Step 1: Find metro/tram route IDs
|
||||||
|
target_route_ids: set[str] = set()
|
||||||
|
with zin.open("routes.txt") as f:
|
||||||
|
header = f.readline().decode("utf-8").strip()
|
||||||
|
cols = header.split(",")
|
||||||
|
route_id_idx = cols.index("route_id")
|
||||||
|
rt_idx = cols.index("route_type")
|
||||||
|
for line in f:
|
||||||
|
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
route_type = parts[rt_idx].strip('"')
|
||||||
|
if route_type in ("0", "1"): # tram, metro/subway
|
||||||
|
target_route_ids.add(parts[route_id_idx].strip('"'))
|
||||||
|
|
||||||
|
if not target_route_ids:
|
||||||
|
print(" No metro/tram routes found, copying unchanged")
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" Found {len(target_route_ids)} metro/tram routes")
|
||||||
|
|
||||||
|
# Step 2: Map target trips to grouping keys
|
||||||
|
trip_group_key: dict[str, tuple[str, str, str]] = {}
|
||||||
|
with zin.open("trips.txt") as f:
|
||||||
|
header = f.readline().decode("utf-8").strip()
|
||||||
|
cols = header.split(",")
|
||||||
|
trip_id_idx = cols.index("trip_id")
|
||||||
|
route_id_idx = cols.index("route_id")
|
||||||
|
dir_idx = cols.index("direction_id") if "direction_id" in cols else -1
|
||||||
|
service_idx = cols.index("service_id")
|
||||||
|
for line in f:
|
||||||
|
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
route_id = parts[route_id_idx].strip('"')
|
||||||
|
if route_id in target_route_ids:
|
||||||
|
trip_id = parts[trip_id_idx].strip('"')
|
||||||
|
direction = parts[dir_idx].strip('"') if dir_idx >= 0 else "0"
|
||||||
|
service_id = parts[service_idx].strip('"')
|
||||||
|
trip_group_key[trip_id] = (route_id, direction, service_id)
|
||||||
|
|
||||||
|
print(f" Found {len(trip_group_key)} trips on target routes")
|
||||||
|
|
||||||
|
# Step 3: Get first departure time and first stop for each target trip
|
||||||
|
trip_first_dep: dict[str, int] = {}
|
||||||
|
trip_first_stop: dict[str, str] = {}
|
||||||
|
with zin.open("stop_times.txt") as f:
|
||||||
|
header = f.readline().decode("utf-8").strip()
|
||||||
|
cols = header.split(",")
|
||||||
|
trip_id_idx = cols.index("trip_id")
|
||||||
|
dep_idx = cols.index("departure_time")
|
||||||
|
seq_idx = cols.index("stop_sequence")
|
||||||
|
stop_id_idx = cols.index("stop_id")
|
||||||
|
for line in f:
|
||||||
|
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
trip_id = parts[trip_id_idx].strip('"')
|
||||||
|
if trip_id not in trip_group_key:
|
||||||
|
continue
|
||||||
|
if parts[seq_idx].strip('"') == "0":
|
||||||
|
dep_secs = _parse_gtfs_time(parts[dep_idx])
|
||||||
|
if dep_secs is not None:
|
||||||
|
trip_first_dep[trip_id] = dep_secs
|
||||||
|
trip_first_stop[trip_id] = parts[stop_id_idx].strip('"')
|
||||||
|
|
||||||
|
# Step 4: Group trips by (route, direction, service, first_stop) and compute headways
|
||||||
|
groups: dict[tuple[str, ...], list[tuple[str, int]]] = defaultdict(list)
|
||||||
|
for trip_id, dep_secs in trip_first_dep.items():
|
||||||
|
route_id, direction, service_id = trip_group_key[trip_id]
|
||||||
|
first_stop = trip_first_stop.get(trip_id, "")
|
||||||
|
key = (route_id, direction, service_id, first_stop)
|
||||||
|
groups[key].append((trip_id, dep_secs))
|
||||||
|
|
||||||
|
trips_to_remove: set[str] = set()
|
||||||
|
frequency_entries: list[tuple[str, int, int, int]] = []
|
||||||
|
groups_converted = 0
|
||||||
|
|
||||||
|
for _key, trips in groups.items():
|
||||||
|
if len(trips) < 4:
|
||||||
|
continue
|
||||||
|
|
||||||
|
trips.sort(key=lambda x: x[1])
|
||||||
|
headways = [trips[i + 1][1] - trips[i][1] for i in range(len(trips) - 1)]
|
||||||
|
headways = [h for h in headways if h > 0]
|
||||||
|
|
||||||
|
if len(headways) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
median_hw = statistics.median(headways)
|
||||||
|
if median_hw > max_headway_secs or median_hw < 30:
|
||||||
|
continue
|
||||||
|
|
||||||
|
mean_hw = statistics.mean(headways)
|
||||||
|
if mean_hw == 0:
|
||||||
|
continue
|
||||||
|
stdev_hw = statistics.stdev(headways) if len(headways) > 1 else 0
|
||||||
|
if stdev_hw / mean_hw > 0.5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert: keep first trip as template, remove the rest
|
||||||
|
template_trip_id = trips[0][0]
|
||||||
|
start_secs = trips[0][1]
|
||||||
|
end_secs = trips[-1][1] + int(median_hw)
|
||||||
|
headway_rounded = max(60, round(median_hw / 60) * 60)
|
||||||
|
|
||||||
|
frequency_entries.append((template_trip_id, start_secs, end_secs, headway_rounded))
|
||||||
|
for trip_id, _ in trips[1:]:
|
||||||
|
trips_to_remove.add(trip_id)
|
||||||
|
groups_converted += 1
|
||||||
|
|
||||||
|
print(f" Converted {groups_converted} trip groups to frequency-based")
|
||||||
|
print(f" Removing {len(trips_to_remove)} redundant trips")
|
||||||
|
print(f" Created {len(frequency_entries)} frequency entries")
|
||||||
|
|
||||||
|
# Step 5: Write modified GTFS
|
||||||
|
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
|
||||||
|
dst, "w", zipfile.ZIP_DEFLATED
|
||||||
|
) as zout:
|
||||||
|
for info in zin.infolist():
|
||||||
|
if info.filename == "trips.txt":
|
||||||
|
with zin.open(info) as f:
|
||||||
|
header = f.readline()
|
||||||
|
header_str = header.decode("utf-8").strip()
|
||||||
|
cols = header_str.split(",")
|
||||||
|
trip_id_idx = cols.index("trip_id")
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
mode="wb", delete=False, suffix=".txt"
|
||||||
|
)
|
||||||
|
tmp.write(header)
|
||||||
|
for line in f:
|
||||||
|
parts = (
|
||||||
|
line.decode("utf-8", errors="replace").strip().split(",")
|
||||||
|
)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
if parts[trip_id_idx].strip('"') not in trips_to_remove:
|
||||||
|
tmp.write(line)
|
||||||
|
tmp.close()
|
||||||
|
zout.write(tmp.name, "trips.txt")
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
|
elif info.filename == "stop_times.txt":
|
||||||
|
with zin.open(info) as f:
|
||||||
|
header = f.readline()
|
||||||
|
header_str = header.decode("utf-8").strip()
|
||||||
|
cols = header_str.split(",")
|
||||||
|
trip_id_idx = cols.index("trip_id")
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
mode="wb", delete=False, suffix=".txt"
|
||||||
|
)
|
||||||
|
tmp.write(header)
|
||||||
|
for line in f:
|
||||||
|
parts = (
|
||||||
|
line.decode("utf-8", errors="replace").strip().split(",")
|
||||||
|
)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
if parts[trip_id_idx].strip('"') not in trips_to_remove:
|
||||||
|
tmp.write(line)
|
||||||
|
tmp.close()
|
||||||
|
zout.write(tmp.name, "stop_times.txt")
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
|
elif info.filename == "frequencies.txt":
|
||||||
|
pass # we'll write our own below
|
||||||
|
|
||||||
|
else:
|
||||||
|
zout.writestr(info, zin.read(info))
|
||||||
|
|
||||||
|
# Write frequencies.txt
|
||||||
|
freq_lines = ["trip_id,start_time,end_time,headway_secs,exact_times\n"]
|
||||||
|
for trip_id, start, end, headway in frequency_entries:
|
||||||
|
freq_lines.append(
|
||||||
|
f"{trip_id},{_secs_to_gtfs_time(start)},{_secs_to_gtfs_time(end)},{headway},0\n"
|
||||||
|
)
|
||||||
|
zout.writestr("frequencies.txt", "".join(freq_lines))
|
||||||
|
|
||||||
|
print(f" Saved to {dst}")
|
||||||
|
|
||||||
|
|
||||||
def download_tfl_transxchange(raw_dir: Path) -> Path:
|
def download_tfl_transxchange(raw_dir: Path) -> Path:
|
||||||
"""Download TfL TransXChange timetable bundle."""
|
"""Download TfL TransXChange timetable bundle."""
|
||||||
dest = raw_dir / "tfl_transxchange.zip"
|
dest = raw_dir / "tfl_transxchange.zip"
|
||||||
|
|
@ -655,12 +882,15 @@ def main() -> None:
|
||||||
raw_dir = output_dir / "raw"
|
raw_dir = output_dir / "raw"
|
||||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 1. Download and clean BODS GTFS
|
# 1. Download, clean, and frequency-convert BODS GTFS
|
||||||
download_osm_pbf(raw_dir)
|
download_osm_pbf(raw_dir)
|
||||||
bods_raw = download_bods_gtfs(raw_dir)
|
bods_raw = download_bods_gtfs(raw_dir)
|
||||||
|
|
||||||
bods_clean = output_dir / "bods_gtfs.zip"
|
bods_cleaned = raw_dir / "bods_gtfs_cleaned.zip"
|
||||||
clean_gtfs(bods_raw, bods_clean)
|
clean_gtfs(bods_raw, bods_cleaned)
|
||||||
|
|
||||||
|
bods_final = output_dir / "bods_gtfs.zip"
|
||||||
|
convert_high_freq_to_frequency_based(bods_cleaned, bods_final)
|
||||||
|
|
||||||
# 2. TfL TransXChange → GTFS
|
# 2. TfL TransXChange → GTFS
|
||||||
if args.skip_tfl:
|
if args.skip_tfl:
|
||||||
|
|
|
||||||
|
|
@ -464,7 +464,7 @@ impl PropertyData {
|
||||||
tracing::info!("Concatenating all data sources");
|
tracing::info!("Concatenating all data sources");
|
||||||
let buy_count = listings_buy.height();
|
let buy_count = listings_buy.height();
|
||||||
let rent_count = listings_rent.height();
|
let rent_count = listings_rent.height();
|
||||||
let mut combined = concat(
|
let combined = concat(
|
||||||
[
|
[
|
||||||
properties_joined.lazy(),
|
properties_joined.lazy(),
|
||||||
listings_buy.lazy(),
|
listings_buy.lazy(),
|
||||||
|
|
@ -495,36 +495,8 @@ impl PropertyData {
|
||||||
let numeric_names = features::all_numeric_feature_names();
|
let numeric_names = features::all_numeric_feature_names();
|
||||||
let enum_names = features::all_enum_feature_names();
|
let enum_names = features::all_enum_feature_names();
|
||||||
|
|
||||||
// Fill in NaN/empty placeholder columns for features that don't exist in all
|
|
||||||
// sources (e.g. Listing date only comes from listings, Estimated current price
|
|
||||||
// only from properties). Without this, diagonal concat leaves them absent.
|
|
||||||
{
|
|
||||||
let schema = combined.schema();
|
|
||||||
let mut fill_exprs: Vec<Expr> = Vec::new();
|
|
||||||
for &name in &numeric_names {
|
|
||||||
if schema.get(name).is_none() {
|
|
||||||
tracing::info!(feature = %name, "Adding NaN placeholder for missing numeric feature");
|
|
||||||
fill_exprs.push(lit(f32::NAN).alias(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for &name in &enum_names {
|
|
||||||
if schema.get(name).is_none() {
|
|
||||||
tracing::info!(feature = %name, "Adding empty placeholder for missing enum feature");
|
|
||||||
fill_exprs.push(lit("").alias(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !fill_exprs.is_empty() {
|
|
||||||
combined = combined
|
|
||||||
.lazy()
|
|
||||||
.with_columns(fill_exprs)
|
|
||||||
.collect()
|
|
||||||
.context("Failed to add placeholder columns for missing features")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let schema = combined.schema();
|
let schema = combined.schema();
|
||||||
|
|
||||||
// Validate: every configured feature exists in combined schema
|
|
||||||
for name in &numeric_names {
|
for name in &numeric_names {
|
||||||
match schema.get(name) {
|
match schema.get(name) {
|
||||||
Some(dtype) if is_numeric_dtype(dtype) => {}
|
Some(dtype) if is_numeric_dtype(dtype) => {}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
},
|
},
|
||||||
step: 10000.0,
|
step: 10000.0,
|
||||||
description: "Inflation-adjusted estimate of the current property value",
|
description: "Inflation-adjusted estimate of the current property value",
|
||||||
detail: "Estimated by applying a repeat-sales price index to the last known sale price, plus a renovation premium for properties with post-sale improvements detected from EPC records (extensions, renovations, remodeling). The index tracks price changes within each postcode sector and property type. Renovation premiums are estimated per area from observed repeat-sale pairs and decay over time. Properties sold recently will have estimates close to their sale price; older sales are adjusted more.",
|
detail: "Estimated by applying a repeat-sales price index to the last known sale price, plus a renovation premium for properties with post-sale improvements detected from EPC records (extensions, renovations, remodelling). The index tracks price changes within each postcode sector and property type. Renovation premiums are estimated per area from observed repeat-sale pairs and decay over time. Properties sold recently will have estimates close to their sale price; older sales are adjusted more.",
|
||||||
source: "price-paid",
|
source: "price-paid",
|
||||||
prefix: "£",
|
prefix: "£",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
@ -259,7 +259,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
},
|
},
|
||||||
step: 50.0,
|
step: 50.0,
|
||||||
description: "Listed monthly rent for properties currently for rent",
|
description: "Listed monthly rent for properties currently for rent",
|
||||||
detail: "The advertised rental price normalized to monthly for properties currently listed for rent on online property portals. Weekly rents are converted (×52/12), yearly (/12), daily (×365.25/12), and quarterly (/3). Only populated for 'For rent' listings.",
|
detail: "The advertised rental price normalised to monthly for properties currently listed for rent on online property portals. Weekly rents are converted (×52/12), yearly (/12), daily (×365.25/12), and quarterly (/3). Only populated for 'For rent' listings.",
|
||||||
source: "online-listings",
|
source: "online-listings",
|
||||||
prefix: "£",
|
prefix: "£",
|
||||||
suffix: "/mo",
|
suffix: "/mo",
|
||||||
|
|
@ -325,82 +325,14 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
name: "Transport",
|
name: "Transport",
|
||||||
features: &[
|
features: &[
|
||||||
FeatureConfig {
|
FeatureConfig {
|
||||||
name: "Public transport to Bank (mins)",
|
name: "Train or tube stations within 1km",
|
||||||
bounds: Bounds::Fixed {
|
|
||||||
min: 0.0,
|
|
||||||
max: 180.0,
|
|
||||||
},
|
|
||||||
step: 2.0,
|
|
||||||
description: "Public transport journey time to Bank station",
|
|
||||||
detail: "Journey time in minutes by public transport to Bank station in the City of London, using TfL's Journey Planner API. Calculated for weekday morning commute times.",
|
|
||||||
source: "tfl-journey-times",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " mins",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
modes: &[],
|
|
||||||
linked: "",
|
|
||||||
},
|
|
||||||
FeatureConfig {
|
|
||||||
name: "Public transport to Fitzrovia (mins)",
|
|
||||||
bounds: Bounds::Fixed {
|
|
||||||
min: 0.0,
|
|
||||||
max: 180.0,
|
|
||||||
},
|
|
||||||
step: 2.0,
|
|
||||||
description: "Public transport journey time to Fitzrovia",
|
|
||||||
detail: "Journey time in minutes by public transport to Fitzrovia in central London, using TfL's Journey Planner API. Calculated for weekday morning commute times.",
|
|
||||||
source: "tfl-journey-times",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " mins",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
modes: &[],
|
|
||||||
linked: "",
|
|
||||||
},
|
|
||||||
FeatureConfig {
|
|
||||||
name: "Cycling to Bank (mins)",
|
|
||||||
bounds: Bounds::Fixed {
|
|
||||||
min: 0.0,
|
|
||||||
max: 180.0,
|
|
||||||
},
|
|
||||||
step: 1.0,
|
|
||||||
description: "Cycling time to Bank station",
|
|
||||||
detail: "Cycling journey time in minutes to Bank station, as calculated by the TfL Journey Planner API. Uses TfL's default cycling speed and route preferences.",
|
|
||||||
source: "tfl-journey-times",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " mins",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
modes: &[],
|
|
||||||
linked: "",
|
|
||||||
},
|
|
||||||
FeatureConfig {
|
|
||||||
name: "Cycling to Fitzrovia (mins)",
|
|
||||||
bounds: Bounds::Fixed {
|
|
||||||
min: 0.0,
|
|
||||||
max: 180.0,
|
|
||||||
},
|
|
||||||
step: 1.0,
|
|
||||||
description: "Cycling time to Fitzrovia",
|
|
||||||
detail: "Cycling journey time in minutes to Fitzrovia, as calculated by the TfL Journey Planner API. Uses TfL's default cycling speed and route preferences.",
|
|
||||||
source: "tfl-journey-times",
|
|
||||||
prefix: "",
|
|
||||||
suffix: " mins",
|
|
||||||
raw: false,
|
|
||||||
absolute: false,
|
|
||||||
modes: &[],
|
|
||||||
linked: "",
|
|
||||||
},
|
|
||||||
FeatureConfig {
|
|
||||||
name: "Number of public transport stations within 2km",
|
|
||||||
bounds: Bounds::Percentile {
|
bounds: Bounds::Percentile {
|
||||||
low: 5.0,
|
low: 5.0,
|
||||||
high: 95.0,
|
high: 95.0,
|
||||||
},
|
},
|
||||||
step: 1.0,
|
step: 1.0,
|
||||||
description: "Number of public transport stops within 2km",
|
description: "Number of train or tube stations within 1km",
|
||||||
detail: "Count of bus stops, rail stations, tube stations, tram stops, and other public transport access points within a 2km radius of the property's postcode. Derived from the NaPTAN (National Public Transport Access Nodes) dataset.",
|
detail: "Count of rail stations and Tube/metro/tram stops within a 1km radius of the property's postcode. Derived from the NaPTAN (National Public Transport Access Nodes) dataset. Does not include bus stops.",
|
||||||
source: "naptan",
|
source: "naptan",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
@ -409,6 +341,23 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
modes: &[],
|
modes: &[],
|
||||||
linked: "",
|
linked: "",
|
||||||
},
|
},
|
||||||
|
FeatureConfig {
|
||||||
|
name: "Distance to nearest train or tube station (km)",
|
||||||
|
bounds: Bounds::Percentile {
|
||||||
|
low: 2.0,
|
||||||
|
high: 98.0,
|
||||||
|
},
|
||||||
|
step: 0.1,
|
||||||
|
description: "Distance to the closest train or tube station",
|
||||||
|
detail: "Straight-line distance in kilometres from the property's postcode centroid to the nearest rail station or Tube/metro/tram stop. Derived from the NaPTAN (National Public Transport Access Nodes) dataset.",
|
||||||
|
source: "naptan",
|
||||||
|
prefix: "",
|
||||||
|
suffix: " km",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
modes: &[],
|
||||||
|
linked: "",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
FeatureGroup {
|
FeatureGroup {
|
||||||
|
|
@ -906,14 +855,31 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
linked: "",
|
linked: "",
|
||||||
},
|
},
|
||||||
FeatureConfig {
|
FeatureConfig {
|
||||||
name: "% Asian",
|
name: "% South Asian",
|
||||||
bounds: Bounds::Fixed {
|
bounds: Bounds::Fixed {
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 100.0,
|
max: 100.0,
|
||||||
},
|
},
|
||||||
step: 1.0,
|
step: 1.0,
|
||||||
description: "Percentage of population identifying as Asian",
|
description: "Percentage of population identifying as South Asian",
|
||||||
detail: "From the 2021 Census. Percentage of the local authority population identifying as Asian or Asian British (Indian, Pakistani, Bangladeshi, Chinese, or any other Asian background).",
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as Indian, Pakistani, Bangladeshi, or any other Asian background.",
|
||||||
|
source: "ethnicity",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
modes: &[],
|
||||||
|
linked: "",
|
||||||
|
},
|
||||||
|
FeatureConfig {
|
||||||
|
name: "% East Asian",
|
||||||
|
bounds: Bounds::Fixed {
|
||||||
|
min: 0.0,
|
||||||
|
max: 100.0,
|
||||||
|
},
|
||||||
|
step: 1.0,
|
||||||
|
description: "Percentage of population identifying as East Asian",
|
||||||
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese.",
|
||||||
source: "ethnicity",
|
source: "ethnicity",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "%",
|
suffix: "%",
|
||||||
|
|
@ -1074,7 +1040,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
|
|
||||||
pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
|
pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
|
||||||
EnumFeatureGroup {
|
EnumFeatureGroup {
|
||||||
name: "Property",
|
name: "Properties in the area",
|
||||||
features: &[
|
features: &[
|
||||||
EnumFeatureConfig {
|
EnumFeatureConfig {
|
||||||
name: "Listing status",
|
name: "Listing status",
|
||||||
|
|
@ -1084,7 +1050,7 @@ pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
|
||||||
source: "online-listings",
|
source: "online-listings",
|
||||||
},
|
},
|
||||||
EnumFeatureConfig {
|
EnumFeatureConfig {
|
||||||
name: "Leashold/Freehold",
|
name: "Leasehold/Freehold",
|
||||||
order: Some(&["Freehold", "Leasehold"]),
|
order: Some(&["Freehold", "Leasehold"]),
|
||||||
description: "Whether the property is leasehold or freehold",
|
description: "Whether the property is leasehold or freehold",
|
||||||
detail: "From HM Land Registry Price Paid data. Freehold means you own the building and the land it stands on. Leasehold means you own the building but not the land — you have a lease from the freeholder for a set number of years.",
|
detail: "From HM Land Registry Price Paid data. Freehold means you own the building and the land it stands on. Leasehold means you own the building but not the land — you have a lease from the freeholder for a set number of years.",
|
||||||
|
|
|
||||||
|
|
@ -417,16 +417,16 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let state_short_url = state.clone();
|
let state_short_url = state.clone();
|
||||||
let state_ai_filters = state.clone();
|
let state_ai_filters = state.clone();
|
||||||
let state_streetview = state.clone();
|
let state_streetview = state.clone();
|
||||||
let state_subscription = state.clone();
|
|
||||||
let state_newsletter = state.clone();
|
let state_newsletter = state.clone();
|
||||||
let state_travel_modes = state.clone();
|
let state_travel_modes = state.clone();
|
||||||
|
let state_travel_destinations = state.clone();
|
||||||
let state_checkout = state.clone();
|
let state_checkout = state.clone();
|
||||||
let state_stripe_webhook = state.clone();
|
let state_stripe_webhook = state.clone();
|
||||||
let state_pricing = state.clone();
|
let state_pricing = state.clone();
|
||||||
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 state_journey = state.clone();
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -461,6 +461,14 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"/api/travel-modes",
|
"/api/travel-modes",
|
||||||
get(move || routes::get_travel_modes(state_travel_modes.clone())),
|
get(move || routes::get_travel_modes(state_travel_modes.clone())),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/travel-destinations",
|
||||||
|
get(move |query| routes::get_travel_destinations(state_travel_destinations.clone(), query)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/journey",
|
||||||
|
get(move |query| routes::get_journey(state_journey.clone(), query)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/hexagon-properties",
|
"/api/hexagon-properties",
|
||||||
get(move |ext, query| {
|
get(move |ext, query| {
|
||||||
|
|
@ -502,16 +510,6 @@ 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(
|
|
||||||
"/api/subscription",
|
|
||||||
patch(move |ext, body| {
|
|
||||||
routes::patch_subscription(state_subscription.clone(), ext, body)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/newsletter",
|
"/api/newsletter",
|
||||||
patch(move |ext, body| {
|
patch(move |ext, body| {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ mod features;
|
||||||
mod hexagon_stats;
|
mod hexagon_stats;
|
||||||
pub(crate) mod hexagons;
|
pub(crate) mod hexagons;
|
||||||
mod invites;
|
mod invites;
|
||||||
|
mod journey;
|
||||||
mod me;
|
mod me;
|
||||||
mod pb_proxy;
|
mod pb_proxy;
|
||||||
mod places;
|
mod places;
|
||||||
|
|
@ -20,10 +21,9 @@ 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 tiles;
|
mod tiles;
|
||||||
pub(crate) mod travel_time;
|
pub(crate) mod travel_time;
|
||||||
|
mod travel_destinations;
|
||||||
mod travel_modes;
|
mod travel_modes;
|
||||||
|
|
||||||
pub use ai_filters::{build_ollama_schema, build_system_prompt, post_ai_filters};
|
pub use ai_filters::{build_ollama_schema, build_system_prompt, post_ai_filters};
|
||||||
|
|
@ -44,10 +44,10 @@ pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
|
||||||
pub use shorten::{get_short_url, post_shorten};
|
pub use shorten::{get_short_url, post_shorten};
|
||||||
pub use streetview::get_streetview;
|
pub use streetview::get_streetview;
|
||||||
pub use invites::{get_invite, post_invites, post_redeem_invite};
|
pub use invites::{get_invite, post_invites, post_redeem_invite};
|
||||||
|
pub use journey::get_journey;
|
||||||
pub use newsletter::patch_newsletter;
|
pub use newsletter::patch_newsletter;
|
||||||
pub use pricing::get_pricing;
|
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 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_destinations::get_travel_destinations;
|
||||||
pub use travel_modes::get_travel_modes;
|
pub use travel_modes::get_travel_modes;
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ pub fn build_system_prompt(features: &FeaturesResponse) -> String {
|
||||||
parts.push(
|
parts.push(
|
||||||
"User: \"cheap freehold house under 400k\"\n\
|
"User: \"cheap freehold house under 400k\"\n\
|
||||||
Output: {\"numeric_filters\": [{\"name\": \"Last known price\", \"bound\": \"max\", \"value\": 400000}], \
|
Output: {\"numeric_filters\": [{\"name\": \"Last known price\", \"bound\": \"max\", \"value\": 400000}], \
|
||||||
\"enum_filters\": [{\"name\": \"Leashold/Freehold\", \"values\": [\"Freehold\"]}, \
|
\"enum_filters\": [{\"name\": \"Leasehold/Freehold\", \"values\": [\"Freehold\"]}, \
|
||||||
{\"name\": \"Property type\", \"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
|
{\"name\": \"Property type\", \"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
|
||||||
\"notes\": \"\"}"
|
\"notes\": \"\"}"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
@ -252,13 +252,13 @@ pub async fn post_ai_filters(
|
||||||
/// ```json
|
/// ```json
|
||||||
/// {
|
/// {
|
||||||
/// "numeric_filters": [{"name": "Last known price", "bound": "max", "value": 300000}],
|
/// "numeric_filters": [{"name": "Last known price", "bound": "max", "value": 300000}],
|
||||||
/// "enum_filters": [{"name": "Leashold/Freehold", "values": ["Freehold"]}]
|
/// "enum_filters": [{"name": "Leasehold/Freehold", "values": ["Freehold"]}]
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// Output format (FeatureFilters):
|
/// Output format (FeatureFilters):
|
||||||
/// ```json
|
/// ```json
|
||||||
/// { "Last known price": [0, 300000], "Leashold/Freehold": ["Freehold"] }
|
/// { "Last known price": [0, 300000], "Leasehold/Freehold": ["Freehold"] }
|
||||||
/// ```
|
/// ```
|
||||||
fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
|
fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
|
||||||
let mut result = serde_json::Map::new();
|
let mut result = serde_json::Map::new();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use crate::parsing::{
|
||||||
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
|
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
|
||||||
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
|
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
|
||||||
};
|
};
|
||||||
use crate::routes::travel_time::TravelTimeAgg;
|
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -40,62 +40,6 @@ pub struct HexagonParams {
|
||||||
travel: Option<String>,
|
travel: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TravelEntry {
|
|
||||||
mode: String,
|
|
||||||
slug: String,
|
|
||||||
use_best: bool,
|
|
||||||
filter_min: Option<f32>,
|
|
||||||
filter_max: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse `travel` param into a list of travel entries.
|
|
||||||
/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
|
|
||||||
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
let mut seen_keys = Vec::new();
|
|
||||||
for segment in travel_str.split('|') {
|
|
||||||
let parts: Vec<&str> = segment.split(':').collect();
|
|
||||||
if parts.len() < 2 {
|
|
||||||
return Err(format!(
|
|
||||||
"each travel entry must be 'mode:slug' or 'mode:slug:min:max', got '{}'",
|
|
||||||
segment
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mode = parts[0].trim().to_string();
|
|
||||||
let slug = parts[1].trim().to_string();
|
|
||||||
|
|
||||||
let use_best = parts.len() >= 3 && parts[2].trim() == "best";
|
|
||||||
let filter_offset = if use_best { 1 } else { 0 };
|
|
||||||
|
|
||||||
let (filter_min, filter_max) = if parts.len() >= 4 + filter_offset {
|
|
||||||
let min: f32 = parts[2 + filter_offset]
|
|
||||||
.trim()
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
|
|
||||||
let max: f32 = parts[3 + filter_offset]
|
|
||||||
.trim()
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
|
|
||||||
(Some(min), Some(max))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let key = format!("{}:{}", mode, slug);
|
|
||||||
if seen_keys.contains(&key) {
|
|
||||||
return Err(format!("duplicate travel entry '{}'", key));
|
|
||||||
}
|
|
||||||
seen_keys.push(key);
|
|
||||||
entries.push(TravelEntry {
|
|
||||||
mode,
|
|
||||||
slug,
|
|
||||||
use_best,
|
|
||||||
filter_min,
|
|
||||||
filter_max,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
|
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue