seems alright
This commit is contained in:
parent
ebe7bbb51d
commit
eac1bd0d13
58 changed files with 23125 additions and 153505 deletions
File diff suppressed because one or more lines are too long
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
|
|
@ -47,6 +47,7 @@
|
||||||
"@typescript-eslint/parser": "^8.59.2",
|
"@typescript-eslint/parser": "^8.59.2",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"babel-loader": "^10.1.1",
|
"babel-loader": "^10.1.1",
|
||||||
|
"compression-webpack-plugin": "^12.0.0",
|
||||||
"copy-webpack-plugin": "^14.0.0",
|
"copy-webpack-plugin": "^14.0.0",
|
||||||
"css-loader": "^7.1.4",
|
"css-loader": "^7.1.4",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
|
|
@ -66,6 +67,7 @@
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
"terser-webpack-plugin": "^5.3.14",
|
||||||
"ts-loader": "^9.5.7",
|
"ts-loader": "^9.5.7",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.5",
|
"vitest": "^4.1.5",
|
||||||
|
|
@ -8169,6 +8171,27 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compression-webpack-plugin": {
|
||||||
|
"version": "12.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-12.0.0.tgz",
|
||||||
|
"integrity": "sha512-LR4mS19Jqq41XfA3xVMLrtzVNzqJbUHdzPeLRfQoLiAS9s87f0021fDuU89xxVQFcB6d20ufBkv4j1rQ4OowHw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"schema-utils": "^4.2.0",
|
||||||
|
"serialize-javascript": "^7.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"webpack": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/compression/node_modules/debug": {
|
"node_modules/compression/node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"@typescript-eslint/parser": "^8.59.2",
|
"@typescript-eslint/parser": "^8.59.2",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"babel-loader": "^10.1.1",
|
"babel-loader": "^10.1.1",
|
||||||
|
"compression-webpack-plugin": "^12.0.0",
|
||||||
"copy-webpack-plugin": "^14.0.0",
|
"copy-webpack-plugin": "^14.0.0",
|
||||||
"css-loader": "^7.1.4",
|
"css-loader": "^7.1.4",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
|
|
@ -73,6 +74,7 @@
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
"terser-webpack-plugin": "^5.3.14",
|
||||||
"ts-loader": "^9.5.7",
|
"ts-loader": "^9.5.7",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.5",
|
"vitest": "^4.1.5",
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,13 @@ export default function App() {
|
||||||
const authCompletedRef = useRef(false);
|
const authCompletedRef = useRef(false);
|
||||||
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
|
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
|
||||||
|
|
||||||
|
// Keep a ref to the latest refreshAuth so the mount-only startup effect always
|
||||||
|
// calls the current implementation without re-running when the callback identity changes.
|
||||||
|
const refreshAuthRef = useRef(refreshAuth);
|
||||||
|
useEffect(() => {
|
||||||
|
refreshAuthRef.current = refreshAuth;
|
||||||
|
}, [refreshAuth]);
|
||||||
|
|
||||||
const openAuthModal = useCallback(
|
const openAuthModal = useCallback(
|
||||||
(
|
(
|
||||||
tab: 'login' | 'register',
|
tab: 'login' | 'register',
|
||||||
|
|
@ -284,14 +291,14 @@ export default function App() {
|
||||||
async function refreshOnStartup() {
|
async function refreshOnStartup() {
|
||||||
if (!returnedFromCheckout) {
|
if (!returnedFromCheckout) {
|
||||||
// Always refresh auth on startup to pick up server-side subscription changes.
|
// Always refresh auth on startup to pick up server-side subscription changes.
|
||||||
refreshAuth().catch(() => {});
|
refreshAuthRef.current().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLicenseSuccessStatus('verifying');
|
setLicenseSuccessStatus('verifying');
|
||||||
for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) {
|
for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const refreshedUser = await refreshAuth();
|
const refreshedUser = await refreshAuthRef.current();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (hasFullAccess(refreshedUser)) {
|
if (hasFullAccess(refreshedUser)) {
|
||||||
trackEvent('Purchase');
|
trackEvent('Purchase');
|
||||||
|
|
@ -314,7 +321,9 @@ export default function App() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
// Mount-only: this is a startup auth refresh / license verification handshake
|
||||||
|
// that must fire exactly once on initial load. refreshAuth is read via ref.
|
||||||
|
}, []);
|
||||||
|
|
||||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
|
|
@ -381,20 +390,17 @@ export default function App() {
|
||||||
[inviteCode]
|
[inviteCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEditSearch = useCallback(
|
const handleEditSearch = useCallback((id: string, name: string, params: string) => {
|
||||||
(id: string, name: string, params: string) => {
|
const search = params.startsWith('?') ? params : `?${params}`;
|
||||||
const search = params.startsWith('?') ? params : `?${params}`;
|
dashboardSearchRef.current = search;
|
||||||
dashboardSearchRef.current = search;
|
const url = `/dashboard${search}`;
|
||||||
const url = `/dashboard${search}`;
|
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
|
||||||
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
|
setMapUrlState(parseUrlState());
|
||||||
setMapUrlState(parseUrlState());
|
setDashboardRouteKey(search);
|
||||||
setDashboardRouteKey(search);
|
setRouteHash('');
|
||||||
setRouteHash('');
|
setActivePage('dashboard');
|
||||||
setActivePage('dashboard');
|
setEditingSearch({ id, name });
|
||||||
setEditingSearch({ id, name });
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCancelEdit = useCallback(() => {
|
const handleCancelEdit = useCallback(() => {
|
||||||
setEditingSearch(null);
|
setEditingSearch(null);
|
||||||
|
|
@ -451,13 +457,25 @@ export default function App() {
|
||||||
activePageRef.current = activePage;
|
activePageRef.current = activePage;
|
||||||
}, [activePage]);
|
}, [activePage]);
|
||||||
|
|
||||||
|
// Refs for the initial history.replaceState seed below — the popstate effect runs
|
||||||
|
// mount-only, but it needs to read the *initial* page/hash/inviteCode values once.
|
||||||
|
const initialPageRef = useRef(activePage);
|
||||||
|
const initialRouteHashRef = useRef(routeHash);
|
||||||
|
const initialInviteCodeRef = useRef(inviteCode);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.history.state?.page) {
|
if (!window.history.state?.page) {
|
||||||
const hash = routeHash || normalizeHash(window.location.hash);
|
const initialActivePage = initialPageRef.current;
|
||||||
|
const hash = initialRouteHashRef.current || normalizeHash(window.location.hash);
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{ page: activePage, hash },
|
{ page: initialActivePage, hash },
|
||||||
'',
|
'',
|
||||||
buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash)
|
buildPageUrl(
|
||||||
|
initialActivePage,
|
||||||
|
initialInviteCodeRef.current ?? undefined,
|
||||||
|
window.location.search,
|
||||||
|
hash
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const handlePopState = (e: PopStateEvent) => {
|
const handlePopState = (e: PopStateEvent) => {
|
||||||
|
|
@ -487,7 +505,10 @@ export default function App() {
|
||||||
};
|
};
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
// Mount-only: registers a single popstate listener for the app lifetime and
|
||||||
|
// seeds initial history state. The handler uses only stable setters and module
|
||||||
|
// functions; initial-render values are read via refs above.
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { fetchSearches } = savedSearches;
|
const { fetchSearches } = savedSearches;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -507,7 +507,7 @@ export default function HomePage({
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
{[row.postcode, row.guides].map((has, j) => {
|
{[row.postcode, row.guides].map((has, j) => {
|
||||||
const statusLabel = has ? 'Yes' : 'No';
|
const statusLabel = has ? t('common.yes') : t('common.no');
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={j}
|
key={j}
|
||||||
|
|
@ -520,11 +520,11 @@ export default function HomePage({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<td
|
<td
|
||||||
aria-label="Yes"
|
aria-label={t('common.yes')}
|
||||||
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30"
|
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">✓</span>
|
<span aria-hidden="true">✓</span>
|
||||||
<span className="sr-only">Yes</span>
|
<span className="sr-only">{t('common.yes')}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ export default function AreaPane({
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatExclusionValue = (exclusion: FilterExclusion, value: number) => {
|
const formatExclusionValue = (exclusion: FilterExclusion, value: number) => {
|
||||||
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.min')}`;
|
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.minute')}`;
|
||||||
return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name)));
|
return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -165,369 +165,217 @@ export default function AreaPane({
|
||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
<IndeterminateProgressBar show={loading && stats != null} />
|
<IndeterminateProgressBar show={loading && stats != null} />
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
||||||
<div className="space-y-3 p-3">
|
<div className="space-y-3 p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
|
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
|
||||||
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
|
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
|
||||||
</h2>
|
</h2>
|
||||||
{loading && (
|
{loading && (
|
||||||
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
|
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||||
|
{t('areaPane.statsFor', {
|
||||||
|
type: isPostcode
|
||||||
|
? t('common.postcode').toLowerCase()
|
||||||
|
: t('common.area').toLowerCase(),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||||
|
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||||
|
{t('common.propertiesPlural')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
|
||||||
|
{t('areaPane.statsBasis')}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!filtersActive}
|
||||||
|
aria-pressed={statsUseFilters && filtersActive}
|
||||||
|
onClick={() => onStatsUseFiltersChange(true)}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||||
|
statsUseFilters && filtersActive
|
||||||
|
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||||
|
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('areaPane.matchingFiltersOption')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={!statsUseFilters || !filtersActive}
|
||||||
|
onClick={() => onStatsUseFiltersChange(false)}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||||
|
!statsUseFilters || !filtersActive
|
||||||
|
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||||
|
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('areaPane.allPropertiesOption')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||||
|
{filtersActive
|
||||||
|
? statsUseFilters
|
||||||
|
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
||||||
|
: t('areaPane.filtersIgnoredForStats')
|
||||||
|
: t('areaPane.noFiltersAffectStats')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFlipToggleCallout && (
|
||||||
|
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
||||||
|
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
{unfilteredCount != null
|
||||||
|
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
|
||||||
|
: t('areaPane.showAllStatsFallback')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatsUseFiltersChange(false)}
|
||||||
|
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
||||||
|
>
|
||||||
|
{t('areaPane.showAllStats')}
|
||||||
|
</button>
|
||||||
|
{filterExclusions.length > 0 && (
|
||||||
|
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
|
||||||
|
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
|
||||||
|
<ol className="mt-1.5 space-y-1.5">
|
||||||
|
{filterExclusions.map((exclusion) => (
|
||||||
|
<li
|
||||||
|
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
|
||||||
|
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
|
||||||
|
>
|
||||||
|
<div className="truncate font-medium">
|
||||||
|
{getExclusionLabel(exclusion)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
|
||||||
|
{getExclusionAdjustment(exclusion)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
)}
|
||||||
{t('areaPane.statsFor', {
|
|
||||||
type: isPostcode
|
|
||||||
? t('common.postcode').toLowerCase()
|
|
||||||
: t('common.area').toLowerCase(),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-right">
|
|
||||||
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
|
||||||
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
|
||||||
{t('common.propertiesPlural')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
{hexagonLocation && stats && (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
||||||
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
|
)}
|
||||||
{t('areaPane.statsBasis')}
|
{(() => {
|
||||||
</span>
|
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
|
||||||
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
|
||||||
<button
|
<JourneyInstructions
|
||||||
type="button"
|
postcode={journeyPostcode}
|
||||||
disabled={!filtersActive}
|
entries={travelTimeEntries}
|
||||||
aria-pressed={statsUseFilters && filtersActive}
|
label={!isPostcode ? journeyPostcode : undefined}
|
||||||
onClick={() => onStatsUseFiltersChange(true)}
|
shareCode={shareCode}
|
||||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
/>
|
||||||
statsUseFilters && filtersActive
|
) : null;
|
||||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
})()}
|
||||||
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
|
{loading && !stats ? (
|
||||||
}`}
|
<LoadingSkeleton />
|
||||||
>
|
) : stats ? (
|
||||||
{t('areaPane.matchingFiltersOption')}
|
<div>
|
||||||
</button>
|
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||||
<button
|
{stats.count > 0 && <HistogramLegend />}
|
||||||
type="button"
|
{stats.price_history &&
|
||||||
aria-pressed={!statsUseFilters || !filtersActive}
|
(() => {
|
||||||
onClick={() => onStatsUseFiltersChange(false)}
|
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
return uniqueYears.size > 1;
|
||||||
!statsUseFilters || !filtersActive
|
})() && (
|
||||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||||
}`}
|
{t('areaPane.priceHistory')}
|
||||||
>
|
</span>
|
||||||
{t('areaPane.allPropertiesOption')}
|
<PriceHistoryChart points={stats.price_history} />
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
|
||||||
{filtersActive
|
|
||||||
? statsUseFilters
|
|
||||||
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
|
||||||
: t('areaPane.filtersIgnoredForStats')
|
|
||||||
: t('areaPane.noFiltersAffectStats')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFlipToggleCallout && (
|
|
||||||
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
|
||||||
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
{unfilteredCount != null
|
|
||||||
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
|
|
||||||
: t('areaPane.showAllStatsFallback')}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onStatsUseFiltersChange(false)}
|
|
||||||
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
|
||||||
>
|
|
||||||
{t('areaPane.showAllStats')}
|
|
||||||
</button>
|
|
||||||
{filterExclusions.length > 0 && (
|
|
||||||
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
|
|
||||||
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
|
|
||||||
<ol className="mt-1.5 space-y-1.5">
|
|
||||||
{filterExclusions.map((exclusion) => (
|
|
||||||
<li
|
|
||||||
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
|
|
||||||
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
|
|
||||||
>
|
|
||||||
<div className="truncate font-medium">{getExclusionLabel(exclusion)}</div>
|
|
||||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
|
|
||||||
{getExclusionAdjustment(exclusion)}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{featureGroups.map((group) => {
|
||||||
)}
|
const hasData = group.features.some(
|
||||||
</div>
|
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||||
</div>
|
);
|
||||||
|
if (!hasData) return null;
|
||||||
|
|
||||||
{hexagonLocation && stats && (
|
const stackedCharts = STACKED_GROUPS[group.name];
|
||||||
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
|
||||||
)}
|
|
||||||
{(() => {
|
|
||||||
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
|
|
||||||
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
|
|
||||||
<JourneyInstructions
|
|
||||||
postcode={journeyPostcode}
|
|
||||||
entries={travelTimeEntries}
|
|
||||||
label={!isPostcode ? journeyPostcode : undefined}
|
|
||||||
shareCode={shareCode}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
{loading && !stats ? (
|
|
||||||
<LoadingSkeleton />
|
|
||||||
) : stats ? (
|
|
||||||
<div>
|
|
||||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
|
||||||
{stats.count > 0 && <HistogramLegend />}
|
|
||||||
{stats.price_history &&
|
|
||||||
(() => {
|
|
||||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
|
||||||
return uniqueYears.size > 1;
|
|
||||||
})() && (
|
|
||||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
|
||||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
|
||||||
{t('areaPane.priceHistory')}
|
|
||||||
</span>
|
|
||||||
<PriceHistoryChart points={stats.price_history} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{featureGroups.map((group) => {
|
|
||||||
const hasData = group.features.some(
|
|
||||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
|
||||||
);
|
|
||||||
if (!hasData) return null;
|
|
||||||
|
|
||||||
const stackedCharts = STACKED_GROUPS[group.name];
|
const stackedEnumFeatureNames = new Set<string>(
|
||||||
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
|
stackedEnumCharts?.flatMap((c) =>
|
||||||
|
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
const stackedEnumFeatureNames = new Set<string>(
|
const expanded = isGroupExpanded(group.name);
|
||||||
stackedEnumCharts?.flatMap((c) =>
|
|
||||||
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
|
|
||||||
) ?? []
|
|
||||||
);
|
|
||||||
|
|
||||||
const expanded = isGroupExpanded(group.name);
|
return (
|
||||||
|
<div key={group.name}>
|
||||||
return (
|
<CollapsibleGroupHeader
|
||||||
<div key={group.name}>
|
name={group.name}
|
||||||
<CollapsibleGroupHeader
|
expanded={expanded}
|
||||||
name={group.name}
|
onToggle={() => onToggleGroup(group.name)}
|
||||||
expanded={expanded}
|
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||||
onToggle={() => onToggleGroup(group.name)}
|
/>
|
||||||
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
{expanded && (
|
||||||
/>
|
<div className="px-3 py-2 space-y-3">
|
||||||
{expanded && (
|
{stackedCharts?.map((chart) => {
|
||||||
<div className="px-3 py-2 space-y-3">
|
const segments = chart.components
|
||||||
{stackedCharts?.map((chart) => {
|
.map((name) => ({
|
||||||
const segments = chart.components
|
name,
|
||||||
.map((name) => ({
|
value: numericByName.get(name)?.mean ?? 0,
|
||||||
name,
|
}))
|
||||||
value: numericByName.get(name)?.mean ?? 0,
|
|
||||||
}))
|
|
||||||
.filter((s) => s.value > 0);
|
|
||||||
|
|
||||||
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
|
||||||
const displaySegments = isPercentageComposition
|
|
||||||
? normalizePercentageSegments(segments)
|
|
||||||
: segments;
|
|
||||||
|
|
||||||
const aggregateStats = chart.feature
|
|
||||||
? numericByName.get(chart.feature)
|
|
||||||
: undefined;
|
|
||||||
const total = aggregateStats
|
|
||||||
? aggregateStats.mean
|
|
||||||
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
|
||||||
|
|
||||||
// Use rateFeature (e.g. per-1k) for display if available
|
|
||||||
const rateStats = chart.rateFeature
|
|
||||||
? numericByName.get(chart.rateFeature)
|
|
||||||
: undefined;
|
|
||||||
const displayValue = isPercentageComposition
|
|
||||||
? 100
|
|
||||||
: rateStats
|
|
||||||
? rateStats.mean
|
|
||||||
: total;
|
|
||||||
|
|
||||||
// Use rateFeature for info popup and national average when available
|
|
||||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
|
||||||
const featureMeta = infoFeatureName
|
|
||||||
? globalFeatureByName.get(infoFeatureName)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const globalMean = featureMeta?.histogram
|
|
||||||
? calculateHistogramMean(featureMeta.histogram)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (total === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={ts(chart.label)}
|
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-baseline mb-1.5">
|
|
||||||
{featureMeta ? (
|
|
||||||
<FeatureLabel
|
|
||||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
|
||||||
onShowInfo={setInfoFeature}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
|
||||||
{ts(chart.label)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
|
||||||
{formatValue(displayValue)}
|
|
||||||
{chart.unit ? ` ${chart.unit}` : ''}
|
|
||||||
</span>
|
|
||||||
{globalMean != null && (
|
|
||||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
|
||||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StackedBarChart
|
|
||||||
segments={displaySegments}
|
|
||||||
total={total}
|
|
||||||
colorMap={
|
|
||||||
chart.label === 'Political vote share'
|
|
||||||
? PARTY_FEATURE_COLORS
|
|
||||||
: STACKED_SEGMENT_COLORS
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(() => {
|
|
||||||
const stackedFeatureNames = new Set<string>(
|
|
||||||
stackedCharts?.flatMap((c) =>
|
|
||||||
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
|
|
||||||
Boolean(s)
|
|
||||||
)
|
|
||||||
) ?? []
|
|
||||||
);
|
|
||||||
return group.features
|
|
||||||
.filter(
|
|
||||||
(f) =>
|
|
||||||
!stackedFeatureNames.has(f.name) &&
|
|
||||||
!stackedEnumFeatureNames.has(f.name)
|
|
||||||
)
|
|
||||||
.map((feature) => {
|
|
||||||
const numericStats = numericByName.get(feature.name);
|
|
||||||
const enumStats = enumByName.get(feature.name);
|
|
||||||
|
|
||||||
if (numericStats) {
|
|
||||||
const globalFeature = globalFeatureByName.get(feature.name);
|
|
||||||
const globalHistogram = globalFeature?.histogram;
|
|
||||||
const globalMean = globalHistogram
|
|
||||||
? calculateHistogramMean(globalHistogram)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feature.name}
|
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-baseline">
|
|
||||||
<FeatureLabel
|
|
||||||
feature={feature}
|
|
||||||
onShowInfo={setInfoFeature}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
|
||||||
{formatValue(numericStats.mean, feature)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{numericStats.histogram &&
|
|
||||||
(globalHistogram ? (
|
|
||||||
<DualHistogram
|
|
||||||
localCounts={numericStats.histogram.counts}
|
|
||||||
globalCounts={globalHistogram.counts}
|
|
||||||
p1={numericStats.histogram.p1}
|
|
||||||
p99={numericStats.histogram.p99}
|
|
||||||
globalMean={globalMean}
|
|
||||||
meanLabel={t('areaPane.nationalAvg')}
|
|
||||||
formatLabel={(v) =>
|
|
||||||
formatFilterValue(
|
|
||||||
v,
|
|
||||||
feature.suffix === '%'
|
|
||||||
? { raw: feature.raw, suffix: feature.suffix }
|
|
||||||
: feature.raw
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DualHistogram
|
|
||||||
localCounts={numericStats.histogram.counts}
|
|
||||||
globalCounts={numericStats.histogram.counts}
|
|
||||||
p1={numericStats.histogram.p1}
|
|
||||||
p99={numericStats.histogram.p99}
|
|
||||||
formatLabel={(v) =>
|
|
||||||
formatFilterValue(
|
|
||||||
v,
|
|
||||||
feature.suffix === '%'
|
|
||||||
? { raw: feature.raw, suffix: feature.suffix }
|
|
||||||
: feature.raw
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enumStats) {
|
|
||||||
const globalFeature = globalFeatureByName.get(feature.name);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feature.name}
|
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
||||||
>
|
|
||||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
|
||||||
<EnumBarChart
|
|
||||||
counts={enumStats.counts}
|
|
||||||
globalCounts={globalFeature?.counts}
|
|
||||||
featureName={feature.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
{stackedEnumCharts?.map((chart) => {
|
|
||||||
const featureMeta = chart.feature
|
|
||||||
? globalFeatureByName.get(chart.feature)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (chart.components.length === 1) {
|
|
||||||
const stats = enumByName.get(chart.components[0]);
|
|
||||||
if (!stats) return null;
|
|
||||||
|
|
||||||
const segments = chart.valueOrder
|
|
||||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
|
||||||
.filter((s) => s.value > 0);
|
.filter((s) => s.value > 0);
|
||||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
|
||||||
|
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
||||||
|
const displaySegments = isPercentageComposition
|
||||||
|
? normalizePercentageSegments(segments)
|
||||||
|
: segments;
|
||||||
|
|
||||||
|
const aggregateStats = chart.feature
|
||||||
|
? numericByName.get(chart.feature)
|
||||||
|
: undefined;
|
||||||
|
const total = aggregateStats
|
||||||
|
? aggregateStats.mean
|
||||||
|
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
||||||
|
|
||||||
|
// Use rateFeature (e.g. per-1k) for display if available
|
||||||
|
const rateStats = chart.rateFeature
|
||||||
|
? numericByName.get(chart.rateFeature)
|
||||||
|
: undefined;
|
||||||
|
const displayValue = isPercentageComposition
|
||||||
|
? 100
|
||||||
|
: rateStats
|
||||||
|
? rateStats.mean
|
||||||
|
: total;
|
||||||
|
|
||||||
|
// Use rateFeature for info popup and national average when available
|
||||||
|
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||||
|
const featureMeta = infoFeatureName
|
||||||
|
? globalFeatureByName.get(infoFeatureName)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const globalMean = featureMeta?.histogram
|
||||||
|
? calculateHistogramMean(featureMeta.histogram)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -538,7 +386,7 @@ export default function AreaPane({
|
||||||
<div className="flex justify-between items-baseline mb-1.5">
|
<div className="flex justify-between items-baseline mb-1.5">
|
||||||
{featureMeta ? (
|
{featureMeta ? (
|
||||||
<FeatureLabel
|
<FeatureLabel
|
||||||
feature={featureMeta}
|
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||||
onShowInfo={setInfoFeature}
|
onShowInfo={setInfoFeature}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
|
|
@ -547,62 +395,216 @@ export default function AreaPane({
|
||||||
{ts(chart.label)}
|
{ts(chart.label)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
<div className="text-right shrink-0">
|
||||||
{total.toLocaleString()}
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
</span>
|
{formatValue(displayValue)}
|
||||||
|
{chart.unit ? ` ${chart.unit}` : ''}
|
||||||
|
</span>
|
||||||
|
{globalMean != null && (
|
||||||
|
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||||
|
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StackedBarChart
|
<StackedBarChart
|
||||||
segments={segments}
|
segments={displaySegments}
|
||||||
total={total}
|
total={total}
|
||||||
colorMap={Object.fromEntries(
|
colorMap={
|
||||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
chart.label === 'Political vote share'
|
||||||
)}
|
? PARTY_FEATURE_COLORS
|
||||||
|
: STACKED_SEGMENT_COLORS
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
|
{(() => {
|
||||||
|
const stackedFeatureNames = new Set<string>(
|
||||||
|
stackedCharts?.flatMap((c) =>
|
||||||
|
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
|
||||||
|
Boolean(s)
|
||||||
|
)
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
return group.features
|
||||||
|
.filter(
|
||||||
|
(f) =>
|
||||||
|
!stackedFeatureNames.has(f.name) &&
|
||||||
|
!stackedEnumFeatureNames.has(f.name)
|
||||||
|
)
|
||||||
|
.map((feature) => {
|
||||||
|
const numericStats = numericByName.get(feature.name);
|
||||||
|
const enumStats = enumByName.get(feature.name);
|
||||||
|
|
||||||
const components = chart.components
|
if (numericStats) {
|
||||||
.map((name) => {
|
const globalFeature = globalFeatureByName.get(feature.name);
|
||||||
const stats = enumByName.get(name);
|
const globalHistogram = globalFeature?.histogram;
|
||||||
return stats ? { label: name, stats } : null;
|
const globalMean = globalHistogram
|
||||||
})
|
? calculateHistogramMean(globalHistogram)
|
||||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
: undefined;
|
||||||
|
|
||||||
if (components.length === 0) return null;
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.name}
|
||||||
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<FeatureLabel
|
||||||
|
feature={feature}
|
||||||
|
onShowInfo={setInfoFeature}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
|
{formatValue(numericStats.mean, feature)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{numericStats.histogram &&
|
||||||
|
(globalHistogram ? (
|
||||||
|
<DualHistogram
|
||||||
|
localCounts={numericStats.histogram.counts}
|
||||||
|
globalCounts={globalHistogram.counts}
|
||||||
|
p1={numericStats.histogram.p1}
|
||||||
|
p99={numericStats.histogram.p99}
|
||||||
|
globalMean={globalMean}
|
||||||
|
meanLabel={t('areaPane.nationalAvg')}
|
||||||
|
formatLabel={(v) =>
|
||||||
|
formatFilterValue(
|
||||||
|
v,
|
||||||
|
feature.suffix === '%'
|
||||||
|
? { raw: feature.raw, suffix: feature.suffix }
|
||||||
|
: feature.raw
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DualHistogram
|
||||||
|
localCounts={numericStats.histogram.counts}
|
||||||
|
globalCounts={numericStats.histogram.counts}
|
||||||
|
p1={numericStats.histogram.p1}
|
||||||
|
p99={numericStats.histogram.p99}
|
||||||
|
formatLabel={(v) =>
|
||||||
|
formatFilterValue(
|
||||||
|
v,
|
||||||
|
feature.suffix === '%'
|
||||||
|
? { raw: feature.raw, suffix: feature.suffix }
|
||||||
|
: feature.raw
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (enumStats) {
|
||||||
<div
|
const globalFeature = globalFeatureByName.get(feature.name);
|
||||||
key={ts(chart.label)}
|
return (
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
<div
|
||||||
>
|
key={feature.name}
|
||||||
<div className="mb-1.5">
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
{featureMeta ? (
|
>
|
||||||
<FeatureLabel
|
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
<EnumBarChart
|
||||||
onShowInfo={setInfoFeature}
|
counts={enumStats.counts}
|
||||||
|
globalCounts={globalFeature?.counts}
|
||||||
|
featureName={feature.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
{stackedEnumCharts?.map((chart) => {
|
||||||
|
const featureMeta = chart.feature
|
||||||
|
? globalFeatureByName.get(chart.feature)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (chart.components.length === 1) {
|
||||||
|
const stats = enumByName.get(chart.components[0]);
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const segments = chart.valueOrder
|
||||||
|
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||||
|
.filter((s) => s.value > 0);
|
||||||
|
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||||
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ts(chart.label)}
|
||||||
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-baseline mb-1.5">
|
||||||
|
{featureMeta ? (
|
||||||
|
<FeatureLabel
|
||||||
|
feature={featureMeta}
|
||||||
|
onShowInfo={setInfoFeature}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||||
|
{ts(chart.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<StackedBarChart
|
||||||
|
segments={segments}
|
||||||
|
total={total}
|
||||||
|
colorMap={Object.fromEntries(
|
||||||
|
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
);
|
||||||
{ts(chart.label)}
|
}
|
||||||
</span>
|
|
||||||
)}
|
const components = chart.components
|
||||||
|
.map((name) => {
|
||||||
|
const stats = enumByName.get(name);
|
||||||
|
return stats ? { label: name, stats } : null;
|
||||||
|
})
|
||||||
|
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||||
|
|
||||||
|
if (components.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ts(chart.label)}
|
||||||
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
|
>
|
||||||
|
<div className="mb-1.5">
|
||||||
|
{featureMeta ? (
|
||||||
|
<FeatureLabel
|
||||||
|
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||||
|
onShowInfo={setInfoFeature}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||||
|
{ts(chart.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StackedEnumChart
|
||||||
|
components={components}
|
||||||
|
valueOrder={chart.valueOrder}
|
||||||
|
valueColors={chart.valueColors}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StackedEnumChart
|
);
|
||||||
components={components}
|
})}
|
||||||
valueOrder={chart.valueOrder}
|
</div>
|
||||||
valueColors={chart.valueColors}
|
)}
|
||||||
/>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,7 @@ export default function FeatureBrowser({
|
||||||
|
|
||||||
const showTravelModes =
|
const showTravelModes =
|
||||||
visibleModes.length > 0 &&
|
visibleModes.length > 0 &&
|
||||||
(!search ||
|
(!search || t('filters.travelTimeKeywords').toLowerCase().includes(search.toLowerCase()));
|
||||||
'travel time journey commute car bicycle walking transit transport station tube train'.includes(
|
|
||||||
search.toLowerCase()
|
|
||||||
));
|
|
||||||
|
|
||||||
// Keep "Transport" first because journey and transport proximity controls belong together.
|
// Keep "Transport" first because journey and transport proximity controls belong together.
|
||||||
const mergedGrouped = useMemo(() => {
|
const mergedGrouped = useMemo(() => {
|
||||||
|
|
@ -123,7 +120,7 @@ export default function FeatureBrowser({
|
||||||
name={group.name}
|
name={group.name}
|
||||||
expanded={isExpanded}
|
expanded={isExpanded}
|
||||||
onToggle={() => toggleGroup(group.name)}
|
onToggle={() => toggleGroup(group.name)}
|
||||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||||
{group.features.length +
|
{group.features.length +
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ vi.mock('react-i18next', () => ({
|
||||||
if (key === 'areaPane.to') return `To ${values?.destination}`;
|
if (key === 'areaPane.to') return `To ${values?.destination}`;
|
||||||
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
|
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
|
||||||
if (key === 'common.min') return 'min';
|
if (key === 'common.min') return 'min';
|
||||||
|
if (key === 'common.minute') return 'min';
|
||||||
if (key === 'common.loading') return 'Loading';
|
if (key === 'common.loading') return 'Loading';
|
||||||
if (key === 'travel.bestCase') return 'Best case';
|
if (key === 'travel.bestCase') return 'Best case';
|
||||||
if (key === 'areaPane.walk') return 'Walk';
|
if (key === 'areaPane.walk') return 'Walk';
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||||
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes}{' '}
|
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes}{' '}
|
||||||
{t('common.min')}
|
{t('common.minute')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -191,7 +191,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<RouteBadge mode={leg.mode} />
|
<RouteBadge mode={leg.mode} />
|
||||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||||
{leg.minutes} {t('common.min')}
|
{leg.minutes} {t('common.minute')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{leg.from && leg.to && (
|
{leg.from && leg.to && (
|
||||||
|
|
@ -333,7 +333,7 @@ export default function JourneyInstructions({
|
||||||
{!j.loading && totalMin > 0 && (
|
{!j.loading && totalMin > 0 && (
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||||
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
|
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
|
||||||
{totalMin} {t('common.min')}
|
{totalMin} {t('common.minute')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -381,7 +381,7 @@ export default function JourneyInstructions({
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||||
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
|
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
|
||||||
{t('common.min')}
|
{t('common.minute')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{showGoogleMapsLink && (
|
{showGoogleMapsLink && (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
|
@ -85,10 +86,10 @@ function formatListingPrice(price: number): string {
|
||||||
return `£${price.toLocaleString()}`;
|
return `£${price.toLocaleString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatListingHeadline(listing: ActualListing): string | null {
|
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (listing.bedrooms != null) parts.push(`${listing.bedrooms} bed`);
|
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
||||||
if (listing.bathrooms != null) parts.push(`${listing.bathrooms} bath`);
|
if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
|
||||||
if (listing.property_sub_type) parts.push(listing.property_sub_type);
|
if (listing.property_sub_type) parts.push(listing.property_sub_type);
|
||||||
else if (listing.property_type) parts.push(listing.property_type);
|
else if (listing.property_type) parts.push(listing.property_type);
|
||||||
return parts.length > 0 ? parts.join(' · ') : null;
|
return parts.length > 0 ? parts.join(' · ') : null;
|
||||||
|
|
@ -730,9 +731,9 @@ export default memo(function Map({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{formatListingHeadline(listingPopup.listing) && (
|
{formatListingHeadline(listingPopup.listing, t) && (
|
||||||
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
|
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
|
||||||
{formatListingHeadline(listingPopup.listing)}
|
{formatListingHeadline(listingPopup.listing, t)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{listingPopup.listing.address && (
|
{listingPopup.listing.address && (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { SearchedLocation } from './LocationSearch';
|
||||||
import { useMapData } from '../../hooks/useMapData';
|
import { useMapData } from '../../hooks/useMapData';
|
||||||
import { usePOIData } from '../../hooks/usePOIData';
|
import { usePOIData } from '../../hooks/usePOIData';
|
||||||
import { useActualListings } from '../../hooks/useActualListings';
|
import { useActualListings } from '../../hooks/useActualListings';
|
||||||
|
import { buildTravelParam } from '../../lib/travel-params';
|
||||||
import { useFilters } from '../../hooks/useFilters';
|
import { useFilters } from '../../hooks/useFilters';
|
||||||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||||
|
|
@ -15,7 +16,7 @@ import { useUrlSync } from '../../hooks/useUrlSync';
|
||||||
import { useTutorial } from '../../hooks/useTutorial';
|
import { useTutorial } from '../../hooks/useTutorial';
|
||||||
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
||||||
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
|
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
|
||||||
import { apiUrl, authHeaders } from '../../lib/api';
|
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||||
|
|
@ -408,10 +409,15 @@ export default function MapPage({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||||
const { listings: actualListings } = useActualListings(
|
const actualListingsFilterParam = useMemo(
|
||||||
mapData.bounds,
|
() => buildFilterString(filters, features),
|
||||||
mapData.currentView?.zoom ?? 0
|
[filters, features]
|
||||||
);
|
);
|
||||||
|
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
||||||
|
const { listings: actualListings } = useActualListings(mapData.bounds, {
|
||||||
|
filterParam: actualListingsFilterParam,
|
||||||
|
travelParam: actualListingsTravelParam,
|
||||||
|
});
|
||||||
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
||||||
|
|
||||||
useUrlSync(
|
useUrlSync(
|
||||||
|
|
@ -464,11 +470,7 @@ export default function MapPage({
|
||||||
mapData.resolution,
|
mapData.resolution,
|
||||||
areaStats
|
areaStats
|
||||||
);
|
);
|
||||||
const tutorial = useTutorial(
|
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
||||||
initialLoading,
|
|
||||||
isMobile,
|
|
||||||
deferTutorial || mapData.licenseRequired
|
|
||||||
);
|
|
||||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||||
const densityLabel = t('mapLegend.historicalMatches');
|
const densityLabel = t('mapLegend.historicalMatches');
|
||||||
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
||||||
|
|
@ -499,15 +501,7 @@ export default function MapPage({
|
||||||
entries,
|
entries,
|
||||||
shareCode
|
shareCode
|
||||||
).toString(),
|
).toString(),
|
||||||
[
|
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
|
||||||
entries,
|
|
||||||
features,
|
|
||||||
filters,
|
|
||||||
rightPaneTab,
|
|
||||||
selectedPOICategories,
|
|
||||||
shareCode,
|
|
||||||
shareAndSaveView,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
const handleSaveSearch = useCallback(
|
const handleSaveSearch = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
|
|
@ -652,11 +646,7 @@ export default function MapPage({
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportToast = (
|
const exportToast = (
|
||||||
<ExportToast
|
<ExportToast notice={exportNotice} closeLabel={t('common.close')} onClose={clearExportNotice} />
|
||||||
notice={exportNotice}
|
|
||||||
closeLabel={t('common.close')}
|
|
||||||
onClose={clearExportNotice}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
const toasts = exportToast;
|
const toasts = exportToast;
|
||||||
|
|
||||||
|
|
@ -671,9 +661,7 @@ export default function MapPage({
|
||||||
i18nKey="savedPage.isBeingUpdated"
|
i18nKey="savedPage.isBeingUpdated"
|
||||||
values={{ name: editingSearch.name }}
|
values={{ name: editingSearch.name }}
|
||||||
components={{
|
components={{
|
||||||
strong: (
|
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
|
||||||
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -61,64 +61,67 @@ export function PropertiesPane({
|
||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
<IndeterminateProgressBar show={loading && properties.length > 0} />
|
<IndeterminateProgressBar show={loading && properties.length > 0} />
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
<InfoPopup
|
<InfoPopup
|
||||||
title={t('propertyCard.propertyData')}
|
title={t('propertyCard.propertyData')}
|
||||||
onClose={() => setShowInfo(false)}
|
onClose={() => setShowInfo(false)}
|
||||||
sourceLink={
|
sourceLink={
|
||||||
onNavigateToSource
|
onNavigateToSource
|
||||||
? {
|
? {
|
||||||
label: t('common.viewDataSource'),
|
label: t('common.viewDataSource'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
onNavigateToSource('epc');
|
onNavigateToSource('epc');
|
||||||
setShowInfo(false);
|
setShowInfo(false);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||||
{t('propertyCard.propertyDataDesc')}
|
{t('propertyCard.propertyDataDesc')}
|
||||||
</p>
|
</p>
|
||||||
</InfoPopup>
|
</InfoPopup>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="p-2">
|
|
||||||
<SearchInput
|
|
||||||
value={search}
|
|
||||||
onChange={setSearch}
|
|
||||||
placeholder={t('propertyCard.searchPlaceholder')}
|
|
||||||
className="p-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{loading && properties.length === 0 ? (
|
|
||||||
<PropertyLoadingSkeleton />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{filtered.map((property, idx) => (
|
|
||||||
<PropertyCard key={idx} property={property} />
|
|
||||||
))}
|
|
||||||
{properties.length < total && (
|
|
||||||
<button
|
|
||||||
onClick={onLoadMore}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<span className="flex items-center justify-center gap-2">
|
|
||||||
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
|
||||||
{t('common.loading')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<div className="p-2">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder={t('propertyCard.searchPlaceholder')}
|
||||||
|
className="p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{loading && properties.length === 0 ? (
|
||||||
|
<PropertyLoadingSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filtered.map((property) => (
|
||||||
|
<PropertyCard
|
||||||
|
key={`${property.lat},${property.lon}|${property.postcode ?? ''}|${property.address ?? ''}`}
|
||||||
|
property={property}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{properties.length < total && (
|
||||||
|
<button
|
||||||
|
onClick={onLoadMore}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
{t('common.loading')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export function TravelTimeCard({
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||||
{t('travel.travelTime', { mode: modes.label(mode) })}
|
{t('travel.travelTime', { mode: modes.label(mode) })}
|
||||||
|
|
@ -158,10 +158,10 @@ export function TravelTimeCard({
|
||||||
/>
|
/>
|
||||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||||
<span className="absolute left-0">
|
<span className="absolute left-0">
|
||||||
{formatFilterValue(displayRange[0])} {t('common.min')}
|
{formatFilterValue(displayRange[0])} {t('common.minute')}
|
||||||
</span>
|
</span>
|
||||||
<span className="absolute right-0">
|
<span className="absolute right-0">
|
||||||
{formatFilterValue(displayRange[1])} {t('common.min')}
|
{formatFilterValue(displayRange[1])} {t('common.minute')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{filterImpact != null && filterImpact > 0 && (
|
{filterImpact != null && filterImpact > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ export function ActiveFilterList({
|
||||||
name={group.name}
|
name={group.name}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={() => onToggleGroup(group.name)}
|
onToggle={() => onToggleGroup(group.name)}
|
||||||
className="sticky top-0 z-10 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
||||||
</CollapsibleGroupHeader>
|
</CollapsibleGroupHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, type FormEvent } from 'react';
|
import { useEffect, useRef, type FormEvent } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
|
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
|
||||||
|
|
@ -30,6 +30,8 @@ export function ClearFiltersDialog({
|
||||||
}: ClearFiltersDialogProps) {
|
}: ClearFiltersDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isEditing = !!editingSearchName && !!onUpdateAndClear;
|
const isEditing = !!editingSearchName && !!onUpdateAndClear;
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
@ -40,17 +42,41 @@ export function ClearFiltersDialog({
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
||||||
|
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
|
||||||
|
'input, button, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
(firstFocusable ?? dialogRef.current)?.focus();
|
||||||
|
return () => {
|
||||||
|
previouslyFocusedRef.current?.focus?.();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
<div
|
||||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="clear-filters-dialog-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
<h2
|
||||||
|
id="clear-filters-dialog-title"
|
||||||
|
className="text-lg font-semibold text-navy-950 dark:text-white"
|
||||||
|
>
|
||||||
{t('filters.clearAllTitle')}
|
{t('filters.clearAllTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -67,9 +93,7 @@ export function ClearFiltersDialog({
|
||||||
i18nKey="filters.clearAllUpdatePrompt"
|
i18nKey="filters.clearAllUpdatePrompt"
|
||||||
values={{ name: editingSearchName }}
|
values={{ name: editingSearchName }}
|
||||||
components={{
|
components={{
|
||||||
strong: (
|
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
|
||||||
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { CloseIcon } from './icons/CloseIcon';
|
import { CloseIcon } from './icons/CloseIcon';
|
||||||
import { GoogleIcon } from './icons/GoogleIcon';
|
import { GoogleIcon } from './icons/GoogleIcon';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
|
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||||
|
|
||||||
type View = 'login' | 'register' | 'forgot';
|
type View = 'login' | 'register' | 'forgot';
|
||||||
|
|
||||||
|
|
@ -34,11 +35,20 @@ export default function AuthModal({
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [resetSent, setResetSent] = useState(false);
|
const [resetSent, setResetSent] = useState(false);
|
||||||
|
const dialogRef = useModalA11y();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackEvent('Auth Modal Open', { tab: initialTab });
|
trackEvent('Auth Modal Open', { tab: initialTab });
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const switchView = useCallback(
|
const switchView = useCallback(
|
||||||
(newView: View) => {
|
(newView: View) => {
|
||||||
setView(newView);
|
setView(newView);
|
||||||
|
|
@ -97,14 +107,26 @@ export default function AuthModal({
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
|
||||||
<div className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700">
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="auth-modal-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
|
<h2 id="auth-modal-title" className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||||
>
|
>
|
||||||
<CloseIcon className="w-5 h-5" />
|
<CloseIcon className="w-5 h-5" />
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export function FeatureLabel({
|
||||||
}: FeatureLabelProps) {
|
}: FeatureLabelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||||
|
const gapClass = size === 'sm' ? 'gap-2' : 'gap-1';
|
||||||
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
|
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
|
||||||
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
|
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
|
||||||
const featureIcon = getFeatureIcon(feature.name, iconClass);
|
const featureIcon = getFeatureIcon(feature.name, iconClass);
|
||||||
|
|
@ -56,7 +57,7 @@ export function FeatureLabel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
|
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} ${gapClass} min-w-0 ${className}`}
|
||||||
>
|
>
|
||||||
{featureIcon}
|
{featureIcon}
|
||||||
{GroupIcon && <GroupIcon className={iconClass} />}
|
{GroupIcon && <GroupIcon className={iconClass} />}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,7 @@ interface IndeterminateProgressBarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IndeterminateProgressBar({
|
export function IndeterminateProgressBar({ show, className = '' }: IndeterminateProgressBarProps) {
|
||||||
show,
|
|
||||||
className = '',
|
|
||||||
}: IndeterminateProgressBarProps) {
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useCallback, type ReactNode } from 'react';
|
import { useRef, useCallback, useEffect, useId, type ReactNode } from 'react';
|
||||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||||
import { CloseIcon } from './icons';
|
import { CloseIcon } from './icons';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
|
|
@ -12,6 +12,8 @@ interface InfoPopupProps {
|
||||||
|
|
||||||
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
|
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
|
||||||
const popupRef = useRef<HTMLDivElement>(null);
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -19,14 +21,42 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
|
||||||
|
|
||||||
useClickOutside(popupRef, handleClose);
|
useClickOutside(popupRef, handleClose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
||||||
|
const firstFocusable = popupRef.current?.querySelector<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
(firstFocusable ?? popupRef.current)?.focus();
|
||||||
|
return () => {
|
||||||
|
previouslyFocusedRef.current?.focus?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={popupRef}
|
ref={popupRef}
|
||||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5 outline-none"
|
||||||
>
|
>
|
||||||
<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 id={titleId} className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
<IconButton onClick={onClose} className="shrink-0">
|
<IconButton onClick={onClose} className="shrink-0">
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||||
|
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||||
|
|
||||||
interface LicenseSuccessModalProps {
|
interface LicenseSuccessModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -14,6 +15,7 @@ export default function LicenseSuccessModal({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isSuccess = status === 'success';
|
const isSuccess = status === 'success';
|
||||||
const isVerifying = status === 'verifying';
|
const isVerifying = status === 'verifying';
|
||||||
|
const dialogRef = useModalA11y();
|
||||||
const particles = useMemo(
|
const particles = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Array.from({ length: 40 }, (_, i) => ({
|
Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
|
@ -36,6 +38,14 @@ export default function LicenseSuccessModal({
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [isSuccess, onClose]);
|
}, [isSuccess, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const title =
|
const title =
|
||||||
status === 'verifying'
|
status === 'verifying'
|
||||||
? t('licenseSuccess.verifyingTitle')
|
? t('licenseSuccess.verifyingTitle')
|
||||||
|
|
@ -56,9 +66,12 @@ export default function LicenseSuccessModal({
|
||||||
: t('licenseSuccess.description');
|
: t('licenseSuccess.description');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
<div
|
||||||
|
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
{isSuccess && (
|
{isSuccess && (
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||||
{particles.map((p) => (
|
{particles.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|
@ -78,7 +91,14 @@ export default function LicenseSuccessModal({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="license-success-modal-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden outline-none"
|
||||||
|
>
|
||||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
||||||
<div className="h-14 mb-3 flex items-center justify-center">
|
<div className="h-14 mb-3 flex items-center justify-center">
|
||||||
{isVerifying ? (
|
{isVerifying ? (
|
||||||
|
|
@ -87,7 +107,9 @@ export default function LicenseSuccessModal({
|
||||||
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
|
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-white">{title}</h2>
|
<h2 id="license-success-modal-title" className="text-2xl font-bold text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
<p className="text-warm-300 text-sm mt-2">{subtitle}</p>
|
<p className="text-warm-300 text-sm mt-2">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-6">
|
<div className="px-6 py-6">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { CheckIcon } from './icons/CheckIcon';
|
import { CheckIcon } from './icons/CheckIcon';
|
||||||
import { CloseIcon } from './icons/CloseIcon';
|
import { CloseIcon } from './icons/CloseIcon';
|
||||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||||
|
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||||
|
|
||||||
export default function SaveSearchModal({
|
export default function SaveSearchModal({
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -20,6 +21,7 @@ export default function SaveSearchModal({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const dialogRef = useModalA11y();
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (e: React.FormEvent) => {
|
async (e: React.FormEvent) => {
|
||||||
|
|
@ -44,18 +46,32 @@ export default function SaveSearchModal({
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
<div
|
||||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="save-search-modal-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
<h2
|
||||||
|
id="save-search-modal-title"
|
||||||
|
className="text-lg font-semibold text-navy-950 dark:text-white"
|
||||||
|
>
|
||||||
{saved ? t('saveSearch.saved') : t('saveSearch.title')}
|
{saved ? t('saveSearch.saved') : t('saveSearch.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||||
>
|
>
|
||||||
<CloseIcon className="w-5 h-5" />
|
<CloseIcon className="w-5 h-5" />
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { CloseIcon } from './icons/CloseIcon';
|
import { CloseIcon } from './icons/CloseIcon';
|
||||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||||
|
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||||
|
|
||||||
interface UpgradeModalProps {
|
interface UpgradeModalProps {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
|
@ -28,6 +29,7 @@ export default function UpgradeModal({
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pricePence, setPricePence] = useState<number | null>(null);
|
const [pricePence, setPricePence] = useState<number | null>(null);
|
||||||
|
const dialogRef = useModalA11y();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(apiUrl('pricing'))
|
fetch(apiUrl('pricing'))
|
||||||
|
|
@ -38,6 +40,14 @@ export default function UpgradeModal({
|
||||||
.catch((err) => logNonAbortError('Failed to fetch pricing', err));
|
.catch((err) => logNonAbortError('Failed to fetch pricing', err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onZoomToFreeZone();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onZoomToFreeZone]);
|
||||||
|
|
||||||
const priceLabel =
|
const priceLabel =
|
||||||
pricePence === null
|
pricePence === null
|
||||||
? '...'
|
? '...'
|
||||||
|
|
@ -59,11 +69,23 @@ export default function UpgradeModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
<div
|
||||||
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="upgrade-modal-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden outline-none"
|
||||||
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onZoomToFreeZone}
|
onClick={onZoomToFreeZone}
|
||||||
|
aria-label={t('common.close')}
|
||||||
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||||
>
|
>
|
||||||
<CloseIcon className="w-5 h-5" />
|
<CloseIcon className="w-5 h-5" />
|
||||||
|
|
@ -71,7 +93,9 @@ export default function UpgradeModal({
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
|
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
|
||||||
|
{t('upgrade.title')}
|
||||||
|
</h2>
|
||||||
<p className="text-warm-300 text-sm">
|
<p className="text-warm-300 text-sm">
|
||||||
{isShareReturn ? t('upgrade.sharedAreaDescription') : t('upgrade.description')}
|
{isShareReturn ? t('upgrade.sharedAreaDescription') : t('upgrade.description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,16 @@ import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 200;
|
const DEBOUNCE_MS = 200;
|
||||||
|
|
||||||
export function useActualListings(bounds: Bounds | null) {
|
interface UseActualListingsOptions {
|
||||||
|
filterParam?: string;
|
||||||
|
travelParam?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActualListings(
|
||||||
|
bounds: Bounds | null,
|
||||||
|
{ filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
|
||||||
|
) {
|
||||||
const [listings, setListings] = useState<ActualListing[]>([]);
|
const [listings, setListings] = useState<ActualListing[]>([]);
|
||||||
const [truncated, setTruncated] = useState(false);
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
|
|
@ -18,7 +25,6 @@ export function useActualListings(bounds: Bounds | null) {
|
||||||
if (!bounds) {
|
if (!bounds) {
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
if (listings.length !== 0) setListings([]);
|
if (listings.length !== 0) setListings([]);
|
||||||
if (truncated) setTruncated(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +36,8 @@ export function useActualListings(bounds: Bounds | null) {
|
||||||
try {
|
try {
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
|
if (filterParam) params.set('filters', filterParam);
|
||||||
|
if (travelParam) params.set('travel', travelParam);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
apiUrl('actual-listings', params),
|
apiUrl('actual-listings', params),
|
||||||
authHeaders({ signal: abortControllerRef.current.signal })
|
authHeaders({ signal: abortControllerRef.current.signal })
|
||||||
|
|
@ -38,7 +46,6 @@ export function useActualListings(bounds: Bounds | null) {
|
||||||
const json: ActualListingsResponse = await res.json();
|
const json: ActualListingsResponse = await res.json();
|
||||||
if (requestIdRef.current !== requestId) return;
|
if (requestIdRef.current !== requestId) return;
|
||||||
setListings(json.listings || []);
|
setListings(json.listings || []);
|
||||||
setTruncated(Boolean(json.truncated));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logNonAbortError('Failed to fetch actual listings', err);
|
logNonAbortError('Failed to fetch actual listings', err);
|
||||||
}
|
}
|
||||||
|
|
@ -48,9 +55,9 @@ export function useActualListings(bounds: Bounds | null) {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
};
|
};
|
||||||
// listings/truncated intentionally excluded — they're internal state, not inputs.
|
// listings intentionally excluded — it's internal state, not an input.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [bounds]);
|
}, [bounds, filterParam, travelParam]);
|
||||||
|
|
||||||
return { listings, truncated };
|
return { listings };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,6 @@ export function useDeckLayers({
|
||||||
isDark,
|
isDark,
|
||||||
hexagonData: data,
|
hexagonData: data,
|
||||||
postcodeData,
|
postcodeData,
|
||||||
resolution: usePostcodeView ? 0 : Math.round(zoom),
|
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -280,21 +279,33 @@ export function useDeckLayers({
|
||||||
const isEnum = enumCountRef.current > 0;
|
const isEnum = enumCountRef.current > 0;
|
||||||
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
|
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
|
||||||
|
|
||||||
|
// Per-render memo: each of getRatios0/1/2 would otherwise call distToRatios
|
||||||
|
// on the same row, tripling the work. Cache by row reference.
|
||||||
|
const ratiosCache = new WeakMap<HexagonData, number[]>();
|
||||||
|
const getRatios = (d: HexagonData): number[] => {
|
||||||
|
let r = ratiosCache.get(d);
|
||||||
|
if (!r) {
|
||||||
|
r = distToRatios(d[distKey]);
|
||||||
|
ratiosCache.set(d, r);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const pieProps: any = isEnum
|
const pieProps: any = isEnum
|
||||||
? {
|
? {
|
||||||
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
|
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
|
||||||
getCenter: (d: HexagonData) => [d.lon, d.lat],
|
getCenter: (d: HexagonData) => [d.lon, d.lat],
|
||||||
getRatios0: (d: HexagonData) => {
|
getRatios0: (d: HexagonData) => {
|
||||||
const r = distToRatios(d[distKey]);
|
const r = getRatios(d);
|
||||||
return [r[0], r[1], r[2], r[3]];
|
return [r[0], r[1], r[2], r[3]];
|
||||||
},
|
},
|
||||||
getRatios1: (d: HexagonData) => {
|
getRatios1: (d: HexagonData) => {
|
||||||
const r = distToRatios(d[distKey]);
|
const r = getRatios(d);
|
||||||
return [r[4], r[5], r[6], r[7]];
|
return [r[4], r[5], r[6], r[7]];
|
||||||
},
|
},
|
||||||
getRatios2: (d: HexagonData) => {
|
getRatios2: (d: HexagonData) => {
|
||||||
const r = distToRatios(d[distKey]);
|
const r = getRatios(d);
|
||||||
return [r[8], r[9]];
|
return [r[8], r[9]];
|
||||||
},
|
},
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
|
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
|
||||||
import {
|
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
|
||||||
apiUrl,
|
|
||||||
buildFilterString,
|
|
||||||
logNonAbortError,
|
|
||||||
authHeaders,
|
|
||||||
isAbortError,
|
|
||||||
} from '../lib/api';
|
|
||||||
import type { TravelTimeEntry } from './useTravelTime';
|
import type { TravelTimeEntry } from './useTravelTime';
|
||||||
import { buildTravelParam } from '../lib/travel-params';
|
import { buildTravelParam } from '../lib/travel-params';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,31 +45,46 @@ export function useListingLayers({
|
||||||
}: UseListingLayersProps) {
|
}: UseListingLayersProps) {
|
||||||
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
|
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
|
||||||
|
|
||||||
const visibleListings = useMemo(() => {
|
// Split into two memos so the inactive view's data changes don't invalidate
|
||||||
if (listings.length === 0) return listings;
|
// the active filtered list. (e.g. in postcode view, hexagonData updates must
|
||||||
if (usePostcodeView) {
|
// not retrigger filtering / downstream layer rebuilds.)
|
||||||
const allowed = new Set<string>();
|
const postcodeFilteredListings = useMemo(() => {
|
||||||
for (const feature of postcodeData) {
|
if (!usePostcodeView || listings.length === 0) return null;
|
||||||
if (feature.properties.count > 0) {
|
|
||||||
allowed.add(normalizePostcode(feature.properties.postcode));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allowed.size === 0) return [];
|
|
||||||
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
|
|
||||||
}
|
|
||||||
const allowed = new Set<string>();
|
const allowed = new Set<string>();
|
||||||
for (const cell of hexagonData) {
|
for (const feature of postcodeData) {
|
||||||
if (cell.count > 0) allowed.add(cell.h3);
|
if (feature.properties.count > 0) {
|
||||||
|
allowed.add(normalizePostcode(feature.properties.postcode));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (allowed.size === 0) return [];
|
if (allowed.size === 0) return [];
|
||||||
|
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
|
||||||
|
}, [listings, postcodeData, usePostcodeView]);
|
||||||
|
|
||||||
|
const hexFilteredListings = useMemo(() => {
|
||||||
|
if (usePostcodeView || listings.length === 0) return null;
|
||||||
|
const allowed = new Set<string>();
|
||||||
|
let cellResolution: number | null = null;
|
||||||
|
for (const cell of hexagonData) {
|
||||||
|
if (cell.count > 0) {
|
||||||
|
allowed.add(cell.h3);
|
||||||
|
if (cellResolution == null) cellResolution = getResolution(cell.h3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allowed.size === 0 || cellResolution == null) return [];
|
||||||
|
const resolutionForLookup = cellResolution;
|
||||||
return listings.filter((listing) => {
|
return listings.filter((listing) => {
|
||||||
try {
|
try {
|
||||||
return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
|
return allowed.has(latLngToCell(listing.lat, listing.lon, resolutionForLookup));
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
|
}, [listings, hexagonData, usePostcodeView]);
|
||||||
|
|
||||||
|
const visibleListings = useMemo(() => {
|
||||||
|
if (listings.length === 0) return listings;
|
||||||
|
return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? [];
|
||||||
|
}, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]);
|
||||||
|
|
||||||
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
|
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
|
||||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/
|
||||||
import { type TravelTimeEntry } from './useTravelTime';
|
import { type TravelTimeEntry } from './useTravelTime';
|
||||||
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
|
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
|
||||||
|
|
||||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
/** Return the p-th percentile (0–100) from a sorted typed array via linear interpolation. */
|
||||||
function percentile(sorted: number[], p: number): number {
|
function percentile(sorted: Float64Array, p: number): number {
|
||||||
if (sorted.length === 0) return 0;
|
if (sorted.length === 0) return 0;
|
||||||
if (sorted.length === 1) return sorted[0];
|
if (sorted.length === 1) return sorted[0];
|
||||||
const idx = (p / 100) * (sorted.length - 1);
|
const idx = (p / 100) * (sorted.length - 1);
|
||||||
|
|
@ -262,10 +262,20 @@ export function useMapData({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeFeature || !activeDragRequest) return;
|
if (!activeFeature || !activeDragRequest) return;
|
||||||
|
|
||||||
|
// Abort any in-flight previous drag fetch before starting a new one.
|
||||||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||||
dragAbortRef.current = new AbortController();
|
|
||||||
|
// Capture the controller locally so this effect's cleanup unambiguously
|
||||||
|
// aborts THIS request's controller, even if `dragAbortRef.current` has
|
||||||
|
// been swapped by a subsequent effect run.
|
||||||
|
const controller = new AbortController();
|
||||||
|
dragAbortRef.current = controller;
|
||||||
|
const { signal } = controller;
|
||||||
|
|
||||||
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
|
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
|
||||||
|
// Capture activeFeature in a local so the async .then() callback cannot
|
||||||
|
// observe a stale-or-newer value via closure surprise.
|
||||||
|
const effectActiveFeature = activeFeature;
|
||||||
latestDragRequestKeyRef.current = requestKey;
|
latestDragRequestKeyRef.current = requestKey;
|
||||||
setDragDataKey('');
|
setDragDataKey('');
|
||||||
dragFeatureRef.current = null;
|
dragFeatureRef.current = null;
|
||||||
|
|
@ -278,14 +288,15 @@ export function useMapData({
|
||||||
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||||||
if (shareCode) params.set('share', shareCode);
|
if (shareCode) params.set('share', shareCode);
|
||||||
|
|
||||||
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
fetch(apiUrl('postcodes', params), authHeaders({ signal }))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((json: { features: PostcodeFeature[] }) => {
|
.then((json: { features: PostcodeFeature[] }) => {
|
||||||
|
if (signal.aborted) return;
|
||||||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||||
setDragPostcodeData(json.features);
|
setDragPostcodeData(json.features);
|
||||||
setDragHexData(null);
|
setDragHexData(null);
|
||||||
setDragDataKey(requestKey);
|
setDragDataKey(requestKey);
|
||||||
dragFeatureRef.current = activeFeature;
|
dragFeatureRef.current = effectActiveFeature;
|
||||||
})
|
})
|
||||||
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
|
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -299,31 +310,36 @@ export function useMapData({
|
||||||
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
|
||||||
if (shareCode) params.set('share', shareCode);
|
if (shareCode) params.set('share', shareCode);
|
||||||
|
|
||||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
fetch(apiUrl('hexagons', params), authHeaders({ signal }))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((json: ApiResponse) => {
|
.then((json: ApiResponse) => {
|
||||||
|
if (signal.aborted) return;
|
||||||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||||
setDragHexData(json.features);
|
setDragHexData(json.features);
|
||||||
setDragPostcodeData(null);
|
setDragPostcodeData(null);
|
||||||
setDragDataKey(requestKey);
|
setDragDataKey(requestKey);
|
||||||
dragFeatureRef.current = activeFeature;
|
dragFeatureRef.current = effectActiveFeature;
|
||||||
})
|
})
|
||||||
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
|
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (dragAbortRef.current) {
|
// Abort the controller captured by THIS effect run rather than reading
|
||||||
dragAbortRef.current.abort();
|
// from the ref (which may already have been replaced by a newer run).
|
||||||
|
controller.abort();
|
||||||
|
if (dragAbortRef.current === controller) {
|
||||||
dragAbortRef.current = null;
|
dragAbortRef.current = null;
|
||||||
}
|
}
|
||||||
if (latestDragRequestKeyRef.current === requestKey) {
|
// Do not clear latestDragRequestKeyRef here: a newer effect run will
|
||||||
latestDragRequestKeyRef.current = '';
|
// overwrite it with its own requestKey, and clearing it would create a
|
||||||
}
|
// brief window in which a late-resolving fetch from this run could pass
|
||||||
|
// the staleness check against an empty key.
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeFeature,
|
activeFeature,
|
||||||
activeDragRequest,
|
activeDragRequest,
|
||||||
dataViewFeature,
|
dataViewFeature,
|
||||||
|
resolution,
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
viewFeatureIsEnum,
|
viewFeatureIsEnum,
|
||||||
shareCode,
|
shareCode,
|
||||||
|
|
@ -538,10 +554,14 @@ export function useMapData({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vals.length === 0) return null;
|
if (vals.length === 0) return null;
|
||||||
vals.sort((a, b) => a - b);
|
// Typed-array sort uses the engine's optimized numeric sort with no
|
||||||
|
// per-element comparator call — measurably faster than `vals.sort((a,b)=>a-b)`
|
||||||
|
// for the 5k–10k samples a busy viewport produces.
|
||||||
|
const sorted = Float64Array.from(vals);
|
||||||
|
sorted.sort();
|
||||||
return [
|
return [
|
||||||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
percentile(sorted, COLOR_RANGE_LOW_PERCENTILE),
|
||||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
percentile(sorted, COLOR_RANGE_HIGH_PERCENTILE),
|
||||||
];
|
];
|
||||||
}, [
|
}, [
|
||||||
bounds,
|
bounds,
|
||||||
|
|
|
||||||
64
frontend/src/hooks/useModalA11y.ts
Normal file
64
frontend/src/hooks/useModalA11y.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared modal accessibility behavior: locks body scroll, traps Tab focus
|
||||||
|
* inside the dialog, restores focus on unmount, and focuses the first
|
||||||
|
* focusable element (or the dialog itself) on mount.
|
||||||
|
*/
|
||||||
|
export function useModalA11y(): React.RefObject<HTMLDivElement | null> {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
const focusableSelector =
|
||||||
|
'input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
const firstFocusable = dialog?.querySelector<HTMLElement>(focusableSelector);
|
||||||
|
(firstFocusable ?? dialog)?.focus();
|
||||||
|
|
||||||
|
// Lock body scroll while preserving scroll position.
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
const prevPaddingRight = document.body.style.paddingRight;
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Tab' || !dialog) return;
|
||||||
|
const focusables = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter(
|
||||||
|
(el) => el.offsetParent !== null || el === document.activeElement
|
||||||
|
);
|
||||||
|
if (focusables.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
dialog.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (active === first || !dialog.contains(active)) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else if (active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
document.body.style.paddingRight = prevPaddingRight;
|
||||||
|
previouslyFocused?.focus?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return dialogRef;
|
||||||
|
}
|
||||||
|
|
@ -12,8 +12,16 @@ export interface SavedSearch {
|
||||||
created: string;
|
created: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 2000;
|
// Exponential backoff: 2s, 3s, 4s, 6s, 8s, 12s, ... capped at 15s.
|
||||||
const MAX_POLL_ATTEMPTS = 15;
|
// Caps total wait under a minute while staying responsive for fast jobs.
|
||||||
|
const POLL_BASE_MS = 2000;
|
||||||
|
const POLL_MAX_MS = 15000;
|
||||||
|
const POLL_BACKOFF = 1.5;
|
||||||
|
const MAX_POLL_ATTEMPTS = 8;
|
||||||
|
|
||||||
|
function nextPollDelay(attempt: number): number {
|
||||||
|
return Math.min(POLL_MAX_MS, Math.round(POLL_BASE_MS * Math.pow(POLL_BACKOFF, attempt)));
|
||||||
|
}
|
||||||
|
|
||||||
export function useSavedSearches(userId: string | null) {
|
export function useSavedSearches(userId: string | null) {
|
||||||
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
||||||
|
|
@ -21,14 +29,16 @@ export function useSavedSearches(userId: string | null) {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pollAttemptsRef = useRef(0);
|
const pollAttemptsRef = useRef(0);
|
||||||
|
const pollInFlightRef = useRef(false);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
const userIdRef = useRef(userId);
|
const userIdRef = useRef(userId);
|
||||||
userIdRef.current = userId;
|
userIdRef.current = userId;
|
||||||
|
|
||||||
const stopPolling = useCallback(() => {
|
const stopPolling = useCallback(() => {
|
||||||
if (pollTimerRef.current) {
|
if (pollTimerRef.current) {
|
||||||
clearInterval(pollTimerRef.current);
|
clearTimeout(pollTimerRef.current);
|
||||||
pollTimerRef.current = null;
|
pollTimerRef.current = null;
|
||||||
}
|
}
|
||||||
pollAttemptsRef.current = 0;
|
pollAttemptsRef.current = 0;
|
||||||
|
|
@ -37,6 +47,15 @@ export function useSavedSearches(userId: string | null) {
|
||||||
// Clean up polling on unmount or userId change
|
// Clean up polling on unmount or userId change
|
||||||
useEffect(() => stopPolling, [userId, stopPolling]);
|
useEffect(() => stopPolling, [userId, stopPolling]);
|
||||||
|
|
||||||
|
// Mark the hook as unmounted so late-arriving async work doesn't touch state
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}, [stopPolling]);
|
||||||
|
|
||||||
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
|
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
|
||||||
const records = await pb.collection('saved_searches').getFullList({
|
const records = await pb.collection('saved_searches').getFullList({
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
|
|
@ -57,28 +76,41 @@ export function useSavedSearches(userId: string | null) {
|
||||||
const startPolling = useCallback(() => {
|
const startPolling = useCallback(() => {
|
||||||
if (pollTimerRef.current) return;
|
if (pollTimerRef.current) return;
|
||||||
pollAttemptsRef.current = 0;
|
pollAttemptsRef.current = 0;
|
||||||
pollTimerRef.current = setInterval(async () => {
|
pollInFlightRef.current = false;
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
const delay = nextPollDelay(pollAttemptsRef.current);
|
||||||
|
pollTimerRef.current = setTimeout(tick, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
if (pollInFlightRef.current) {
|
||||||
|
scheduleNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const uid = userIdRef.current;
|
const uid = userIdRef.current;
|
||||||
if (!uid) {
|
if (!uid) return;
|
||||||
stopPolling();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pollAttemptsRef.current++;
|
pollAttemptsRef.current++;
|
||||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return;
|
||||||
stopPolling();
|
pollInFlightRef.current = true;
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const mapped = await fetchRecords(uid);
|
const mapped = await fetchRecords(uid);
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
setSearches(mapped);
|
setSearches(mapped);
|
||||||
if (!mapped.some((s) => !s.screenshotUrl)) {
|
if (!mapped.some((s) => !s.screenshotUrl)) return;
|
||||||
stopPolling();
|
scheduleNext();
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Silent — background poll errors don't surface to UI
|
// Silent — background poll errors don't surface to UI; keep trying.
|
||||||
|
if (isMountedRef.current) scheduleNext();
|
||||||
|
} finally {
|
||||||
|
pollInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
}, POLL_INTERVAL_MS);
|
};
|
||||||
}, [stopPolling, fetchRecords]);
|
|
||||||
|
scheduleNext();
|
||||||
|
}, [fetchRecords]);
|
||||||
|
|
||||||
const fetchSearches = useCallback(async () => {
|
const fetchSearches = useCallback(async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
|
||||||
|
|
@ -25,21 +25,28 @@ const de: Translations = {
|
||||||
total: 'Gesamt',
|
total: 'Gesamt',
|
||||||
min: 'Min.',
|
min: 'Min.',
|
||||||
max: 'Max.',
|
max: 'Max.',
|
||||||
|
minute: 'Min.',
|
||||||
or: 'oder',
|
or: 'oder',
|
||||||
area: 'Gebiet',
|
area: 'Gebiet',
|
||||||
properties: 'Immobilien',
|
properties: 'Immobilien',
|
||||||
postcode: 'Postleitzahl',
|
postcode: 'Postleitzahl',
|
||||||
noAreaSelected: 'Kein Gebiet ausgewählt',
|
noAreaSelected: 'Kein Gebiet ausgewählt',
|
||||||
noAreaSelectedDesc:
|
noAreaSelectedDesc:
|
||||||
'Klicke auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
|
'Klicken Sie auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
|
||||||
clickForDetails: 'Für Details klicken',
|
clickForDetails: 'Für Details klicken',
|
||||||
property: 'Immobilie',
|
property: 'Immobilie',
|
||||||
propertiesPlural: 'Immobilien',
|
propertiesPlural: 'Immobilien',
|
||||||
|
bedsCount: '{{count}} Schlafzimmer',
|
||||||
|
bedsCount_other: '{{count}} Schlafzimmer',
|
||||||
|
bathsCount: '{{count}} Bad',
|
||||||
|
bathsCount_other: '{{count}} Bäder',
|
||||||
places: 'Orte',
|
places: 'Orte',
|
||||||
noData: 'Keine Daten',
|
noData: 'Keine Daten',
|
||||||
allLow: 'Alles niedrig',
|
allLow: 'Alles niedrig',
|
||||||
connectingToServer: 'Verbindung zum Server...',
|
connectingToServer: 'Verbindung zum Server...',
|
||||||
closePane: 'Bereich schließen',
|
closePane: 'Bereich schließen',
|
||||||
|
yes: 'Ja',
|
||||||
|
no: 'Nein',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Header / Nav ───────────────────────────────────
|
// ── Header / Nav ───────────────────────────────────
|
||||||
|
|
@ -310,8 +317,7 @@ const de: Translations = {
|
||||||
'Family trade-offs to compare': 'Familienkompromisse zum Vergleich',
|
'Family trade-offs to compare': 'Familienkompromisse zum Vergleich',
|
||||||
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
|
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
|
||||||
'Kombinieren Sie Schulen mit Parks, Straßenlärm, Kriminalität, Wohnfläche, Pendelweg, Breitband und Preis, damit die Auswahlliste den gesamten Umzug widerspiegelt.',
|
'Kombinieren Sie Schulen mit Parks, Straßenlärm, Kriminalität, Wohnfläche, Pendelweg, Breitband und Preis, damit die Auswahlliste den gesamten Umzug widerspiegelt.',
|
||||||
'Does this show school catchment guarantees?':
|
'Does this show school catchment guarantees?': 'Zeigt dies garantierte Schul-Einzugsgebiete?',
|
||||||
'Zeigt dies garantierte Schul-Einzugsgebiete?',
|
|
||||||
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
|
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
|
||||||
'Nein. Es hilft dabei, vielversprechende Gebiete zu identifizieren, Einzugsgebiete und Zulassungen müssen jedoch bei der Schule oder der örtlichen Behörde überprüft werden.',
|
'Nein. Es hilft dabei, vielversprechende Gebiete zu identifizieren, Einzugsgebiete und Zulassungen müssen jedoch bei der Schule oder der örtlichen Behörde überprüft werden.',
|
||||||
'Can I combine school filters with parks and safety?':
|
'Can I combine school filters with parks and safety?':
|
||||||
|
|
@ -480,8 +486,7 @@ const de: Translations = {
|
||||||
'Wie Konto- und gespeicherte Suchdaten im Produkt verarbeitet werden.',
|
'Wie Konto- und gespeicherte Suchdaten im Produkt verarbeitet werden.',
|
||||||
'Compare Bristol postcodes': 'Vergleichen Sie die Postleitzahlen von Bristol',
|
'Compare Bristol postcodes': 'Vergleichen Sie die Postleitzahlen von Bristol',
|
||||||
'Trust and coverage': 'Vertrauen und Abdeckung',
|
'Trust and coverage': 'Vertrauen und Abdeckung',
|
||||||
'Perfect Postcode data sources and coverage':
|
'Perfect Postcode data sources and coverage': 'Perfect Postcode – Datenquellen und Abdeckung',
|
||||||
'Perfect Postcode – Datenquellen und Abdeckung',
|
|
||||||
'Perfect Postcode data sources - Property, schools, commute and local context':
|
'Perfect Postcode data sources - Property, schools, commute and local context':
|
||||||
'Perfect Postcode – Datenquellen: Immobilien, Schulen, Pendelweg und lokaler Kontext',
|
'Perfect Postcode – Datenquellen: Immobilien, Schulen, Pendelweg und lokaler Kontext',
|
||||||
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
|
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
|
||||||
|
|
@ -501,8 +506,7 @@ const de: Translations = {
|
||||||
'Travel-time data': 'Reisezeitdaten',
|
'Travel-time data': 'Reisezeitdaten',
|
||||||
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
|
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
|
||||||
'Reisezeitfilter sind für einen konsistenten Gebietsvergleich konzipiert. Bevor Sie sich für ein Gebiet entscheiden, sollten Sie Routenverfügbarkeit, Störungen, Parkmöglichkeiten, Fußläufigkeit und Fahrplandetails überprüfen.',
|
'Reisezeitfilter sind für einen konsistenten Gebietsvergleich konzipiert. Bevor Sie sich für ein Gebiet entscheiden, sollten Sie Routenverfügbarkeit, Störungen, Parkmöglichkeiten, Fußläufigkeit und Fahrplandetails überprüfen.',
|
||||||
'Why does coverage focus on England?':
|
'Why does coverage focus on England?': 'Warum konzentriert sich die Abdeckung auf England?',
|
||||||
'Warum konzentriert sich die Abdeckung auf England?',
|
|
||||||
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
|
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
|
||||||
'Mehrere zentrale Datensätze zu Immobilien, Bildung und lokalem Kontext sind jurisdiktionsspezifisch. Eine Abdeckung von England sorgt für konsistentere Vergleiche.',
|
'Mehrere zentrale Datensätze zu Immobilien, Bildung und lokalem Kontext sind jurisdiktionsspezifisch. Eine Abdeckung von England sorgt für konsistentere Vergleiche.',
|
||||||
'How should I handle stale or missing data?':
|
'How should I handle stale or missing data?':
|
||||||
|
|
@ -588,15 +592,15 @@ const de: Translations = {
|
||||||
createAccount: 'Konto erstellen',
|
createAccount: 'Konto erstellen',
|
||||||
resetPassword: 'Passwort zurücksetzen',
|
resetPassword: 'Passwort zurücksetzen',
|
||||||
valueProp:
|
valueProp:
|
||||||
'Speichere Suchen, merke dir Immobilien und erstelle eine Auswahlliste passender Gebiete.',
|
'Speichern Sie Suchen, merken Sie sich Immobilien und erstellen Sie eine Auswahlliste passender Gebiete.',
|
||||||
continueWithGoogle: 'Weiter mit Google',
|
continueWithGoogle: 'Weiter mit Google',
|
||||||
email: 'E-Mail',
|
email: 'E-Mail',
|
||||||
emailPlaceholder: 'du@beispiel.de',
|
emailPlaceholder: 'name@beispiel.de',
|
||||||
password: 'Passwort',
|
password: 'Passwort',
|
||||||
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
|
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
|
||||||
passwordPlaceholderLogin: 'Dein Passwort',
|
passwordPlaceholderLogin: 'Ihr Passwort',
|
||||||
forgotPassword: 'Passwort vergessen?',
|
forgotPassword: 'Passwort vergessen?',
|
||||||
resetSent: 'Prüfe deine E-Mails für einen Link zum Zurücksetzen.',
|
resetSent: 'Prüfen Sie Ihre E-Mails für einen Link zum Zurücksetzen.',
|
||||||
pleaseWait: 'Bitte warten...',
|
pleaseWait: 'Bitte warten...',
|
||||||
sendResetLink: 'Link zum Zurücksetzen senden',
|
sendResetLink: 'Link zum Zurücksetzen senden',
|
||||||
backToLogin: 'Zurück zur Anmeldung',
|
backToLogin: 'Zurück zur Anmeldung',
|
||||||
|
|
@ -606,7 +610,7 @@ const de: Translations = {
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'Jede passende Postleitzahl finden',
|
title: 'Jede passende Postleitzahl finden',
|
||||||
description:
|
description:
|
||||||
'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
|
'Sie erkunden gerade das Demogebiet. Erhalten Sie lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
|
||||||
free: 'Kostenlos',
|
free: 'Kostenlos',
|
||||||
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
||||||
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
|
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
|
||||||
|
|
@ -618,7 +622,7 @@ const de: Translations = {
|
||||||
continueWithDemo: 'Mit Demo fortfahren',
|
continueWithDemo: 'Mit Demo fortfahren',
|
||||||
backToSharedArea: 'Zurück zum geteilten Gebiet',
|
backToSharedArea: 'Zurück zum geteilten Gebiet',
|
||||||
sharedAreaDescription:
|
sharedAreaDescription:
|
||||||
'Du siehst ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichere dir lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
|
'Sie sehen ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichern Sie sich lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
|
||||||
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -626,7 +630,7 @@ const de: Translations = {
|
||||||
saveSearch: {
|
saveSearch: {
|
||||||
title: 'Suche speichern',
|
title: 'Suche speichern',
|
||||||
saved: 'Suche gespeichert',
|
saved: 'Suche gespeichert',
|
||||||
savedSuccess: 'Deine Suche wurde erfolgreich gespeichert.',
|
savedSuccess: 'Ihre Suche wurde erfolgreich gespeichert.',
|
||||||
viewSavedSearches: 'Gespeicherte Suchen ansehen',
|
viewSavedSearches: 'Gespeicherte Suchen ansehen',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
namePlaceholder: 'Meine Suche',
|
namePlaceholder: 'Meine Suche',
|
||||||
|
|
@ -636,15 +640,15 @@ const de: Translations = {
|
||||||
// ── License Success ────────────────────────────────
|
// ── License Success ────────────────────────────────
|
||||||
licenseSuccess: {
|
licenseSuccess: {
|
||||||
verifyingTitle: 'Zugang wird geprüft',
|
verifyingTitle: 'Zugang wird geprüft',
|
||||||
verifyingSubtitle: 'Wir prüfen dein Konto, bevor wir die Karte freischalten.',
|
verifyingSubtitle: 'Wir prüfen Ihr Konto, bevor wir die Karte freischalten.',
|
||||||
verifyingDescription: 'Das dauert nach dem Bezahlen normalerweise nur ein paar Sekunden.',
|
verifyingDescription: 'Das dauert nach dem Bezahlen normalerweise nur ein paar Sekunden.',
|
||||||
activationDelayedTitle: 'Zahlung erhalten',
|
activationDelayedTitle: 'Zahlung erhalten',
|
||||||
activationDelayedSubtitle: 'Der Zugang wird noch aktiviert.',
|
activationDelayedSubtitle: 'Der Zugang wird noch aktiviert.',
|
||||||
activationDelayedDescription:
|
activationDelayedDescription:
|
||||||
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisiere gleich noch einmal oder kontaktiere den Support, falls der Zugang nicht erscheint.',
|
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisieren Sie gleich noch einmal oder kontaktieren Sie den Support, falls der Zugang nicht erscheint.',
|
||||||
stayOnPricing: 'Auf der Preisseite bleiben',
|
stayOnPricing: 'Auf der Preisseite bleiben',
|
||||||
title: 'Du bist dabei.',
|
title: 'Sie sind dabei.',
|
||||||
subtitle: 'Dein lebenslanger Zugang ist jetzt aktiv.',
|
subtitle: 'Ihr lebenslanger Zugang ist jetzt aktiv.',
|
||||||
description: 'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
|
description: 'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
|
||||||
startExploring: 'Jetzt entdecken',
|
startExploring: 'Jetzt entdecken',
|
||||||
},
|
},
|
||||||
|
|
@ -655,18 +659,18 @@ const de: Translations = {
|
||||||
addFilter: 'Filter hinzufügen',
|
addFilter: 'Filter hinzufügen',
|
||||||
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
|
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
|
||||||
addFiltersHint:
|
addFiltersHint:
|
||||||
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
|
'Fügen Sie unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die Ihren Kriterien entsprechen',
|
||||||
upgradePrompt:
|
upgradePrompt:
|
||||||
'Finde passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
|
'Finden Sie passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
|
||||||
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
|
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
|
||||||
upgradeToFullMap: 'Zur Vollversion upgraden',
|
upgradeToFullMap: 'Zur Vollversion upgraden',
|
||||||
chooseFilters:
|
chooseFilters:
|
||||||
'Klicke auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
|
'Klicken Sie auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
|
||||||
searchFeatures: 'Filter durchsuchen...',
|
searchFeatures: 'Filter durchsuchen...',
|
||||||
noMatchingFeatures: 'Keine passenden Filter',
|
noMatchingFeatures: 'Keine passenden Filter',
|
||||||
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
|
tryDifferentSearch: 'Versuchen Sie einen anderen Suchbegriff',
|
||||||
allFeaturesActive: 'Alle Filter sind aktiv',
|
allFeaturesActive: 'Alle Filter sind aktiv',
|
||||||
removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
|
removeFilterHint: 'Entfernen Sie einen Filter, um verfügbare Merkmale zu sehen',
|
||||||
featureInfo: 'Über diese Daten',
|
featureInfo: 'Über diese Daten',
|
||||||
aboutData: 'Über diese Daten',
|
aboutData: 'Über diese Daten',
|
||||||
aboutDataShort: 'Info',
|
aboutDataShort: 'Info',
|
||||||
|
|
@ -679,7 +683,7 @@ const de: Translations = {
|
||||||
replayTutorial: 'Interaktives Tutorial erneut abspielen',
|
replayTutorial: 'Interaktives Tutorial erneut abspielen',
|
||||||
clearAll: 'Alle löschen',
|
clearAll: 'Alle löschen',
|
||||||
clearAllTitle: 'Alle Filter löschen?',
|
clearAllTitle: 'Alle Filter löschen?',
|
||||||
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
|
clearAllSavePrompt: 'Möchten Sie Ihre aktuellen Filter vor dem Löschen speichern?',
|
||||||
clearAllUpdatePrompt:
|
clearAllUpdatePrompt:
|
||||||
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
|
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
|
||||||
saveAndClear: 'Speichern & löschen',
|
saveAndClear: 'Speichern & löschen',
|
||||||
|
|
@ -700,12 +704,14 @@ const de: Translations = {
|
||||||
ethnicity: 'Ethnie',
|
ethnicity: 'Ethnie',
|
||||||
poiType: 'POI-Typ',
|
poiType: 'POI-Typ',
|
||||||
party: 'Partei',
|
party: 'Partei',
|
||||||
|
travelTimeKeywords:
|
||||||
|
'Reisezeit Fahrzeit Pendelzeit Pendeln Fahrt Reise Auto Fahrrad Rad Radfahren zu Fuß Gehen ÖPNV Verkehr Transport Bahnhof Bahn U-Bahn S-Bahn Zug Bus Straßenbahn öffentlich Route travel time journey commute car bicycle bike walking transit transport station train tube bus metro rail route',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Philosophy Popup ───────────────────────────────
|
// ── Philosophy Popup ───────────────────────────────
|
||||||
philosophy: {
|
philosophy: {
|
||||||
intro:
|
intro:
|
||||||
'Beginne mit deinen Muss-Kriterien, dann füge Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn du Filter hinzufügst. Die verbleibenden Gebiete sind deine besten Treffer.',
|
'Beginnen Sie mit Ihren Muss-Kriterien, dann fügen Sie Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn Sie Filter hinzufügen. Die verbleibenden Gebiete sind Ihre besten Treffer.',
|
||||||
step1Title: 'Budget und Grundlagen',
|
step1Title: 'Budget und Grundlagen',
|
||||||
step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
|
step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
|
||||||
step2Title: 'Pendelweg',
|
step2Title: 'Pendelweg',
|
||||||
|
|
@ -718,7 +724,7 @@ const de: Translations = {
|
||||||
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
|
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
|
||||||
step6Title: 'Energie',
|
step6Title: 'Energie',
|
||||||
step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
|
step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
|
||||||
tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
|
tip: 'Tipp: Wenn nichts passt, lockern Sie eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Travel Time ────────────────────────────────────
|
// ── Travel Time ────────────────────────────────────
|
||||||
|
|
@ -755,14 +761,14 @@ const de: Translations = {
|
||||||
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
|
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
|
||||||
walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
|
walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
|
||||||
mainDesc: 'Zeigt die Reisezeit vom ausgewählten Ziel zu jedem Gebiet.',
|
mainDesc: 'Zeigt die Reisezeit vom ausgewählten Ziel zu jedem Gebiet.',
|
||||||
sliderHint: 'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
|
sliderHint: 'Verwenden Sie den Schieberegler, um Ihre maximale Pendelzeit festzulegen.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── AI Filter ──────────────────────────────────────
|
// ── AI Filter ──────────────────────────────────────
|
||||||
aiFilter: {
|
aiFilter: {
|
||||||
describeIdealArea: 'Beschreibe, wo du leben möchtest',
|
describeIdealArea: 'Beschreiben Sie, wo Sie leben möchten',
|
||||||
aiSearch: 'KI-Suche',
|
aiSearch: 'KI-Suche',
|
||||||
describeHint: 'beschreibe, wonach du suchst',
|
describeHint: 'beschreiben Sie, wonach Sie suchen',
|
||||||
placeholder: 'z. B. 2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhig...',
|
placeholder: 'z. B. 2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhig...',
|
||||||
example1: '2 Schlafzimmer unter £525k, 45 Min. zur Arbeit',
|
example1: '2 Schlafzimmer unter £525k, 45 Min. zur Arbeit',
|
||||||
example2: 'Familienfreundliche Gebiete nahe guten Schulen unter £650k',
|
example2: 'Familienfreundliche Gebiete nahe guten Schulen unter £650k',
|
||||||
|
|
@ -772,7 +778,7 @@ const de: Translations = {
|
||||||
generatingFilters: 'Filter werden generiert...',
|
generatingFilters: 'Filter werden generiert...',
|
||||||
refiningResults: 'Ergebnisse werden verfeinert...',
|
refiningResults: 'Ergebnisse werden verfeinert...',
|
||||||
weeklyLimitReached:
|
weeklyLimitReached:
|
||||||
'Du hast das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
|
'Sie haben das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
|
|
@ -1268,7 +1274,7 @@ const de: Translations = {
|
||||||
upgrade: 'Upgraden',
|
upgrade: 'Upgraden',
|
||||||
redirecting: 'Weiterleitung…',
|
redirecting: 'Weiterleitung…',
|
||||||
receiveNewsletter: 'Newsletter-E-Mails erhalten',
|
receiveNewsletter: 'Newsletter-E-Mails erhalten',
|
||||||
needHelp: 'Brauchst du Hilfe? Schreib uns an',
|
needHelp: 'Brauchen Sie Hilfe? Schreiben Sie uns an',
|
||||||
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
|
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
|
||||||
shareLinksTitle: 'Geteilte Links',
|
shareLinksTitle: 'Geteilte Links',
|
||||||
noShareLinksYet: 'Noch keine geteilten Links',
|
noShareLinksYet: 'Noch keine geteilten Links',
|
||||||
|
|
@ -1281,12 +1287,12 @@ const de: Translations = {
|
||||||
searches: 'Suchen',
|
searches: 'Suchen',
|
||||||
noSavedSearches: 'Noch keine gespeicherten Suchen',
|
noSavedSearches: 'Noch keine gespeicherten Suchen',
|
||||||
noSavedSearchesDesc:
|
noSavedSearchesDesc:
|
||||||
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
|
'Speichern Sie Ihre Filter und Kartenansicht, um genau dort weiterzumachen, wo Sie aufgehört haben.',
|
||||||
clickToRename: 'Klicken zum Umbenennen',
|
clickToRename: 'Klicken zum Umbenennen',
|
||||||
notesPlaceholder: 'Notiere deine Gedanken...',
|
notesPlaceholder: 'Notieren Sie Ihre Gedanken...',
|
||||||
deleteSearch: 'Suche löschen',
|
deleteSearch: 'Suche löschen',
|
||||||
deleteSearchConfirm:
|
deleteSearchConfirm:
|
||||||
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
'Möchten Sie diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||||
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
|
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
|
||||||
updating: 'Aktualisiere...',
|
updating: 'Aktualisiere...',
|
||||||
},
|
},
|
||||||
|
|
@ -1301,7 +1307,7 @@ const de: Translations = {
|
||||||
copyInviteLink: 'Einladungslink kopieren',
|
copyInviteLink: 'Einladungslink kopieren',
|
||||||
adminInvitesTitle: 'Admin-Einladungen (100% Rabatt)',
|
adminInvitesTitle: 'Admin-Einladungen (100% Rabatt)',
|
||||||
referralInvitesTitle: 'Empfehlungseinladungen (30% Rabatt)',
|
referralInvitesTitle: 'Empfehlungseinladungen (30% Rabatt)',
|
||||||
yourInviteLinks: 'Deine Einladungslinks',
|
yourInviteLinks: 'Ihre Einladungslinks',
|
||||||
noInvitesYet: 'Noch keine Einladungen erstellt',
|
noInvitesYet: 'Noch keine Einladungen erstellt',
|
||||||
link: 'Link',
|
link: 'Link',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
|
|
@ -1312,13 +1318,13 @@ const de: Translations = {
|
||||||
|
|
||||||
// ── Invite Page ────────────────────────────────────
|
// ── Invite Page ────────────────────────────────────
|
||||||
invitePage: {
|
invitePage: {
|
||||||
youreInvited: 'Du bist eingeladen!',
|
youreInvited: 'Sie sind eingeladen!',
|
||||||
specialOffer: 'Sonderangebot!',
|
specialOffer: 'Sonderangebot!',
|
||||||
invitedByFree: '{{name}} hat dich eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
invitedByFree: '{{name}} hat Sie eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
||||||
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
|
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
|
||||||
genericFreeInvite: 'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
genericFreeInvite: 'Sie wurden eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
||||||
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
|
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
|
||||||
exploreEvery: 'Finde Postleitzahlen, die zu deinem Leben passen',
|
exploreEvery: 'Finden Sie Postleitzahlen, die zu Ihrem Leben passen',
|
||||||
propertyInfo: 'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
|
propertyInfo: 'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
|
||||||
invalidInvite: 'Ungültige Einladung',
|
invalidInvite: 'Ungültige Einladung',
|
||||||
inviteAlreadyUsed: 'Einladung bereits verwendet',
|
inviteAlreadyUsed: 'Einladung bereits verwendet',
|
||||||
|
|
@ -1326,13 +1332,13 @@ const de: Translations = {
|
||||||
invalidInviteLink: 'Ungültiger Einladungslink',
|
invalidInviteLink: 'Ungültiger Einladungslink',
|
||||||
invalidInviteLinkDesc: 'Dieser Einladungslink ist ungültig oder abgelaufen.',
|
invalidInviteLinkDesc: 'Dieser Einladungslink ist ungültig oder abgelaufen.',
|
||||||
licenseActivated: 'Lizenz aktiviert!',
|
licenseActivated: 'Lizenz aktiviert!',
|
||||||
fullAccessGranted: 'Du hast jetzt vollen Zugang zu Perfect Postcode.',
|
fullAccessGranted: 'Sie haben jetzt vollen Zugang zu Perfect Postcode.',
|
||||||
activating: 'Wird aktiviert...',
|
activating: 'Wird aktiviert...',
|
||||||
activateLicense: 'Lizenz aktivieren',
|
activateLicense: 'Lizenz aktivieren',
|
||||||
claimDiscount: 'Rabatt einlösen',
|
claimDiscount: 'Rabatt einlösen',
|
||||||
registerToClaim: 'Registrieren zum Einlösen',
|
registerToClaim: 'Registrieren zum Einlösen',
|
||||||
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
|
youAlreadyHaveLicense: 'Sie haben bereits eine Lizenz',
|
||||||
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
|
accountHasFullAccess: 'Ihr Konto hat bereits vollen Zugang.',
|
||||||
failedToValidate: 'Einladungslink konnte nicht validiert werden',
|
failedToValidate: 'Einladungslink konnte nicht validiert werden',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ const en = {
|
||||||
total: 'Total',
|
total: 'Total',
|
||||||
min: 'min',
|
min: 'min',
|
||||||
max: 'max',
|
max: 'max',
|
||||||
|
minute: 'min',
|
||||||
or: 'or',
|
or: 'or',
|
||||||
area: 'Area',
|
area: 'Area',
|
||||||
properties: 'Properties',
|
properties: 'Properties',
|
||||||
|
|
@ -33,11 +34,17 @@ const en = {
|
||||||
clickForDetails: 'Click for details',
|
clickForDetails: 'Click for details',
|
||||||
property: 'property',
|
property: 'property',
|
||||||
propertiesPlural: 'properties',
|
propertiesPlural: 'properties',
|
||||||
|
bedsCount: '{{count}} bed',
|
||||||
|
bedsCount_other: '{{count}} beds',
|
||||||
|
bathsCount: '{{count}} bath',
|
||||||
|
bathsCount_other: '{{count}} baths',
|
||||||
places: 'places',
|
places: 'places',
|
||||||
noData: 'No data',
|
noData: 'No data',
|
||||||
allLow: 'All low',
|
allLow: 'All low',
|
||||||
connectingToServer: 'Connecting to server...',
|
connectingToServer: 'Connecting to server...',
|
||||||
closePane: 'Close pane',
|
closePane: 'Close pane',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Header / Nav ───────────────────────────────────
|
// ── Header / Nav ───────────────────────────────────
|
||||||
|
|
@ -653,7 +660,8 @@ const en = {
|
||||||
clearAll: 'Clear all',
|
clearAll: 'Clear all',
|
||||||
clearAllTitle: 'Clear all filters?',
|
clearAllTitle: 'Clear all filters?',
|
||||||
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
|
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
|
||||||
clearAllUpdatePrompt: 'Update <strong>{{name}}</strong> with your current filters before clearing?',
|
clearAllUpdatePrompt:
|
||||||
|
'Update <strong>{{name}}</strong> with your current filters before clearing?',
|
||||||
saveAndClear: 'Save & Clear',
|
saveAndClear: 'Save & Clear',
|
||||||
updateAndClear: 'Update & Clear',
|
updateAndClear: 'Update & Clear',
|
||||||
clearWithoutSaving: 'Clear without saving',
|
clearWithoutSaving: 'Clear without saving',
|
||||||
|
|
@ -672,6 +680,8 @@ const en = {
|
||||||
ethnicity: 'Ethnicity',
|
ethnicity: 'Ethnicity',
|
||||||
poiType: 'POI type',
|
poiType: 'POI type',
|
||||||
party: 'Party',
|
party: 'Party',
|
||||||
|
travelTimeKeywords:
|
||||||
|
'travel time journey commute car bicycle bike cycling walking walk transit transport public station tube train bus metro subway underground rail route',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Philosophy Popup ───────────────────────────────
|
// ── Philosophy Popup ───────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const fr: Translations = {
|
||||||
total: 'Total',
|
total: 'Total',
|
||||||
min: 'min',
|
min: 'min',
|
||||||
max: 'max',
|
max: 'max',
|
||||||
|
minute: 'min',
|
||||||
or: 'ou',
|
or: 'ou',
|
||||||
area: 'Zone',
|
area: 'Zone',
|
||||||
properties: 'Propriétés',
|
properties: 'Propriétés',
|
||||||
|
|
@ -35,11 +36,17 @@ const fr: Translations = {
|
||||||
clickForDetails: 'Cliquez pour les détails',
|
clickForDetails: 'Cliquez pour les détails',
|
||||||
property: 'propriété',
|
property: 'propriété',
|
||||||
propertiesPlural: 'propriétés',
|
propertiesPlural: 'propriétés',
|
||||||
|
bedsCount: '{{count}} ch.',
|
||||||
|
bedsCount_other: '{{count}} ch.',
|
||||||
|
bathsCount: '{{count}} sdb',
|
||||||
|
bathsCount_other: '{{count}} sdb',
|
||||||
places: 'lieux',
|
places: 'lieux',
|
||||||
noData: 'Aucune donnée',
|
noData: 'Aucune donnée',
|
||||||
allLow: 'Tout est faible',
|
allLow: 'Tout est faible',
|
||||||
connectingToServer: 'Connexion au serveur...',
|
connectingToServer: 'Connexion au serveur...',
|
||||||
closePane: 'Fermer le panneau',
|
closePane: 'Fermer le panneau',
|
||||||
|
yes: 'Oui',
|
||||||
|
no: 'Non',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Header / Nav ───────────────────────────────────
|
// ── Header / Nav ───────────────────────────────────
|
||||||
|
|
@ -318,7 +325,7 @@ const fr: Translations = {
|
||||||
"Oui. La recherche adaptée à l'école peut être combinée avec la criminalité, les parcs, les déplacements domicile-travail, le prix, la taille de la propriété et les services locaux.",
|
"Oui. La recherche adaptée à l'école peut être combinée avec la criminalité, les parcs, les déplacements domicile-travail, le prix, la taille de la propriété et les services locaux.",
|
||||||
'Is Ofsted the only school signal?': 'Ofsted est-il le seul signal scolaire ?',
|
'Is Ofsted the only school signal?': 'Ofsted est-il le seul signal scolaire ?',
|
||||||
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
|
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
|
||||||
"Aucun score isolé ne devrait décider d’un déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur l’école.",
|
'Aucun score isolé ne devrait décider d’un déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur l’école.',
|
||||||
'See where education, property, transport, and environment data comes from.':
|
'See where education, property, transport, and environment data comes from.':
|
||||||
"Découvrez d'où proviennent les données sur l'éducation, l'immobilier, les transports et l'environnement.",
|
"Découvrez d'où proviennent les données sur l'éducation, l'immobilier, les transports et l'environnement.",
|
||||||
'Explore school-aware searches': "Explorez les recherches adaptées à l'école",
|
'Explore school-aware searches': "Explorez les recherches adaptées à l'école",
|
||||||
|
|
@ -337,7 +344,7 @@ const fr: Translations = {
|
||||||
'Compare postcodes consistently across England.':
|
'Compare postcodes consistently across England.':
|
||||||
'Comparez les codes postaux de manière cohérente dans toute l’Angleterre.',
|
'Comparez les codes postaux de manière cohérente dans toute l’Angleterre.',
|
||||||
'Check the street before spending a viewing slot':
|
'Check the street before spending a viewing slot':
|
||||||
"Vérifiez la rue avant d’y consacrer un créneau de visite",
|
'Vérifiez la rue avant d’y consacrer un créneau de visite',
|
||||||
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
|
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
|
||||||
"Utilisez le vérificateur de code postal pour examiner l'historique des prix, le contexte local, les commodités, les écoles et les signaux environnementaux avant de consacrer du temps à votre visite.",
|
"Utilisez le vérificateur de code postal pour examiner l'historique des prix, le contexte local, les commodités, les écoles et les signaux environnementaux avant de consacrer du temps à votre visite.",
|
||||||
'Compare neighbouring postcodes': 'Comparez les codes postaux voisins',
|
'Compare neighbouring postcodes': 'Comparez les codes postaux voisins',
|
||||||
|
|
@ -702,6 +709,8 @@ const fr: Translations = {
|
||||||
ethnicity: 'Origine ethnique',
|
ethnicity: 'Origine ethnique',
|
||||||
poiType: 'Type de POI',
|
poiType: 'Type de POI',
|
||||||
party: 'Parti',
|
party: 'Parti',
|
||||||
|
travelTimeKeywords:
|
||||||
|
'temps trajet déplacement navette domicile-travail voiture vélo bicyclette cyclisme marche à pied piéton transports en commun public station gare train métro tramway bus RER itinéraire route travel time journey commute car bicycle bike walking transit transport station tube train',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Philosophy Popup ───────────────────────────────
|
// ── Philosophy Popup ───────────────────────────────
|
||||||
|
|
@ -843,8 +852,7 @@ const fr: Translations = {
|
||||||
lowerMinTo: 'Abaisser le minimum à {{value}}',
|
lowerMinTo: 'Abaisser le minimum à {{value}}',
|
||||||
raiseMaxTo: 'Augmenter le maximum à {{value}}',
|
raiseMaxTo: 'Augmenter le maximum à {{value}}',
|
||||||
allowCategory: 'Autoriser {{value}}',
|
allowCategory: 'Autoriser {{value}}',
|
||||||
missingFilterValue:
|
missingFilterValue: 'Aucune valeur pour ce filtre ; supprimez-le',
|
||||||
'Aucune valeur pour ce filtre ; supprimez-le',
|
|
||||||
noFilterDataShort: 'Aucune donnée',
|
noFilterDataShort: 'Aucune donnée',
|
||||||
travelTo: 'Trajet vers {{destination}}',
|
travelTo: 'Trajet vers {{destination}}',
|
||||||
viewProperties: 'Voir {{count}} propriétés',
|
viewProperties: 'Voir {{count}} propriétés',
|
||||||
|
|
@ -1297,7 +1305,8 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ── Invites Page ───────────────────────────────────
|
// ── Invites Page ───────────────────────────────────
|
||||||
invitesPage: {
|
invitesPage: {
|
||||||
inviteLinksLicensed: 'Les liens d’invitation sont disponibles pour les utilisateurs sous licence.',
|
inviteLinksLicensed:
|
||||||
|
'Les liens d’invitation sont disponibles pour les utilisateurs sous licence.',
|
||||||
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
||||||
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
||||||
generateFreeInvite: 'Générer un lien d’invitation gratuit',
|
generateFreeInvite: 'Générer un lien d’invitation gratuit',
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,9 @@ const hi: Translations = {
|
||||||
none: 'कोई नहीं',
|
none: 'कोई नहीं',
|
||||||
viewDataSource: 'डेटा स्रोत देखें',
|
viewDataSource: 'डेटा स्रोत देखें',
|
||||||
total: 'कुल',
|
total: 'कुल',
|
||||||
min: 'मिनट',
|
min: 'न्यूनतम',
|
||||||
max: 'अधिकतम',
|
max: 'अधिकतम',
|
||||||
|
minute: 'मिनट',
|
||||||
or: 'या',
|
or: 'या',
|
||||||
area: 'क्षेत्र',
|
area: 'क्षेत्र',
|
||||||
properties: 'संपत्तियां',
|
properties: 'संपत्तियां',
|
||||||
|
|
@ -34,11 +35,17 @@ const hi: Translations = {
|
||||||
clickForDetails: 'विवरण के लिए क्लिक करें',
|
clickForDetails: 'विवरण के लिए क्लिक करें',
|
||||||
property: 'संपत्ति',
|
property: 'संपत्ति',
|
||||||
propertiesPlural: 'संपत्तियां',
|
propertiesPlural: 'संपत्तियां',
|
||||||
|
bedsCount: '{{count}} बेड',
|
||||||
|
bedsCount_other: '{{count}} बेड',
|
||||||
|
bathsCount: '{{count}} बाथ',
|
||||||
|
bathsCount_other: '{{count}} बाथ',
|
||||||
places: 'स्थान',
|
places: 'स्थान',
|
||||||
noData: 'कोई डेटा नहीं',
|
noData: 'कोई डेटा नहीं',
|
||||||
allLow: 'सभी कम',
|
allLow: 'सभी कम',
|
||||||
connectingToServer: 'सर्वर से कनेक्ट हो रहा है...',
|
connectingToServer: 'सर्वर से कनेक्ट हो रहा है...',
|
||||||
closePane: 'पैन बंद करें',
|
closePane: 'पैन बंद करें',
|
||||||
|
yes: 'हाँ',
|
||||||
|
no: 'नहीं',
|
||||||
},
|
},
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
|
|
@ -670,6 +677,8 @@ const hi: Translations = {
|
||||||
ethnicity: 'जातीय समूह',
|
ethnicity: 'जातीय समूह',
|
||||||
poiType: 'POI प्रकार',
|
poiType: 'POI प्रकार',
|
||||||
party: 'पार्टी',
|
party: 'पार्टी',
|
||||||
|
travelTimeKeywords:
|
||||||
|
'यात्रा यात्रा समय सफर आवागमन कार गाड़ी साइकिल बाइक पैदल चलना सार्वजनिक परिवहन परिवहन यातायात स्टेशन ट्रेन रेल मेट्रो ट्यूब बस मार्ग travel time journey commute car bicycle bike walking transit transport station tube train',
|
||||||
},
|
},
|
||||||
|
|
||||||
philosophy: {
|
philosophy: {
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ const hu: Translations = {
|
||||||
none: 'Egyik sem',
|
none: 'Egyik sem',
|
||||||
viewDataSource: 'Adatforrás megtekintése',
|
viewDataSource: 'Adatforrás megtekintése',
|
||||||
total: 'Összesen',
|
total: 'Összesen',
|
||||||
min: 'perc',
|
min: 'min.',
|
||||||
max: 'max.',
|
max: 'max.',
|
||||||
|
minute: 'perc',
|
||||||
or: 'vagy',
|
or: 'vagy',
|
||||||
area: 'Terület',
|
area: 'Terület',
|
||||||
properties: 'Ingatlanok',
|
properties: 'Ingatlanok',
|
||||||
|
|
@ -35,11 +36,17 @@ const hu: Translations = {
|
||||||
clickForDetails: 'Kattints a részletekhez',
|
clickForDetails: 'Kattints a részletekhez',
|
||||||
property: 'ingatlan',
|
property: 'ingatlan',
|
||||||
propertiesPlural: 'ingatlanok',
|
propertiesPlural: 'ingatlanok',
|
||||||
|
bedsCount: '{{count}} hsz.',
|
||||||
|
bedsCount_other: '{{count}} hsz.',
|
||||||
|
bathsCount: '{{count}} fsz.',
|
||||||
|
bathsCount_other: '{{count}} fsz.',
|
||||||
places: 'helyek',
|
places: 'helyek',
|
||||||
noData: 'Nincs adat',
|
noData: 'Nincs adat',
|
||||||
allLow: 'Mind alacsony',
|
allLow: 'Mind alacsony',
|
||||||
connectingToServer: 'Kapcsolódás a szerverhez...',
|
connectingToServer: 'Kapcsolódás a szerverhez...',
|
||||||
closePane: 'Panel bezárása',
|
closePane: 'Panel bezárása',
|
||||||
|
yes: 'Igen',
|
||||||
|
no: 'Nem',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Header / Nav ───────────────────────────────────
|
// ── Header / Nav ───────────────────────────────────
|
||||||
|
|
@ -446,7 +453,8 @@ const hu: Translations = {
|
||||||
'Make commute constraints explicit': 'Tegye egyértelművé az ingázási korlátozásokat',
|
'Make commute constraints explicit': 'Tegye egyértelművé az ingázási korlátozásokat',
|
||||||
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
|
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
|
||||||
'Ha fontos a központ, állomás, kórház, egyetem vagy üzleti park elérése, először használja az utazási idő szűrőit, majd hasonlítsa össze a fennmaradó irányítószámokat ingatlanadatok alapján.',
|
'Ha fontos a központ, állomás, kórház, egyetem vagy üzleti park elérése, először használja az utazási idő szűrőit, majd hasonlítsa össze a fennmaradó irányítószámokat ingatlanadatok alapján.',
|
||||||
'Compare value, not just headline price': 'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
|
'Compare value, not just headline price':
|
||||||
|
'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
|
||||||
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
|
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
|
||||||
'Használja együtt az ár-, ingatlantípus- és alapterület-szűrőket. Ez segít megkülönböztetni az alacsonyabb költségű területeket azoktól a területektől, amelyek egyszerűen kisebb vagy eltérő otthonokat tartalmaznak.',
|
'Használja együtt az ár-, ingatlantípus- és alapterület-szűrőket. Ez segít megkülönböztetni az alacsonyabb költségű területeket azoktól a területektől, amelyek egyszerűen kisebb vagy eltérő otthonokat tartalmaznak.',
|
||||||
'Screen environmental and local-service signals':
|
'Screen environmental and local-service signals':
|
||||||
|
|
@ -686,6 +694,8 @@ const hu: Translations = {
|
||||||
ethnicity: 'Etnikai csoport',
|
ethnicity: 'Etnikai csoport',
|
||||||
poiType: 'POI-típus',
|
poiType: 'POI-típus',
|
||||||
party: 'Párt',
|
party: 'Párt',
|
||||||
|
travelTimeKeywords:
|
||||||
|
'utazási idő utazás ingázás menetidő autó kocsi kerékpár bicikli biciklizés gyaloglás gyalog séta tömegközlekedés közlekedés közösségi közlekedés állomás vonat metró villamos busz HÉV útvonal travel time journey commute car bicycle bike walking transit transport station tube train',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Philosophy Popup ───────────────────────────────
|
// ── Philosophy Popup ───────────────────────────────
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,9 @@ function AppErrorFallback() {
|
||||||
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
|
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
||||||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">Refresh the page to try again.</p>
|
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
||||||
|
Refresh the page to try again.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,13 @@ export function initBugsink(): boolean {
|
||||||
),
|
),
|
||||||
release:
|
release:
|
||||||
nonempty(runtimeConfig.release) ??
|
nonempty(runtimeConfig.release) ??
|
||||||
readBuildTimeString(typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined),
|
readBuildTimeString(
|
||||||
|
typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined
|
||||||
|
),
|
||||||
sendDefaultPii:
|
sendDefaultPii:
|
||||||
runtimeConfig.sendDefaultPii ??
|
runtimeConfig.sendDefaultPii ??
|
||||||
readBuildTimeBoolean(
|
readBuildTimeBoolean(
|
||||||
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean'
|
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean' ? __BUGSINK_SEND_DEFAULT_PII__ : undefined
|
||||||
? __BUGSINK_SEND_DEFAULT_PII__
|
|
||||||
: undefined
|
|
||||||
),
|
),
|
||||||
tracesSampleRate: 0,
|
tracesSampleRate: 0,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,6 @@ export interface ActualListing {
|
||||||
export interface ActualListingsResponse {
|
export interface ActualListingsResponse {
|
||||||
listings: ActualListing[];
|
listings: ActualListing[];
|
||||||
total: number;
|
total: number;
|
||||||
truncated: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface POICategoryGroup {
|
export interface POICategoryGroup {
|
||||||
|
|
@ -198,7 +197,9 @@ export interface Property {
|
||||||
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
|
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HexagonPropertiesResponse {
|
/** Shared paginated list of `Property` records returned by both
|
||||||
|
* `/api/hexagon-properties` and `/api/postcode-properties`. */
|
||||||
|
export interface PropertyListResponse {
|
||||||
properties: Property[];
|
properties: Property[];
|
||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
@ -206,6 +207,10 @@ export interface HexagonPropertiesResponse {
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `PropertyListResponse`. Kept as an alias during the
|
||||||
|
* rollout so consumers can migrate without breaking. */
|
||||||
|
export type HexagonPropertiesResponse = PropertyListResponse;
|
||||||
|
|
||||||
export interface NumericFeatureStats {
|
export interface NumericFeatureStats {
|
||||||
name: string;
|
name: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||||
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
|
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
|
||||||
|
const TerserPlugin = require('terser-webpack-plugin');
|
||||||
|
const CompressionPlugin = require('compression-webpack-plugin');
|
||||||
|
const zlib = require('zlib');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
const packageJson = require('./package.json');
|
const packageJson = require('./package.json');
|
||||||
|
|
@ -150,11 +153,49 @@ module.exports = (env, argv) => {
|
||||||
filename: '[name].[contenthash:8].css',
|
filename: '[name].[contenthash:8].css',
|
||||||
chunkFilename: '[name].[contenthash:8].css',
|
chunkFilename: '[name].[contenthash:8].css',
|
||||||
}),
|
}),
|
||||||
|
new CompressionPlugin({
|
||||||
|
filename: '[path][base].gz',
|
||||||
|
algorithm: 'gzip',
|
||||||
|
test: /\.(js|css|html|svg|json|wasm)$/,
|
||||||
|
threshold: 1024,
|
||||||
|
minRatio: 0.8,
|
||||||
|
}),
|
||||||
|
new CompressionPlugin({
|
||||||
|
filename: '[path][base].br',
|
||||||
|
algorithm: 'brotliCompress',
|
||||||
|
test: /\.(js|css|html|svg|json|wasm)$/,
|
||||||
|
compressionOptions: {
|
||||||
|
params: {
|
||||||
|
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
threshold: 1024,
|
||||||
|
minRatio: 0.8,
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
: [new ReactRefreshWebpackPlugin()]),
|
: [new ReactRefreshWebpackPlugin()]),
|
||||||
],
|
],
|
||||||
optimization: isProduction
|
optimization: isProduction
|
||||||
? {
|
? {
|
||||||
|
minimize: true,
|
||||||
|
minimizer: [
|
||||||
|
new TerserPlugin({
|
||||||
|
parallel: true,
|
||||||
|
extractComments: false,
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true,
|
||||||
|
drop_debugger: true,
|
||||||
|
passes: 2,
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
comments: false,
|
||||||
|
},
|
||||||
|
keep_classnames: true,
|
||||||
|
keep_fnames: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
splitChunks: {
|
splitChunks: {
|
||||||
chunks: 'all',
|
chunks: 'all',
|
||||||
cacheGroups: {
|
cacheGroups: {
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,19 @@ Reuses the same england-latest.osm.pbf as pois.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import osmium
|
import osmium
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from pyproj import Transformer
|
from pyproj import Transformer
|
||||||
from shapely import wkb
|
from shapely import wkb
|
||||||
|
from shapely.errors import GEOSException
|
||||||
from shapely.geometry import MultiPolygon, Polygon
|
from shapely.geometry import MultiPolygon, Polygon
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
MIN_AREA_SQM = 5_000 # ~70m x 70m — skip pocket parks and small ponds
|
MIN_AREA_SQM = 5_000 # ~70m x 70m — skip pocket parks and small ponds
|
||||||
|
|
||||||
|
|
@ -68,6 +72,7 @@ class GreenspaceHandler(osmium.SimpleHandler):
|
||||||
self._wkb_factory = osmium.geom.WKBFactory()
|
self._wkb_factory = osmium.geom.WKBFactory()
|
||||||
self._progress = progress
|
self._progress = progress
|
||||||
self.geometries = []
|
self.geometries = []
|
||||||
|
self.skipped_areas = 0
|
||||||
|
|
||||||
def area(self, a):
|
def area(self, a):
|
||||||
self._progress.update(1)
|
self._progress.update(1)
|
||||||
|
|
@ -76,7 +81,14 @@ class GreenspaceHandler(osmium.SimpleHandler):
|
||||||
try:
|
try:
|
||||||
wkb_data = self._wkb_factory.create_multipolygon(a)
|
wkb_data = self._wkb_factory.create_multipolygon(a)
|
||||||
geom = wkb.loads(wkb_data, hex=True)
|
geom = wkb.loads(wkb_data, hex=True)
|
||||||
except Exception:
|
except (RuntimeError, GEOSException, ValueError) as exc:
|
||||||
|
self.skipped_areas += 1
|
||||||
|
logger.warning(
|
||||||
|
"Failed to assemble multipolygon for area orig_id=%s (%s)",
|
||||||
|
getattr(a, "orig_id", lambda: "?")(),
|
||||||
|
type(exc).__name__,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if geom.is_empty or not geom.is_valid:
|
if geom.is_empty or not geom.is_valid:
|
||||||
|
|
@ -113,6 +125,11 @@ def main():
|
||||||
print(
|
print(
|
||||||
f"Found {len(handler.geometries)} greenspace/water polygons >= {MIN_AREA_SQM} sqm"
|
f"Found {len(handler.geometries)} greenspace/water polygons >= {MIN_AREA_SQM} sqm"
|
||||||
)
|
)
|
||||||
|
if handler.skipped_areas:
|
||||||
|
logger.warning(
|
||||||
|
"Skipped %d areas due to geometry assembly errors",
|
||||||
|
handler.skipped_areas,
|
||||||
|
)
|
||||||
|
|
||||||
# Merge overlapping geometries per 10km grid cell for efficiency
|
# Merge overlapping geometries per 10km grid cell for efficiency
|
||||||
if handler.geometries:
|
if handler.geometries:
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ License: Open Government Licence v3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -21,10 +22,13 @@ import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import shapefile as shp
|
import shapefile as shp
|
||||||
from pyproj import Transformer
|
from pyproj import Transformer
|
||||||
|
from shapely.errors import GEOSException
|
||||||
from shapely.geometry import shape as to_shapely
|
from shapely.geometry import shape as to_shapely
|
||||||
|
|
||||||
from pipeline.utils.download import download, extract_zip
|
from pipeline.utils.download import download, extract_zip
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
URL = "https://api.os.uk/downloads/v1/products/OpenGreenspace/downloads?area=GB&format=ESRI%C2%AE+Shapefile&redirect"
|
URL = "https://api.os.uk/downloads/v1/products/OpenGreenspace/downloads?area=GB&format=ESRI%C2%AE+Shapefile&redirect"
|
||||||
|
|
||||||
_to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True)
|
_to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True)
|
||||||
|
|
@ -76,6 +80,7 @@ def _read_access_points(
|
||||||
lngs: list[float] = []
|
lngs: list[float] = []
|
||||||
categories: list[str] = []
|
categories: list[str] = []
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
error_skipped = 0
|
||||||
|
|
||||||
for sr in reader.shapeRecords():
|
for sr in reader.shapeRecords():
|
||||||
site_id = sr.record[ref_idx]
|
site_id = sr.record[ref_idx]
|
||||||
|
|
@ -89,7 +94,13 @@ def _read_access_points(
|
||||||
if geom.is_empty:
|
if geom.is_empty:
|
||||||
continue
|
continue
|
||||||
lng, lat = _to_wgs84.transform(geom.x, geom.y)
|
lng, lat = _to_wgs84.transform(geom.x, geom.y)
|
||||||
except Exception:
|
except (GEOSException, ValueError, AttributeError, TypeError):
|
||||||
|
error_skipped += 1
|
||||||
|
logger.warning(
|
||||||
|
"Failed to process access point geometry for site_id=%s",
|
||||||
|
site_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lats.append(lat)
|
lats.append(lat)
|
||||||
|
|
@ -98,6 +109,11 @@ def _read_access_points(
|
||||||
|
|
||||||
if skipped:
|
if skipped:
|
||||||
print(f" Skipped {skipped:,} access points with unknown site ID")
|
print(f" Skipped {skipped:,} access points with unknown site ID")
|
||||||
|
if error_skipped:
|
||||||
|
logger.warning(
|
||||||
|
"Skipped %d access point records due to geometry/transform errors",
|
||||||
|
error_skipped,
|
||||||
|
)
|
||||||
|
|
||||||
return lats, lngs, categories
|
return lats, lngs, categories
|
||||||
|
|
||||||
|
|
@ -116,6 +132,7 @@ def _read_site_centroids(
|
||||||
lats: list[float] = []
|
lats: list[float] = []
|
||||||
lngs: list[float] = []
|
lngs: list[float] = []
|
||||||
categories: list[str] = []
|
categories: list[str] = []
|
||||||
|
error_skipped = 0
|
||||||
|
|
||||||
for sr in reader.shapeRecords():
|
for sr in reader.shapeRecords():
|
||||||
site_id = sr.record[id_idx]
|
site_id = sr.record[id_idx]
|
||||||
|
|
@ -129,13 +146,25 @@ def _read_site_centroids(
|
||||||
continue
|
continue
|
||||||
centroid = geom.centroid
|
centroid = geom.centroid
|
||||||
lng, lat = _to_wgs84.transform(centroid.x, centroid.y)
|
lng, lat = _to_wgs84.transform(centroid.x, centroid.y)
|
||||||
except Exception:
|
except (GEOSException, ValueError, AttributeError, TypeError):
|
||||||
|
error_skipped += 1
|
||||||
|
logger.warning(
|
||||||
|
"Failed to compute centroid for site_id=%s",
|
||||||
|
site_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lats.append(lat)
|
lats.append(lat)
|
||||||
lngs.append(lng)
|
lngs.append(lng)
|
||||||
categories.append(func)
|
categories.append(func)
|
||||||
|
|
||||||
|
if error_skipped:
|
||||||
|
logger.warning(
|
||||||
|
"Skipped %d site centroid records due to geometry/transform errors",
|
||||||
|
error_skipped,
|
||||||
|
)
|
||||||
|
|
||||||
return lats, lngs, categories
|
return lats, lngs, categories
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
|
|
||||||
import osmium
|
import osmium
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from shapely import make_valid
|
from shapely import make_valid
|
||||||
|
from shapely.errors import GEOSException
|
||||||
from shapely.geometry import Point
|
from shapely.geometry import Point
|
||||||
from shapely.wkb import loads as load_wkb
|
from shapely.wkb import loads as load_wkb
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
@ -17,6 +19,8 @@ from pipeline.utils.england_geometry import (
|
||||||
load_england_polygon,
|
load_england_polygon,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BATCH_SIZE = 50_000
|
BATCH_SIZE = 50_000
|
||||||
|
|
||||||
MIN_OCCURENCE_COUNT = 20
|
MIN_OCCURENCE_COUNT = 20
|
||||||
|
|
@ -57,6 +61,7 @@ class POIHandler(osmium.SimpleHandler):
|
||||||
self._tmp_dir = tmp_dir
|
self._tmp_dir = tmp_dir
|
||||||
self._batch_num = 0
|
self._batch_num = 0
|
||||||
self.poi_count = 0
|
self.poi_count = 0
|
||||||
|
self.skipped_areas = 0
|
||||||
self._progress = progress
|
self._progress = progress
|
||||||
self._england = england_polygon
|
self._england = england_polygon
|
||||||
self._wkb_factory = osmium.geom.WKBFactory()
|
self._wkb_factory = osmium.geom.WKBFactory()
|
||||||
|
|
@ -120,7 +125,14 @@ class POIHandler(osmium.SimpleHandler):
|
||||||
def _point_from_area(self, area: osmium.osm.Area) -> tuple[float, float] | None:
|
def _point_from_area(self, area: osmium.osm.Area) -> tuple[float, float] | None:
|
||||||
try:
|
try:
|
||||||
geom = load_wkb(self._wkb_factory.create_multipolygon(area), hex=True)
|
geom = load_wkb(self._wkb_factory.create_multipolygon(area), hex=True)
|
||||||
except Exception:
|
except (RuntimeError, GEOSException, ValueError) as exc:
|
||||||
|
self.skipped_areas += 1
|
||||||
|
logger.warning(
|
||||||
|
"Failed to build multipolygon WKB for area orig_id=%s (%s)",
|
||||||
|
getattr(area, "orig_id", lambda: "?")(),
|
||||||
|
type(exc).__name__,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
return _representative_lat_lon(geom, self._england)
|
return _representative_lat_lon(geom, self._england)
|
||||||
|
|
||||||
|
|
@ -185,6 +197,11 @@ def main() -> None:
|
||||||
handler._flush_batch() # write any remaining POIs
|
handler._flush_batch() # write any remaining POIs
|
||||||
|
|
||||||
print(f"Extracted {handler.poi_count:,} POIs")
|
print(f"Extracted {handler.poi_count:,} POIs")
|
||||||
|
if handler.skipped_areas:
|
||||||
|
logger.warning(
|
||||||
|
"Skipped %d areas due to geometry assembly errors",
|
||||||
|
handler.skipped_areas,
|
||||||
|
)
|
||||||
|
|
||||||
batch_files = sorted(tmp_dir.glob("batch_*.parquet"))
|
batch_files = sorted(tmp_dir.glob("batch_*.parquet"))
|
||||||
df = pl.concat([pl.scan_parquet(f) for f in batch_files])
|
df = pl.concat([pl.scan_parquet(f) for f in batch_files])
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ dependencies = [
|
||||||
"numpy>=1.26.0",
|
"numpy>=1.26.0",
|
||||||
"pandas>=2.0.0",
|
"pandas>=2.0.0",
|
||||||
"plotly>=6.5.2",
|
"plotly>=6.5.2",
|
||||||
"polars>=1.37.1",
|
"polars>=1.37.1,<2.0.0",
|
||||||
"pyarrow>=15.0.0",
|
"pyarrow>=15.0.0",
|
||||||
"tqdm>=4.67.1",
|
"tqdm>=4.67.1",
|
||||||
"fastexcel>=0.19.0",
|
"fastexcel>=0.19.0",
|
||||||
|
|
@ -26,8 +26,6 @@ dependencies = [
|
||||||
"pillow>=12.0.0",
|
"pillow>=12.0.0",
|
||||||
"folium>=0.20.0",
|
"folium>=0.20.0",
|
||||||
"pyogrio>=0.12.1",
|
"pyogrio>=0.12.1",
|
||||||
"httpx",
|
|
||||||
"polars",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,7 @@ lto = "thin"
|
||||||
|
|
||||||
[profile.production]
|
[profile.production]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
lto = true
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
|
|
|
||||||
99
server-rs/src/api_error.rs
Normal file
99
server-rs/src/api_error.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Uniform API error type. Implements `IntoResponse` and serializes as JSON so
|
||||||
|
/// every endpoint returns a structurally-identical error body the frontend
|
||||||
|
/// can rely on, regardless of which route raised it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ApiError {
|
||||||
|
BadRequest(String),
|
||||||
|
Unauthorized,
|
||||||
|
Forbidden(String),
|
||||||
|
NotFound(String),
|
||||||
|
Conflict(String),
|
||||||
|
Internal(String),
|
||||||
|
BadGateway(String),
|
||||||
|
ServiceUnavailable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrorBody {
|
||||||
|
error: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
fn status(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
Self::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
Self::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
|
Self::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
Self::Conflict(_) => StatusCode::CONFLICT,
|
||||||
|
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Self::BadGateway(_) => StatusCode::BAD_GATEWAY,
|
||||||
|
Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::BadRequest(_) => "bad_request",
|
||||||
|
Self::Unauthorized => "unauthorized",
|
||||||
|
Self::Forbidden(_) => "forbidden",
|
||||||
|
Self::NotFound(_) => "not_found",
|
||||||
|
Self::Conflict(_) => "conflict",
|
||||||
|
Self::Internal(_) => "internal_error",
|
||||||
|
Self::BadGateway(_) => "upstream_error",
|
||||||
|
Self::ServiceUnavailable(_) => "service_unavailable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Unauthorized => "Authentication required".to_string(),
|
||||||
|
Self::BadRequest(m)
|
||||||
|
| Self::Forbidden(m)
|
||||||
|
| Self::NotFound(m)
|
||||||
|
| Self::Conflict(m)
|
||||||
|
| Self::Internal(m)
|
||||||
|
| Self::BadGateway(m)
|
||||||
|
| Self::ServiceUnavailable(m) => m.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = self.status();
|
||||||
|
let body = ErrorBody {
|
||||||
|
error: self.code().to_string(),
|
||||||
|
message: self.message(),
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge from the legacy `(StatusCode, String)` tuples to the new error type
|
||||||
|
/// so partially-migrated routes keep compiling while the migration progresses.
|
||||||
|
impl From<(StatusCode, String)> for ApiError {
|
||||||
|
fn from((status, message): (StatusCode, String)) -> Self {
|
||||||
|
match status {
|
||||||
|
StatusCode::BAD_REQUEST => Self::BadRequest(message),
|
||||||
|
StatusCode::UNAUTHORIZED => Self::Unauthorized,
|
||||||
|
StatusCode::FORBIDDEN => Self::Forbidden(message),
|
||||||
|
StatusCode::NOT_FOUND => Self::NotFound(message),
|
||||||
|
StatusCode::CONFLICT => Self::Conflict(message),
|
||||||
|
StatusCode::BAD_GATEWAY => Self::BadGateway(message),
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE => Self::ServiceUnavailable(message),
|
||||||
|
_ => Self::Internal(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for ApiError {
|
||||||
|
fn from(message: String) -> Self {
|
||||||
|
Self::Internal(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,10 @@ pub const MAX_CELLS_PER_REQUEST: usize = 200000;
|
||||||
pub const MAX_POIS_PER_REQUEST: usize = 3000;
|
pub const MAX_POIS_PER_REQUEST: usize = 3000;
|
||||||
|
|
||||||
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
||||||
|
pub const DEFAULT_ACTUAL_LISTINGS_LIMIT: usize = 500;
|
||||||
|
pub const MAX_ACTUAL_LISTINGS_LIMIT: usize = 2000;
|
||||||
|
pub const MAX_PLACES_LIMIT: usize = 20;
|
||||||
|
pub const DEFAULT_PLACES_LIMIT: usize = 7;
|
||||||
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
|
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
|
||||||
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
|
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,32 @@ fn extract_opt_datetime_iso(df: &DataFrame, name: &str) -> Result<Vec<Option<Str
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
|
||||||
|
let column = df
|
||||||
|
.column(name)
|
||||||
|
.with_context(|| format!("Missing column '{name}'"))?;
|
||||||
|
let list = column
|
||||||
|
.list()
|
||||||
|
.with_context(|| format!("Column '{name}' is not a list column"))?;
|
||||||
|
let mut out = Vec::with_capacity(list.len());
|
||||||
|
for series_opt in list.into_iter() {
|
||||||
|
let entries = match series_opt {
|
||||||
|
Some(series) => {
|
||||||
|
let strings = series.str().with_context(|| {
|
||||||
|
format!("Column '{name}' list inner is not a string column")
|
||||||
|
})?;
|
||||||
|
strings
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|value| value.map(ToString::to_string))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
out.push(entries);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -298,29 +324,3 @@ mod tests {
|
||||||
assert!(!any_listing.listing_url.is_empty());
|
assert!(!any_listing.listing_url.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
|
|
||||||
let column = df
|
|
||||||
.column(name)
|
|
||||||
.with_context(|| format!("Missing column '{name}'"))?;
|
|
||||||
let list = column
|
|
||||||
.list()
|
|
||||||
.with_context(|| format!("Column '{name}' is not a list column"))?;
|
|
||||||
let mut out = Vec::with_capacity(list.len());
|
|
||||||
for series_opt in list.into_iter() {
|
|
||||||
let entries = match series_opt {
|
|
||||||
Some(series) => {
|
|
||||||
let strings = series.str().with_context(|| {
|
|
||||||
format!("Column '{name}' list inner is not a string column")
|
|
||||||
})?;
|
|
||||||
strings
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|value| value.map(ToString::to_string))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
out.push(entries);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,10 @@ impl PlaceData {
|
||||||
let lon = extract_f32_col(&df, "lon")?;
|
let lon = extract_f32_col(&df, "lon")?;
|
||||||
let population: Vec<u32> = if df.column("population").is_ok() {
|
let population: Vec<u32> = if df.column("population").is_ok() {
|
||||||
let pop_f32 = extract_f32_col(&df, "population")?;
|
let pop_f32 = extract_f32_col(&df, "population")?;
|
||||||
pop_f32.iter().map(|&val| val.max(0.0) as u32).collect()
|
pop_f32
|
||||||
|
.iter()
|
||||||
|
.map(|&val| val.max(0.0).min(u32::MAX as f32) as u32)
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
vec![0; row_count]
|
vec![0; row_count]
|
||||||
};
|
};
|
||||||
|
|
@ -419,11 +422,11 @@ mod tests {
|
||||||
|
|
||||||
fn test_city_rows() -> [(&'static str, f32, f32, u32); 5] {
|
fn test_city_rows() -> [(&'static str, f32, f32, u32); 5] {
|
||||||
[
|
[
|
||||||
("London", 51.5074456, -0.1277653, 8_908_083),
|
("London", 51.507_446, -0.1277653, 8_908_083),
|
||||||
("Westminster", 51.4973206, -0.137149, 211_365),
|
("Westminster", 51.497_322, -0.137149, 211_365),
|
||||||
("City of London", 51.5156177, -0.0919983, 10_847),
|
("City of London", 51.515_617, -0.0919983, 10_847),
|
||||||
("Cambridge", 52.2055314, 0.1186637, 145_818),
|
("Cambridge", 52.205_532, 0.1186637, 145_818),
|
||||||
("Oxford", 51.7520131, -1.2578499, 165_000),
|
("Oxford", 51.752_014, -1.2578499, 165_000),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -503,7 +506,7 @@ mod tests {
|
||||||
let cities = test_city_candidates();
|
let cities = test_city_candidates();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
nearest_display_city(51.3713049, -0.101957, &cities),
|
nearest_display_city(51.371_304, -0.101957, &cities),
|
||||||
Some("London")
|
Some("London")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -513,7 +516,7 @@ mod tests {
|
||||||
let cities = test_city_candidates();
|
let cities = test_city_candidates();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
nearest_display_city(52.1277704, -0.0813098, &cities),
|
nearest_display_city(52.127_77, -0.0813098, &cities),
|
||||||
Some("Cambridge")
|
Some("Cambridge")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1014,6 +1014,22 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Feature names that describe an individual property (price, size, type, etc.) rather
|
||||||
|
/// than the surrounding area. Use this to skip filters that should not exclude live
|
||||||
|
/// listings on the map even though they hide aggregated property rows.
|
||||||
|
pub fn property_level_feature_names() -> Vec<&'static str> {
|
||||||
|
const PROPERTY_GROUPS: &[&str] = &["Properties", "Property prices"];
|
||||||
|
FEATURE_GROUPS
|
||||||
|
.iter()
|
||||||
|
.filter(|group| PROPERTY_GROUPS.contains(&group.name))
|
||||||
|
.flat_map(|group| group.features.iter())
|
||||||
|
.map(|feature| match feature {
|
||||||
|
Feature::Numeric(c) => c.name,
|
||||||
|
Feature::Enum(c) => c.name,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Flat ordered list of all numeric feature names (follows group order).
|
/// Flat ordered list of all numeric feature names (follows group order).
|
||||||
pub fn all_numeric_feature_names() -> Vec<&'static str> {
|
pub fn all_numeric_feature_names() -> Vec<&'static str> {
|
||||||
FEATURE_GROUPS
|
FEATURE_GROUPS
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#![allow(clippy::min_ident_chars)]
|
#![allow(clippy::min_ident_chars)]
|
||||||
|
|
||||||
mod aggregation;
|
mod aggregation;
|
||||||
|
mod api_error;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod bugsink;
|
mod bugsink;
|
||||||
mod checkout_sessions;
|
mod checkout_sessions;
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,239 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
|
use rustc_hash::FxHashSet;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::api_error::ApiError;
|
||||||
|
use crate::consts::{DEFAULT_ACTUAL_LISTINGS_LIMIT, MAX_ACTUAL_LISTINGS_LIMIT};
|
||||||
use crate::data::ActualListing;
|
use crate::data::ActualListing;
|
||||||
use crate::parsing::require_bounds;
|
use crate::features::property_level_feature_names;
|
||||||
use crate::state::SharedState;
|
use crate::parsing::{
|
||||||
|
parse_filters_with_poi, require_bounds, row_passes_filters, row_passes_poi_filters,
|
||||||
|
};
|
||||||
|
use crate::state::{AppState, SharedState};
|
||||||
|
|
||||||
const MAX_RESULTS: usize = 5000;
|
use super::travel_time::{parse_optional_travel, row_passes_travel_filters, TravelEntry};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ActualListingsParams {
|
pub struct ActualListingsParams {
|
||||||
bounds: Option<String>,
|
bounds: Option<String>,
|
||||||
|
/// `;;`-separated filters: `name:min:max;;...`
|
||||||
|
filters: Option<String>,
|
||||||
|
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`
|
||||||
|
travel: Option<String>,
|
||||||
|
/// Page size — defaults to DEFAULT_ACTUAL_LISTINGS_LIMIT, capped at
|
||||||
|
/// MAX_ACTUAL_LISTINGS_LIMIT.
|
||||||
|
limit: Option<usize>,
|
||||||
|
/// Number of results to skip. Defaults to 0.
|
||||||
|
offset: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ActualListingsResponse {
|
pub struct ActualListingsResponse {
|
||||||
pub listings: Vec<ActualListing>,
|
pub listings: Vec<ActualListing>,
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
|
pub limit: usize,
|
||||||
|
pub offset: usize,
|
||||||
pub truncated: bool,
|
pub truncated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_actual_listings(
|
pub async fn get_actual_listings(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
Query(params): Query<ActualListingsParams>,
|
Query(params): Query<ActualListingsParams>,
|
||||||
) -> Result<Json<ActualListingsResponse>, (StatusCode, String)> {
|
) -> Result<Json<ActualListingsResponse>, ApiError> {
|
||||||
let state = shared.load_state();
|
let state = shared.load_state();
|
||||||
|
let limit = params
|
||||||
|
.limit
|
||||||
|
.unwrap_or(DEFAULT_ACTUAL_LISTINGS_LIMIT)
|
||||||
|
.min(MAX_ACTUAL_LISTINGS_LIMIT);
|
||||||
|
let offset = params.offset.unwrap_or(0);
|
||||||
let Some(actual_listings) = state.actual_listings.clone() else {
|
let Some(actual_listings) = state.actual_listings.clone() else {
|
||||||
return Ok(Json(ActualListingsResponse {
|
return Ok(Json(ActualListingsResponse {
|
||||||
listings: Vec::new(),
|
listings: Vec::new(),
|
||||||
total: 0,
|
total: 0,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
let (south, west, north, east) = require_bounds(params.bounds)?;
|
let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
|
||||||
|
|
||||||
let response = tokio::task::spawn_blocking(move || {
|
let quant = state.data.quant_ref();
|
||||||
let t0 = std::time::Instant::now();
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||||
let row_indices = actual_listings.grid.query(south, west, north, east);
|
let (mut parsed_filters, mut parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
||||||
let total = row_indices.len();
|
params.filters.as_deref(),
|
||||||
let truncated = total > MAX_RESULTS;
|
&state.feature_name_to_index,
|
||||||
|
&state.data.enum_values,
|
||||||
|
&quant,
|
||||||
|
&state.data.poi_metrics.name_to_index,
|
||||||
|
&poi_quant,
|
||||||
|
)
|
||||||
|
.map_err(ApiError::BadRequest)?;
|
||||||
|
|
||||||
let mut listings: Vec<ActualListing> = row_indices
|
// Drop property-level filters (price, sqm, build year, beds, type, etc.) so they
|
||||||
.iter()
|
// don't hide live listings — those are individual-property concerns the user can
|
||||||
.take(MAX_RESULTS)
|
// judge from the pin itself. We only keep area/postcode-level filters here.
|
||||||
.map(|&row| actual_listings.listing_at(row as usize))
|
let property_level_idxs: FxHashSet<usize> = property_level_feature_names()
|
||||||
.collect();
|
.into_iter()
|
||||||
|
.filter_map(|name| state.feature_name_to_index.get(name).copied())
|
||||||
|
.collect();
|
||||||
|
parsed_filters.retain(|f| !property_level_idxs.contains(&f.feat_idx));
|
||||||
|
parsed_enum_filters.retain(|f| !property_level_idxs.contains(&f.feat_idx));
|
||||||
|
|
||||||
// Sort newest first so the most relevant pins win when the viewport is busy.
|
let travel_entries =
|
||||||
listings.sort_by(|left, right| {
|
parse_optional_travel(params.travel.as_deref()).map_err(ApiError::BadRequest)?;
|
||||||
right
|
|
||||||
.listing_date_iso
|
|
||||||
.cmp(&left.listing_date_iso)
|
|
||||||
.then_with(|| right.asking_price.cmp(&left.asking_price))
|
|
||||||
});
|
|
||||||
|
|
||||||
let elapsed = t0.elapsed();
|
let has_area_filters = !parsed_filters.is_empty()
|
||||||
info!(
|
|| !parsed_enum_filters.is_empty()
|
||||||
results = listings.len(),
|
|| !parsed_poi_filters.is_empty()
|
||||||
total,
|
|| !travel_entries.is_empty();
|
||||||
truncated,
|
|
||||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
|
||||||
"GET /api/actual-listings"
|
|
||||||
);
|
|
||||||
|
|
||||||
ActualListingsResponse {
|
let state_clone = state.clone();
|
||||||
listings,
|
let response =
|
||||||
total,
|
tokio::task::spawn_blocking(move || -> Result<ActualListingsResponse, String> {
|
||||||
truncated,
|
let t0 = std::time::Instant::now();
|
||||||
}
|
|
||||||
})
|
let passing_postcodes = if has_area_filters {
|
||||||
.await
|
Some(compute_passing_postcodes(
|
||||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
&state_clone,
|
||||||
|
south,
|
||||||
|
west,
|
||||||
|
north,
|
||||||
|
east,
|
||||||
|
&parsed_filters,
|
||||||
|
&parsed_enum_filters,
|
||||||
|
&parsed_poi_filters,
|
||||||
|
&travel_entries,
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let row_indices = actual_listings.grid.query(south, west, north, east);
|
||||||
|
let total_in_bounds = row_indices.len();
|
||||||
|
|
||||||
|
// Build (row, sort_key) pairs so we can sort by index without
|
||||||
|
// materializing the full ActualListing for every matching row.
|
||||||
|
let mut matching_rows: Vec<usize> = row_indices
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&row_idx| {
|
||||||
|
let row = row_idx as usize;
|
||||||
|
if let Some(allowed) = passing_postcodes.as_ref() {
|
||||||
|
if !allowed.contains(actual_listings.postcode[row].as_str()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(row)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_matching = matching_rows.len();
|
||||||
|
|
||||||
|
matching_rows.sort_by(|&left, &right| {
|
||||||
|
actual_listings.listing_date_iso[right]
|
||||||
|
.cmp(&actual_listings.listing_date_iso[left])
|
||||||
|
.then_with(|| {
|
||||||
|
actual_listings.asking_price[right].cmp(&actual_listings.asking_price[left])
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let truncated = total_matching > offset.saturating_add(limit);
|
||||||
|
let listings: Vec<ActualListing> = matching_rows
|
||||||
|
.iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.map(|&row| actual_listings.listing_at(row))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let elapsed = t0.elapsed();
|
||||||
|
info!(
|
||||||
|
results = listings.len(),
|
||||||
|
total = total_matching,
|
||||||
|
total_in_bounds,
|
||||||
|
offset,
|
||||||
|
filtered = passing_postcodes.is_some(),
|
||||||
|
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||||
|
"GET /api/actual-listings"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ActualListingsResponse {
|
||||||
|
listings,
|
||||||
|
total: total_matching,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
truncated,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| ApiError::Internal(error.to_string()))?
|
||||||
|
.map_err(ApiError::Internal)?;
|
||||||
|
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn compute_passing_postcodes(
|
||||||
|
state: &AppState,
|
||||||
|
south: f64,
|
||||||
|
west: f64,
|
||||||
|
north: f64,
|
||||||
|
east: f64,
|
||||||
|
parsed_filters: &[crate::parsing::ParsedFilter],
|
||||||
|
parsed_enum_filters: &[crate::parsing::ParsedEnumFilter],
|
||||||
|
parsed_poi_filters: &[crate::parsing::ParsedPoiFilter],
|
||||||
|
travel_entries: &[TravelEntry],
|
||||||
|
) -> Result<FxHashSet<String>, String> {
|
||||||
|
let num_features = state.data.num_features;
|
||||||
|
let feature_data = &state.data.feature_data;
|
||||||
|
let poi_metrics = &state.data.poi_metrics;
|
||||||
|
let has_poi_filters = !parsed_poi_filters.is_empty();
|
||||||
|
|
||||||
|
let travel_data = if travel_entries.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let store = &state.travel_time_store;
|
||||||
|
travel_entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
store
|
||||||
|
.get(&entry.mode, &entry.slug)
|
||||||
|
.map_err(|err| format!("Failed to load travel data: {}", err))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
let has_travel = !travel_entries.is_empty();
|
||||||
|
|
||||||
|
let mut passing: FxHashSet<String> = FxHashSet::default();
|
||||||
|
|
||||||
|
state
|
||||||
|
.grid
|
||||||
|
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||||
|
let row = row_idx as usize;
|
||||||
|
if !row_passes_filters(
|
||||||
|
row,
|
||||||
|
parsed_filters,
|
||||||
|
parsed_enum_filters,
|
||||||
|
feature_data,
|
||||||
|
num_features,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if has_poi_filters && !row_passes_poi_filters(row, parsed_poi_filters, poi_metrics) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let postcode = state.data.postcode(row);
|
||||||
|
if has_travel && !row_passes_travel_filters(postcode, travel_entries, &travel_data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Property postcodes share the same canonical "OUT IN" format used by
|
||||||
|
// ActualListingData::load (normalize_postcode), so we can match by string.
|
||||||
|
if !passing.contains(postcode) {
|
||||||
|
passing.insert(postcode.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(passing)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Once};
|
||||||
|
|
||||||
|
static OUT_OF_RANGE_WARN: Once = Once::new();
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
|
@ -260,6 +262,14 @@ pub(super) fn top_filter_exclusions(
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(category) = values.get(raw as usize) else {
|
let Some(category) = values.get(raw as usize) else {
|
||||||
|
OUT_OF_RANGE_WARN.call_once(|| {
|
||||||
|
warn!(
|
||||||
|
feature = %data.feature_names[filter.feat_idx],
|
||||||
|
raw,
|
||||||
|
max = values.len(),
|
||||||
|
"Enum value index out of range (logged once)"
|
||||||
|
);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -372,10 +382,10 @@ pub(super) fn top_filter_exclusions(
|
||||||
.unwrap_or(f32::INFINITY);
|
.unwrap_or(f32::INFINITY);
|
||||||
|
|
||||||
let replace = path_score < current_score
|
let replace = path_score < current_score
|
||||||
|| (path_score == current_score
|
|| (path_score.total_cmp(¤t_score) == std::cmp::Ordering::Equal
|
||||||
&& best_path
|
&& best_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(true, |current| path.len() < current.len()));
|
.is_none_or(|current| path.len() < current.len()));
|
||||||
if replace {
|
if replace {
|
||||||
best_path = Some(path);
|
best_path = Some(path);
|
||||||
}
|
}
|
||||||
|
|
@ -394,8 +404,7 @@ pub(super) fn top_filter_exclusions(
|
||||||
|
|
||||||
exclusions.sort_by(|a, b| {
|
exclusions.sort_by(|a, b| {
|
||||||
a.relative_difference
|
a.relative_difference
|
||||||
.partial_cmp(&b.relative_difference)
|
.total_cmp(&b.relative_difference)
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
.then_with(|| b.rejected_count.cmp(&a.rejected_count))
|
.then_with(|| b.rejected_count.cmp(&a.rejected_count))
|
||||||
.then_with(|| a.name.cmp(&b.name))
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
});
|
});
|
||||||
|
|
@ -524,6 +533,27 @@ pub async fn get_hexagon_stats(
|
||||||
// for the requested journey destination (so it has journey data). Fall back
|
// for the requested journey destination (so it has journey data). Fall back
|
||||||
// to geographic proximity to the hexagon center.
|
// to geographic proximity to the hexagon center.
|
||||||
let central_postcode = if !matching_rows.is_empty() {
|
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 lat = state.data.lat.as_slice();
|
||||||
|
let lon = state.data.lon.as_slice();
|
||||||
|
let distance_sq = |row: usize| -> Option<f32> {
|
||||||
|
match (lat.get(row), lon.get(row)) {
|
||||||
|
(Some(&la), Some(&lo)) if la.is_finite() && lo.is_finite() => {
|
||||||
|
Some((la - center_lat).powi(2) + (lo - center_lon).powi(2))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
OUT_OF_RANGE_WARN.call_once(|| {
|
||||||
|
warn!(
|
||||||
|
"matching_rows index out of range or non-finite lat/lon (logged once)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref travel_data) = journey_travel_data {
|
if let Some(ref travel_data) = journey_travel_data {
|
||||||
// Find the row with the shortest travel time in the travel data
|
// Find the row with the shortest travel time in the travel data
|
||||||
let best_row = matching_rows
|
let best_row = matching_rows
|
||||||
|
|
@ -537,40 +567,24 @@ pub async fn get_hexagon_stats(
|
||||||
.map(|(row, _)| row);
|
.map(|(row, _)| row);
|
||||||
|
|
||||||
// Fall back to geographic center if no row has travel data
|
// Fall back to geographic center if no row has travel data
|
||||||
let row = best_row.unwrap_or_else(|| {
|
let row = best_row.or_else(|| {
|
||||||
let center: h3o::LatLng = cell.into();
|
|
||||||
let center_lat = center.lat() as f32;
|
|
||||||
let center_lon = center.lng() as f32;
|
|
||||||
matching_rows
|
matching_rows
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.min_by(|&a, &b| {
|
.filter_map(|row| distance_sq(row).map(|d| (row, d)))
|
||||||
let da = (state.data.lat[a] - center_lat).powi(2)
|
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||||
+ (state.data.lon[a] - center_lon).powi(2);
|
.map(|(row, _)| row)
|
||||||
let db = (state.data.lat[b] - center_lat).powi(2)
|
|
||||||
+ (state.data.lon[b] - center_lon).powi(2);
|
|
||||||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
})
|
|
||||||
.expect("matching_rows is non-empty")
|
|
||||||
});
|
});
|
||||||
Some(state.data.postcode(row).to_string())
|
row.map(|row| state.data.postcode(row).to_string())
|
||||||
} else {
|
} else {
|
||||||
// No journey destination requested — use geographic center
|
// No journey destination requested — use geographic center
|
||||||
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
|
let closest_row = matching_rows
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.min_by(|&a, &b| {
|
.filter_map(|row| distance_sq(row).map(|d| (row, d)))
|
||||||
let da = (state.data.lat[a] - center_lat).powi(2)
|
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||||
+ (state.data.lon[a] - center_lon).powi(2);
|
.map(|(row, _)| row);
|
||||||
let db = (state.data.lat[b] - center_lat).powi(2)
|
closest_row.map(|row| state.data.postcode(row).to_string())
|
||||||
+ (state.data.lon[b] - center_lon).powi(2);
|
|
||||||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
})
|
|
||||||
.expect("matching_rows is non-empty");
|
|
||||||
Some(state.data.postcode(closest_row).to_string())
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,47 @@ async fn mark_invite_used(
|
||||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defense in depth: PocketBase has no atomic compare-and-swap for record
|
||||||
|
// updates, and our local + distributed locks could in principle fail (lock
|
||||||
|
// server timeout, server restart mid-redemption). Re-read the record and
|
||||||
|
// confirm WE actually own it — if a concurrent redemption beat us to the
|
||||||
|
// PATCH, both writes succeeded but the loser's user_id is overwritten and
|
||||||
|
// we must NOT grant a license.
|
||||||
|
let verify_url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
||||||
|
let verify_resp = match state
|
||||||
|
.http_client
|
||||||
|
.get(&verify_url)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to verify invite redemption: {err}");
|
||||||
|
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !verify_resp.status().is_success() {
|
||||||
|
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = match verify_resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to parse invite verify response: {err}");
|
||||||
|
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let actual_user = body["used_by_id"].as_str().unwrap_or("");
|
||||||
|
if actual_user != user_id {
|
||||||
|
warn!(
|
||||||
|
invite_id,
|
||||||
|
expected = user_id,
|
||||||
|
actual = actual_user,
|
||||||
|
"Invite redemption race lost — invite already claimed by another user"
|
||||||
|
);
|
||||||
|
return Err((StatusCode::CONFLICT, "Invite was already redeemed").into_response());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,11 +553,16 @@ pub async fn get_invite(
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
let user_body: serde_json::Value = resp.json().await.unwrap_or_default();
|
match resp.json::<serde_json::Value>().await {
|
||||||
user_body["email"]
|
Ok(user_body) => user_body["email"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.and_then(|e| e.split('@').next())
|
.and_then(|e| e.split('@').next())
|
||||||
.and_then(sanitize_invited_by)
|
.and_then(sanitize_invited_by),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to parse inviter user record JSON: {err}");
|
||||||
|
return StatusCode::BAD_GATEWAY.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
@ -689,26 +735,6 @@ pub async fn post_redeem_invite(
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
|
|
||||||
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
filter,
|
|
||||||
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn redeemable_invite_filter_rejects_unsafe_values() {
|
|
||||||
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
|
|
||||||
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List invites. Users only see invites they created, including admins.
|
/// List invites. Users only see invites they created, including admins.
|
||||||
pub async fn get_invites(
|
pub async fn get_invites(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
|
|
@ -787,3 +813,23 @@ pub async fn get_invites(
|
||||||
|
|
||||||
Json(InviteListResponse { invites }).into_response()
|
Json(InviteListResponse { invites }).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
|
||||||
|
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
filter,
|
||||||
|
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redeemable_invite_filter_rejects_unsafe_values() {
|
||||||
|
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
|
||||||
|
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::api_error::ApiError;
|
||||||
|
use crate::consts::{DEFAULT_PLACES_LIMIT, MAX_PLACES_LIMIT};
|
||||||
use crate::data::{normalize_search_text, slugify};
|
use crate::data::{normalize_search_text, slugify};
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
|
@ -96,15 +97,18 @@ fn postcode_starts_with_compact(postcode: &str, compact_query: &str) -> bool {
|
||||||
pub async fn get_places(
|
pub async fn get_places(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
Query(params): Query<PlacesParams>,
|
Query(params): Query<PlacesParams>,
|
||||||
) -> Result<Json<PlacesResponse>, (StatusCode, String)> {
|
) -> Result<Json<PlacesResponse>, ApiError> {
|
||||||
let state = shared.load_state();
|
let state = shared.load_state();
|
||||||
let query = if params.q.is_empty() {
|
let query = if params.q.is_empty() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "'q' must not be empty".into()));
|
return Err(ApiError::BadRequest("'q' must not be empty".into()));
|
||||||
} else {
|
} else {
|
||||||
params.q
|
params.q
|
||||||
};
|
};
|
||||||
|
|
||||||
let limit = params.limit.unwrap_or(7).min(20);
|
let limit = params
|
||||||
|
.limit
|
||||||
|
.unwrap_or(DEFAULT_PLACES_LIMIT)
|
||||||
|
.min(MAX_PLACES_LIMIT);
|
||||||
let mode_filter = params.mode;
|
let mode_filter = params.mode;
|
||||||
|
|
||||||
let places = tokio::task::spawn_blocking(move || {
|
let places = tokio::task::spawn_blocking(move || {
|
||||||
|
|
@ -264,7 +268,7 @@ pub async fn get_places(
|
||||||
(results, postcodes, addresses)
|
(results, postcodes, addresses)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
.map_err(|error| ApiError::Internal(error.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(PlacesResponse {
|
Ok(Json(PlacesResponse {
|
||||||
places: places.0,
|
places: places.0,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::api_error::ApiError;
|
||||||
use crate::consts::MAX_POIS_PER_REQUEST;
|
use crate::consts::MAX_POIS_PER_REQUEST;
|
||||||
use crate::data::{resolve_poi_category_filter, POICategoryGroup};
|
use crate::data::{resolve_poi_category_filter, POICategoryGroup};
|
||||||
use crate::parsing::require_bounds;
|
use crate::parsing::require_bounds;
|
||||||
|
|
@ -39,9 +39,9 @@ pub struct POIParams {
|
||||||
pub async fn get_pois(
|
pub async fn get_pois(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
Query(params): Query<POIParams>,
|
Query(params): Query<POIParams>,
|
||||||
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
|
) -> Result<Json<POIsResponse>, ApiError> {
|
||||||
let state = shared.load_state();
|
let state = shared.load_state();
|
||||||
let (south, west, north, east) = require_bounds(params.bounds)?;
|
let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
|
||||||
|
|
||||||
let category_filter: Option<rustc_hash::FxHashSet<u16>> = params
|
let category_filter: Option<rustc_hash::FxHashSet<u16>> = params
|
||||||
.categories
|
.categories
|
||||||
|
|
@ -109,7 +109,7 @@ pub async fn get_pois(
|
||||||
pois
|
pois
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
.map_err(|error| ApiError::Internal(error.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(POIsResponse { pois }))
|
Ok(Json(POIsResponse { pois }))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
use crate::utils::normalize_postcode;
|
use crate::utils::normalize_postcode;
|
||||||
|
|
||||||
use super::properties::{HexagonPropertiesResponse, Property};
|
use super::properties::{Property, PropertyListResponse};
|
||||||
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -36,7 +36,7 @@ pub async fn get_postcode_properties(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
Extension(user): Extension<OptionalUser>,
|
Extension(user): Extension<OptionalUser>,
|
||||||
Query(params): Query<PostcodePropertiesParams>,
|
Query(params): Query<PostcodePropertiesParams>,
|
||||||
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
|
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
|
||||||
let state = shared.load_state();
|
let state = shared.load_state();
|
||||||
let normalized = normalize_postcode(¶ms.postcode);
|
let normalized = normalize_postcode(¶ms.postcode);
|
||||||
|
|
||||||
|
|
@ -183,7 +183,7 @@ pub async fn get_postcode_properties(
|
||||||
"GET /api/postcode-properties"
|
"GET /api/postcode-properties"
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(HexagonPropertiesResponse {
|
Ok(PropertyListResponse {
|
||||||
properties,
|
properties,
|
||||||
total,
|
total,
|
||||||
limit,
|
limit,
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,11 @@ pub struct Property {
|
||||||
pub features: FxHashMap<String, f32>,
|
pub features: FxHashMap<String, f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared paginated list of `Property` records. Used by both
|
||||||
|
/// `/api/hexagon-properties` (lookup by H3 cell) and `/api/postcode-properties`
|
||||||
|
/// (lookup by postcode) so the frontend can render either result the same way.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct HexagonPropertiesResponse {
|
pub struct PropertyListResponse {
|
||||||
pub properties: Vec<Property>,
|
pub properties: Vec<Property>,
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
|
|
@ -183,7 +186,7 @@ pub async fn get_hexagon_properties(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
Extension(user): Extension<OptionalUser>,
|
Extension(user): Extension<OptionalUser>,
|
||||||
Query(params): Query<HexagonPropertiesParams>,
|
Query(params): Query<HexagonPropertiesParams>,
|
||||||
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
|
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
|
||||||
let state = shared.load_state();
|
let state = shared.load_state();
|
||||||
let cell = h3o::CellIndex::from_str(¶ms.h3).map_err(|error| {
|
let cell = h3o::CellIndex::from_str(¶ms.h3).map_err(|error| {
|
||||||
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
|
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
|
||||||
|
|
@ -306,7 +309,7 @@ pub async fn get_hexagon_properties(
|
||||||
"GET /api/hexagon-properties"
|
"GET /api/hexagon-properties"
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(HexagonPropertiesResponse {
|
Ok(PropertyListResponse {
|
||||||
properties,
|
properties,
|
||||||
total,
|
total,
|
||||||
limit,
|
limit,
|
||||||
|
|
|
||||||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -1401,7 +1401,6 @@ dev = [
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastexcel", specifier = ">=0.19.0" },
|
{ name = "fastexcel", specifier = ">=0.19.0" },
|
||||||
{ name = "folium", specifier = ">=0.20.0" },
|
{ name = "folium", specifier = ">=0.20.0" },
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
||||||
{ name = "ipywidgets", specifier = ">=8.0.0" },
|
{ name = "ipywidgets", specifier = ">=8.0.0" },
|
||||||
{ name = "jupyter", specifier = ">=1.0.0" },
|
{ name = "jupyter", specifier = ">=1.0.0" },
|
||||||
|
|
@ -1411,8 +1410,7 @@ requires-dist = [
|
||||||
{ name = "pandas", specifier = ">=2.0.0" },
|
{ name = "pandas", specifier = ">=2.0.0" },
|
||||||
{ name = "pillow", specifier = ">=12.0.0" },
|
{ name = "pillow", specifier = ">=12.0.0" },
|
||||||
{ name = "plotly", specifier = ">=6.5.2" },
|
{ name = "plotly", specifier = ">=6.5.2" },
|
||||||
{ name = "polars" },
|
{ name = "polars", specifier = ">=1.37.1,<2.0.0" },
|
||||||
{ name = "polars", specifier = ">=1.37.1" },
|
|
||||||
{ name = "pyarrow", specifier = ">=15.0.0" },
|
{ name = "pyarrow", specifier = ">=15.0.0" },
|
||||||
{ name = "pyogrio", specifier = ">=0.12.1" },
|
{ name = "pyogrio", specifier = ">=0.12.1" },
|
||||||
{ name = "pyproj", specifier = ">=3.7.2" },
|
{ name = "pyproj", specifier = ">=3.7.2" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue