This commit is contained in:
Andras Schmelczer 2026-05-26 19:45:13 +01:00
parent c645b0f1d4
commit 39ef5c6646
79 changed files with 5660 additions and 2199 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,020 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Before After
Before After

Binary file not shown.

View file

@ -41,7 +41,6 @@ import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
import StreetViewEmbed from './StreetViewEmbed';
import HistogramLegend from './HistogramLegend';
import JourneyInstructions from './JourneyInstructions';
interface AreaPaneProps {
@ -462,7 +461,6 @@ export default function AreaPane({
) : 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)));
@ -547,6 +545,10 @@ export default function AreaPane({
if (total === 0) return null;
const crimeSeries = chart.feature
? crimeByYearByFeatureName.get(chart.feature)
: undefined;
return (
<div
key={ts(chart.label)}
@ -585,6 +587,11 @@ export default function AreaPane({
: STACKED_SEGMENT_COLORS
}
/>
{crimeSeries && crimeSeries.points.length > 1 && (
<div className="mt-2">
<CrimeYearChart points={crimeSeries.points} />
</div>
)}
</div>
);
})}

View file

@ -1,21 +0,0 @@
import { useTranslation } from 'react-i18next';
export default function HistogramLegend() {
const { t } = useTranslation();
return (
<div className="mx-3 mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 rounded border border-warm-200 bg-white px-2.5 py-1.5 text-[10px] text-warm-500 dark:border-navy-800 dark:bg-navy-950/60 dark:text-warm-400">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2 rounded-[2px] bg-teal-600 dark:bg-teal-400" />
<span className="font-medium text-warm-700 dark:text-warm-200">
{t('histogramLegend.tealBars')}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2 rounded-[2px] bg-warm-300/70 dark:bg-warm-600/70" />
<span className="font-medium text-warm-700 dark:text-warm-200">
{t('histogramLegend.greyBars')}
</span>
</div>
</div>
);
}

View file

@ -96,7 +96,7 @@ export default memo(function HoverCard({
if (!data) {
return (
<div
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm pointer-events-none z-50 min-w-[140px]"
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm pointer-events-none z-30 min-w-[140px]"
style={cardStyle}
>
<div className="animate-pulse space-y-2">
@ -109,7 +109,7 @@ export default memo(function HoverCard({
return (
<div
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-50 min-w-[180px] max-w-[260px]"
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-30 min-w-[180px] max-w-[260px]"
style={cardStyle}
>
<div className="relative">

View file

@ -0,0 +1,215 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import LocationSearch from './LocationSearch';
import type { PostcodeGeometry } from '../../types';
const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) =>
key === 'locationSearch.placeholder' ? 'Search places or postcodes...' : key,
}),
}));
vi.mock('../../hooks/useIsMobile', () => ({
useIsMobile: () => false,
}));
vi.mock('../../lib/pocketbase', () => ({
default: { authStore: { isValid: false, token: '' } },
}));
const postcodeGeometry: PostcodeGeometry = {
type: 'Polygon',
coordinates: [
[
[-0.12, 51.5],
[-0.11, 51.5],
[-0.11, 51.51],
[-0.12, 51.51],
[-0.12, 51.5],
],
],
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('LocationSearch', () => {
afterEach(() => {
cleanup();
window.localStorage.clear();
vi.unstubAllGlobals();
});
it('ignores stale postcode lookups when a newer search starts', async () => {
const firstLookup = deferred<Response>();
const secondLookup = deferred<Response>();
const requests: { postcode: string; signal?: AbortSignal | null }[] = [];
vi.stubGlobal(
'fetch',
vi.fn((input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input), 'http://localhost');
const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
requests.push({ postcode, signal: init?.signal });
if (postcode === 'SW1A 1AA') return firstLookup.promise;
if (postcode === 'E14 2DG') return secondLookup.promise;
return Promise.resolve(new Response(null, { status: 404 }));
})
);
const onFlyTo = vi.fn();
const onLocationSearched = vi.fn();
render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'SW1A 1AA' } });
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.change(input, { target: { value: 'E14 2DG' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(requests).toHaveLength(2);
expect(requests[0].signal?.aborted).toBe(true);
secondLookup.resolve(
jsonResponse({
postcode: 'E14 2DG',
latitude: 51.505,
longitude: -0.01,
geometry: postcodeGeometry,
})
);
await waitFor(() => {
expect(onLocationSearched).toHaveBeenCalledTimes(1);
});
expect(onFlyTo).toHaveBeenCalledWith(51.505, -0.01, 16);
expect(onLocationSearched).toHaveBeenCalledWith({
postcode: 'E14 2DG',
geometry: postcodeGeometry,
latitude: 51.505,
longitude: -0.01,
zoom: 16,
});
firstLookup.resolve(
jsonResponse({
postcode: 'SW1A 1AA',
latitude: 51.501,
longitude: -0.141,
geometry: postcodeGeometry,
})
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onFlyTo).toHaveBeenCalledTimes(1);
expect(onLocationSearched).toHaveBeenCalledTimes(1);
});
it('stores successful searches locally and shows them when the input is empty', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: string | URL | Request) => {
const url = new URL(String(input), 'http://localhost');
const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
return Promise.resolve(
jsonResponse({
postcode,
latitude: 51.505,
longitude: -0.01,
geometry: postcodeGeometry,
})
);
})
);
const onFlyTo = vi.fn();
const onLocationSearched = vi.fn();
render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'SW1A 1AA' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
expect(onLocationSearched).toHaveBeenCalledTimes(1);
});
expect(JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]')).toEqual([
{ type: 'postcode', label: 'SW1A 1AA' },
]);
fireEvent.focus(input);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'SW1A 1AA' })).toBeTruthy();
});
});
it('keeps only the three most recent local searches', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: string | URL | Request) => {
const url = new URL(String(input), 'http://localhost');
const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
return Promise.resolve(
jsonResponse({
postcode,
latitude: 51.505,
longitude: -0.01,
geometry: postcodeGeometry,
})
);
})
);
render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);
const input = screen.getByRole('textbox');
for (const postcode of ['SW1A 1AA', 'E14 2DG', 'W1A 1AA', 'EC1A 1BB']) {
fireEvent.change(input, { target: { value: postcode } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
const stored = JSON.parse(
window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]'
) as { label?: string }[];
expect(stored[0]?.label).toBe(postcode);
});
}
const stored = JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]') as {
label?: string;
}[];
expect(stored.map((search) => search.label)).toEqual(['EC1A 1BB', 'W1A 1AA', 'E14 2DG']);
fireEvent.focus(input);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'EC1A 1BB' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'W1A 1AA' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'E14 2DG' })).toBeTruthy();
});
expect(screen.queryByText('SW1A 1AA')).toBeNull();
});
});

View file

@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api';
import { authHeaders, isAbortError } from '../../lib/api';
import { POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
@ -16,6 +16,7 @@ export interface SearchedLocation {
geometry: PostcodeGeometry;
latitude: number;
longitude: number;
zoom: number;
markerLatitude?: number;
markerLongitude?: number;
openProperties?: boolean;
@ -73,6 +74,34 @@ export default function LocationSearch({
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const lookupAbortRef = useRef<AbortController | null>(null);
const lookupRequestIdRef = useRef(0);
const cancelLookup = useCallback((updateLoading = true) => {
lookupRequestIdRef.current += 1;
lookupAbortRef.current?.abort();
lookupAbortRef.current = null;
if (updateLoading) setLoading(false);
}, []);
const beginLookup = useCallback(() => {
lookupAbortRef.current?.abort();
const controller = new AbortController();
lookupAbortRef.current = controller;
lookupRequestIdRef.current += 1;
setError(null);
setLoading(true);
search.close();
return { controller, requestId: lookupRequestIdRef.current };
}, [search]);
const isCurrentLookup = useCallback((requestId: number, controller: AbortController) => {
return (
lookupRequestIdRef.current === requestId &&
lookupAbortRef.current === controller &&
!controller.signal.aborted
);
}, []);
// Close on outside click
useEffect(() => {
@ -93,106 +122,136 @@ export default function LocationSearch({
}
}, [isMobile, expanded]);
useEffect(() => {
return () => cancelLookup(false);
}, [cancelLookup]);
const selectResult = useCallback(
async (result: SearchResult) => {
const { controller, requestId } = beginLookup();
if (result.type === 'place') {
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
setError(null);
setLoading(true);
search.close();
onFlyTo(result.lat, result.lon, zoom);
// On mobile the drawer opens after onLocationSearched; MapPage handles
// the fly-to there with the correct viewport inset so the target isn't
// hidden behind the drawer. On desktop fly immediately for snappy feedback.
if (!isMobile) onFlyTo(result.lat, result.lon, zoom);
try {
const params = new URLSearchParams({
lat: String(result.lat),
lng: String(result.lon),
log: 'false',
});
const res = await fetch(`/api/nearest-postcode?${params}`, authHeaders());
const res = await fetch(
`/api/nearest-postcode?${params}`,
authHeaders({ signal: controller.signal })
);
if (!isCurrentLookup(requestId, controller)) return;
if (!res.ok) {
setError(t('locationSearch.lookupFailed'));
return;
}
const json: PostcodeLookupResponse = await res.json();
if (!isCurrentLookup(requestId, controller)) return;
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
zoom,
markerLatitude: result.lat,
markerLongitude: result.lon,
});
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);
} catch {
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
return;
}
if (result.type === 'address') {
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.postcode)}`,
authHeaders()
authHeaders({ signal: controller.signal })
);
if (!isCurrentLookup(requestId, controller)) return;
if (!res.ok) {
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: PostcodeLookupResponse = await res.json();
onFlyTo(result.lat, result.lon, 17);
if (!isCurrentLookup(requestId, controller)) return;
if (!isMobile) onFlyTo(result.lat, result.lon, 17);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: result.lat,
longitude: result.lon,
zoom: 17,
markerLatitude: result.lat,
markerLongitude: result.lon,
openProperties: true,
focusAddress: result.address,
});
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);
} catch {
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
return;
}
// Postcode — fetch geometry
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders({ signal: controller.signal })
);
if (!isCurrentLookup(requestId, controller)) return;
if (!res.ok) {
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: PostcodeLookupResponse = await res.json();
onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
if (!isCurrentLookup(requestId, controller)) return;
if (!isMobile) onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
zoom: POSTCODE_SEARCH_ZOOM,
});
search.saveRecentSearch(result);
search.clear();
if (isMobile) setExpanded(false);
} catch {
} catch (error) {
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
if (isCurrentLookup(requestId, controller)) {
lookupAbortRef.current = null;
setLoading(false);
}
}
},
[onFlyTo, onLocationSearched, isMobile, search, t]
[beginLookup, isCurrentLookup, onFlyTo, onLocationSearched, isMobile, search, t]
);
const [locating, setLocating] = useState(false);
@ -203,6 +262,7 @@ export default function LocationSearch({
return;
}
setError(null);
cancelLookup();
setLocating(true);
search.close();
try {
@ -234,7 +294,7 @@ export default function LocationSearch({
} finally {
setLocating(false);
}
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
}, [cancelLookup, onFlyTo, onCurrentLocationFound, isMobile, search, t]);
// Mobile collapsed state: search icon + locate button
if (isMobile && !expanded) {
@ -281,7 +341,10 @@ export default function LocationSearch({
'px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'
}
inputRef={inputRef}
onInputChange={() => setError(null)}
onInputChange={() => {
setError(null);
cancelLookup();
}}
/>
<button
type="button"

View file

@ -33,6 +33,7 @@ import {
MAP_BOUNDS,
POI_GROUP_COLORS,
POSTCODE_ZOOM_THRESHOLD,
POI_AUTO_CARD_ZOOM_THRESHOLD,
} from '../../lib/consts';
import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
@ -104,6 +105,15 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n
return parts.length > 0 ? parts.join(' · ') : null;
}
interface PoiPopupCardData {
name: string;
category: string;
icon_category?: string;
group: string;
emoji: string;
school?: SchoolMetadata;
}
interface Dimensions {
width: number;
height: number;
@ -289,10 +299,16 @@ function renderSchoolMetadata(school: SchoolMetadata) {
)}
{school.fsm_percent !== undefined && (
<>
<dt className="text-warm-500 dark:text-warm-400">FSM</dt>
<dt className="text-warm-500 dark:text-warm-400">Free meal</dt>
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
</>
)}
{school.ofsted_rating && (
<>
<dt className="text-warm-500 dark:text-warm-400">Ofsted</dt>
<dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
</>
)}
{school.sixth_form === 'Has a sixth form' && (
<>
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
@ -358,6 +374,36 @@ function renderSchoolMetadata(school: SchoolMetadata) {
);
}
function PoiPopupCardContent({ poi }: { poi: PoiPopupCardData }) {
return (
<div className="px-3 py-2 max-w-[280px]">
<div className="flex items-center gap-2">
<img
src={getPoiIconUrl(poi.category, poi.emoji, poi.icon_category, poi.name)}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
/>
<div className="min-w-0">
<div className="font-semibold dark:text-warm-100">{poi.name}</div>
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: `rgb(${getPoiGroupColor(poi.group).join(',')})`,
}}
/>
{ts(poi.category)}
</div>
</div>
</div>
{poi.school && renderSchoolMetadata(poi.school)}
</div>
);
}
function getRenderedViewState(map: MapRef | null): ViewState | null {
if (!map) return null;
@ -575,6 +621,7 @@ export default memo(function Map({
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
const [mapReady, setMapReady] = useState(false);
// In screenshot mode, use the prop directly for instant updates (no async lag)
const viewState = screenshotMode ? initialViewState : internalViewState;
@ -664,6 +711,10 @@ export default memo(function Map({
if (screenshotMode) window.__map_idle = true;
}, [screenshotMode]);
const handleLoad = useCallback(() => {
setMapReady(true);
}, []);
const handleFlyTo = useCallback(
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
setInternalViewState((prev) => {
@ -715,6 +766,7 @@ export default memo(function Map({
layers,
popupInfo,
clearPopupInfo,
visiblePois,
listingPopup,
clearListingPopup,
hoverPosition,
@ -744,6 +796,31 @@ export default memo(function Map({
travelTimeEntries,
});
const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD;
const autoPoiCards = useMemo(() => {
const map = mapRef.current;
if (!showAutoPoiCards || !mapReady || !map || dimensions.width <= 0 || dimensions.height <= 0) {
return [];
}
return visiblePois.flatMap((poi) => {
const point = map.project([poi.lng, poi.lat]);
if (
!Number.isFinite(point.x) ||
!Number.isFinite(point.y) ||
point.x < 0 ||
point.x > dimensions.width ||
point.y < 0 ||
point.y > dimensions.height
) {
return [];
}
return [{ poi, x: point.x, y: point.y }];
});
// viewState isn't read directly but drives map.project — recompute when the camera moves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
return (
<div
className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
@ -755,7 +832,7 @@ export default memo(function Map({
ref={mapRef}
{...viewState}
onMove={handleMove}
onLoad={undefined}
onLoad={handleLoad}
onIdle={handleIdle}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
@ -896,14 +973,28 @@ export default memo(function Map({
))}
</div>
)}
{popupInfo && (
{autoPoiCards.map(({ poi, x, y }) => (
<div
key={poi.id}
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
style={{
left: x,
top: y - 12,
transform: 'translate(-50%, -100%)',
zIndex: 9,
}}
>
<PoiPopupCardContent poi={poi} />
</div>
))}
{popupInfo && (!showAutoPoiCards || popupInfo.isCluster) && (
<div
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
style={{
left: popupInfo.x,
top: popupInfo.y - 50,
transform: 'translateX(-50%)',
zIndex: 9999,
zIndex: 30,
}}
>
<button
@ -922,36 +1013,7 @@ export default memo(function Map({
</div>
</div>
) : (
<div className="px-3 py-2 max-w-[280px]">
<div className="flex items-center gap-2">
<img
src={getPoiIconUrl(
popupInfo.category,
popupInfo.emoji,
popupInfo.icon_category,
popupInfo.name
)}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
/>
<div>
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
}}
/>
{ts(popupInfo.category)}
</div>
</div>
</div>
{popupInfo.school && renderSchoolMetadata(popupInfo.school)}
</div>
<PoiPopupCardContent poi={popupInfo} />
)}
</div>
)}
@ -962,7 +1024,7 @@ export default memo(function Map({
left: listingPopup.x,
top: listingPopup.y - 12,
transform: 'translate(-50%, -100%)',
zIndex: 9999,
zIndex: 30,
}}
onMouseLeave={clearListingPopup}
>

View file

@ -1,7 +1,7 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import type { ActualListing, MapFlyToOptions, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
@ -25,11 +25,7 @@ import {
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import {
INITIAL_VIEW_STATE,
POSTCODE_SEARCH_ZOOM,
POSTCODE_ZOOM_THRESHOLD,
} from '../../lib/consts';
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
import type { OverlayId } from '../../lib/overlays';
import { useLicense } from '../../hooks/useLicense';
import { stateToParams } from '../../lib/url-state';
@ -67,6 +63,9 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
export type { ExportState } from './map-page/types';
type PendingFlyTo = { lat: number; lng: number; zoom: number };
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
declare const __DEV__: boolean;
export default function MapPage({
features,
@ -115,6 +114,7 @@ export default function MapPage({
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [devActualListingsEnabled, setDevActualListingsEnabled] = useState(true);
const {
filters,
@ -378,7 +378,7 @@ export default function MapPage({
pendingLocationSearchFlyToRef.current = {
lat: markerLat ?? result.latitude,
lng: markerLng ?? result.longitude,
zoom: result.openProperties ? 17 : POSTCODE_SEARCH_ZOOM,
zoom: result.zoom,
};
setMobileDrawerOpen(true);
consumePendingLocationSearchFlyTo();
@ -450,11 +450,20 @@ export default function MapPage({
[filters, features]
);
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
const { listings: actualListings } = useActualListings(mapData.bounds, {
filterParam: actualListingsFilterParam,
travelParam: actualListingsTravelParam,
shareCode,
});
const actualListingsEnabled = !__DEV__ || devActualListingsEnabled;
const { listings: actualListings } = useActualListings(
actualListingsEnabled ? mapData.bounds : null,
{
filterParam: actualListingsFilterParam,
travelParam: actualListingsTravelParam,
shareCode,
}
);
const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS;
const handleToggleActualListings = useCallback(() => {
if (!__DEV__) return;
setDevActualListingsEnabled((enabled) => !enabled);
}, []);
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
@ -798,7 +807,9 @@ export default function MapPage({
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
actualListings={visibleActualListings}
actualListingsEnabled={actualListingsEnabled}
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
@ -866,7 +877,9 @@ export default function MapPage({
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
actualListings={visibleActualListings}
actualListingsEnabled={actualListingsEnabled}
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}

View file

@ -36,7 +36,7 @@ export function MapPageSelectionPane({
return (
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
className="relative z-40 flex bg-white dark:bg-navy-950 shadow-lg"
style={{ width }}
>
<div

View file

@ -52,7 +52,7 @@ export default function MobileDrawer({
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex flex-col">
<div data-tutorial="right-pane" className="fixed inset-0 z-50 flex flex-col">
{/* Backdrop — top 10% */}
<div className="h-[10%] bg-black/50" onClick={onClose} />

View file

@ -191,7 +191,7 @@ function PropertyCard({ property }: { property: Property }) {
)}
{property.listed_building === 'Yes' && (
<span className="text-xs bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full px-1.5 py-0.5 font-medium leading-none">
{ts('Listed building')}
{t('propertyCard.listedBuildingBadge')}
</span>
)}
</div>

View file

@ -17,6 +17,7 @@ import type { OverlayId } from '../../../lib/overlays';
import type { SearchedLocation } from '../LocationSearch';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { EyeIcon } from '../../ui/icons/EyeIcon';
import { HouseIcon } from '../../ui/icons/HouseIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
@ -56,6 +57,8 @@ interface DesktopMapPageProps {
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
actualListingsEnabled: boolean;
onToggleActualListings?: () => void;
travelTimeEntries: TravelTimeEntry[];
densityLabel: string;
totalCount?: number;
@ -106,6 +109,8 @@ export function DesktopMapPage({
onCurrentLocationFound,
currentLocation,
actualListings,
actualListingsEnabled,
onToggleActualListings,
travelTimeEntries,
densityLabel,
totalCount,
@ -154,7 +159,7 @@ export function DesktopMapPage({
<div
data-tutorial="filters"
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
className="relative z-40 flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
@ -208,7 +213,20 @@ export function DesktopMapPage({
totalCount={totalCount}
/>
</Suspense>
<div className="absolute bottom-4 right-4 z-10 flex flex-col items-end gap-2">
<div className="absolute bottom-4 right-4 z-10 flex max-w-[calc(100%_-_2rem)] flex-row flex-wrap justify-end gap-2">
{onToggleActualListings && (
<button
type="button"
onClick={onToggleActualListings}
aria-pressed={actualListingsEnabled}
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
>
<HouseIcon className="h-5 w-5" />
<span className="text-sm font-medium">Listings</span>
</button>
)}
<button
onClick={onToggleOverlayPane}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}

View file

@ -15,6 +15,7 @@ import type { SearchedLocation } from '../LocationSearch';
import MobileBottomSheet from '../MobileBottomSheet';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { EyeIcon } from '../../ui/icons/EyeIcon';
import { HouseIcon } from '../../ui/icons/HouseIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
@ -47,6 +48,8 @@ interface MobileMapPageProps {
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
actualListingsEnabled: boolean;
onToggleActualListings?: () => void;
travelTimeEntries: TravelTimeEntry[];
bottomScreenInset: number;
onBottomSheetCoveredHeightChange: (height: number) => void;
@ -94,6 +97,8 @@ export function MobileMapPage({
onCurrentLocationFound,
currentLocation,
actualListings,
actualListingsEnabled,
onToggleActualListings,
travelTimeEntries,
bottomScreenInset,
onBottomSheetCoveredHeightChange,
@ -161,7 +166,19 @@ export function MobileMapPage({
</Suspense>
</div>
<div className="absolute right-3 top-3 z-20 flex flex-col gap-2">
<div className="absolute right-3 top-3 z-20 flex max-w-[calc(100%_-_1.5rem)] flex-row flex-wrap justify-end gap-2">
{onToggleActualListings && (
<button
type="button"
onClick={onToggleActualListings}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
aria-pressed={actualListingsEnabled}
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
>
<HouseIcon className="h-5 w-5" />
</button>
)}
<button
onClick={onToggleOverlayPane}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}

View file

@ -11,7 +11,7 @@ import type { Page } from '../../ui/Header';
import type { PointerEvent } from 'react';
export interface ExportState {
onExport: () => void;
onExport: (options?: { postcodes?: string[] }) => void;
exporting: boolean;
}

View file

@ -135,73 +135,86 @@ export function useExportController({
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
const handleExport = useCallback(() => {
if (exporting) return;
if (!bounds) {
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
return;
}
const handleExport = useCallback(
(options?: { postcodes?: string[] }) => {
if (exporting) return;
const { south, west, north, east } = bounds;
const params = new URLSearchParams({
bounds: `${south},${west},${north},${east}`,
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
for (const overlay of selectedOverlays) {
params.append('overlay', overlay);
}
if (shareCode) params.set('share', shareCode);
const url = apiUrl('export', params);
const postcodeList = options?.postcodes?.map((p) => p.trim()).filter(Boolean) ?? [];
const isListMode = postcodeList.length > 0;
const controller = new AbortController();
let timedOut = false;
const timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, EXPORT_TIMEOUT_MS);
setExporting(true);
clearExportNotice();
void (async () => {
try {
const res = await fetch(url, authHeaders({ signal: controller.signal }));
if (!res.ok) throw new Error(await getExportErrorMessage(res));
const blob = await res.blob();
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
triggerExportDownload(blob, getExportFileName(res));
trackEvent('Export');
showExportNotice({ kind: 'success', message: t('header.exportReady') });
} catch (err) {
if (!timedOut) logNonAbortError('Export failed', err);
const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
showExportNotice({
kind: 'error',
message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`,
});
} finally {
window.clearTimeout(timeoutId);
setExporting(false);
if (!isListMode && !bounds) {
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
return;
}
})();
}, [
bounds,
clearExportNotice,
exporting,
features,
filters,
selectedOverlays,
shareCode,
showExportNotice,
t,
travelTimeEntries,
]);
const params = new URLSearchParams();
if (isListMode) {
params.set('postcodes', postcodeList.join(','));
if (shareCode) params.set('share', shareCode);
} else {
const { south, west, north, east } = bounds!;
params.set('bounds', `${south},${west},${north},${east}`);
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
for (const overlay of selectedOverlays) {
params.append('overlay', overlay);
}
if (shareCode) params.set('share', shareCode);
}
const url = apiUrl('export', params);
const controller = new AbortController();
let timedOut = false;
const timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, EXPORT_TIMEOUT_MS);
setExporting(true);
clearExportNotice();
void (async () => {
try {
const res = await fetch(url, authHeaders({ signal: controller.signal }));
if (!res.ok) throw new Error(await getExportErrorMessage(res));
const blob = await res.blob();
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
triggerExportDownload(blob, getExportFileName(res));
trackEvent('Export');
showExportNotice({ kind: 'success', message: t('header.exportReady') });
} catch (err) {
if (!timedOut) logNonAbortError('Export failed', err);
const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
showExportNotice({
kind: 'error',
message: timedOut
? t('header.exportTimedOut')
: `${t('header.exportFailed')}${detail}`,
});
} finally {
window.clearTimeout(timeoutId);
setExporting(false);
}
})();
},
[
bounds,
clearExportNotice,
exporting,
features,
filters,
selectedOverlays,
shareCode,
showExportNotice,
t,
travelTimeEntries,
]
);
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });

View file

@ -0,0 +1,221 @@
import { useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { DownloadIcon } from './icons/DownloadIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
export type ExportMode = 'filters' | 'list';
interface ExportMenuProps {
open: boolean;
exporting: boolean;
onClose: () => void;
onExport: (options?: { postcodes?: string[] }) => void;
}
const EMPTY_LIST: string[] = [''];
export default function ExportMenu({ open, exporting, onClose, onExport }: ExportMenuProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<ExportMode>('filters');
const [postcodes, setPostcodes] = useState<string[]>(EMPTY_LIST);
const titleId = useId();
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
const focusIndexRef = useRef<number | null>(null);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
useEffect(() => {
if (focusIndexRef.current == null) return;
inputRefs.current[focusIndexRef.current]?.focus();
focusIndexRef.current = null;
}, [postcodes.length]);
if (!open) return null;
const cleaned = postcodes.map((p) => p.trim()).filter(Boolean);
const canSubmit = !exporting && (mode === 'filters' || cleaned.length > 0);
const handleSubmit = () => {
if (!canSubmit) return;
if (mode === 'list') {
onExport({ postcodes: cleaned });
} else {
onExport();
}
};
const updateAt = (idx: number, value: string) => {
setPostcodes((prev) => prev.map((p, i) => (i === idx ? value : p)));
};
const addRow = () => {
setPostcodes((prev) => {
focusIndexRef.current = prev.length;
return [...prev, ''];
});
};
const removeAt = (idx: number) => {
setPostcodes((prev) => {
if (prev.length <= 1) return [''];
return prev.filter((_, i) => i !== idx);
});
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, idx: number) => {
if (e.key === 'Enter') {
e.preventDefault();
if (idx === postcodes.length - 1 && postcodes[idx].trim()) {
addRow();
} else {
inputRefs.current[idx + 1]?.focus();
}
} else if (
e.key === 'Backspace' &&
postcodes[idx] === '' &&
postcodes.length > 1
) {
e.preventDefault();
focusIndexRef.current = Math.max(0, idx - 1);
removeAt(idx);
}
};
const cardClass = (selected: boolean) =>
`w-full text-left cursor-pointer rounded-lg border p-3 transition-colors ${
selected
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
: 'border-warm-200 dark:border-warm-700 hover:border-warm-300 dark:hover:border-warm-600 bg-white dark:bg-warm-800'
}`;
return (
<>
<div
className="fixed inset-0 bg-black/50 z-[90]"
onClick={onClose}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed left-1/2 top-1/2 z-[91] -translate-x-1/2 -translate-y-1/2 w-[92vw] max-w-md bg-white dark:bg-warm-800 rounded-lg shadow-xl flex flex-col max-h-[90vh]"
>
<div className="flex items-center justify-between px-4 h-12 border-b border-warm-200 dark:border-warm-700 shrink-0">
<span id={titleId} className="font-semibold text-navy-950 dark:text-warm-100">
{t('export.title')}
</span>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="flex cursor-pointer items-center justify-center w-9 h-9 -mr-2 rounded hover:bg-warm-100 dark:hover:bg-warm-700 transition-colors"
>
<CloseIcon className="w-5 h-5 text-warm-700 dark:text-warm-300" />
</button>
</div>
<div className="p-4 space-y-3 overflow-y-auto">
<button
type="button"
onClick={() => setMode('filters')}
className={cardClass(mode === 'filters')}
>
<div className="font-medium text-navy-950 dark:text-warm-100">
{t('export.modeFilters')}
</div>
<div className="text-xs text-warm-500 dark:text-warm-400 mt-1">
{t('export.modeFiltersHint')}
</div>
</button>
<button
type="button"
onClick={() => setMode('list')}
className={cardClass(mode === 'list')}
>
<div className="font-medium text-navy-950 dark:text-warm-100">
{t('export.modeList')}
</div>
<div className="text-xs text-warm-500 dark:text-warm-400 mt-1">
{t('export.modeListHint')}
</div>
</button>
{mode === 'list' && (
<div className="flex flex-col gap-2 pt-1">
<span className="text-xs font-medium text-warm-600 dark:text-warm-300">
{t('export.listLabel')}
</span>
<div className="flex flex-col gap-1.5">
{postcodes.map((value, idx) => (
<div key={idx} className="flex items-center gap-1.5">
<input
ref={(el) => {
inputRefs.current[idx] = el;
}}
type="text"
value={value}
onChange={(e) => updateAt(idx, e.target.value)}
onKeyDown={(e) => handleInputKeyDown(e, idx)}
placeholder={t('export.listPlaceholder')}
autoComplete="off"
spellCheck={false}
className="flex-1 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 px-3 py-1.5 text-sm text-navy-950 dark:text-warm-100 font-mono uppercase placeholder:normal-case placeholder:font-sans placeholder:text-warm-400 focus:outline-none focus:border-teal-500"
/>
<button
type="button"
onClick={() => removeAt(idx)}
disabled={postcodes.length === 1 && !value}
aria-label={t('export.removeRow')}
className="flex cursor-pointer items-center justify-center w-8 h-8 rounded text-warm-500 hover:text-warm-700 hover:bg-warm-100 dark:hover:bg-warm-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<CloseIcon className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addRow}
className="self-start flex cursor-pointer items-center gap-1 text-sm text-teal-700 dark:text-teal-300 hover:text-teal-800 dark:hover:text-teal-200 mt-0.5"
>
<span aria-hidden="true" className="text-base leading-none">
+
</span>
{t('export.addRow')}
</button>
<div className="text-xs text-warm-500 dark:text-warm-400">
{t('export.listCount', { count: cleaned.length })}
</div>
</div>
)}
</div>
<div className="px-4 py-3 border-t border-warm-200 dark:border-warm-700 shrink-0">
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="w-full flex cursor-pointer items-center justify-center gap-2 px-4 py-2 rounded bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{exporting ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<DownloadIcon className="w-4 h-4" />
)}
{exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
</div>
</div>
</>
);
}

View file

@ -15,6 +15,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
import LanguageDropdown from './LanguageDropdown';
import ExportMenu from './ExportMenu';
export type Page =
| 'home'
@ -37,7 +38,7 @@ export type Page =
| 'invite';
export interface HeaderExportState {
onExport: () => void;
onExport: (options?: { postcodes?: string[] }) => void;
exporting: boolean;
}
@ -110,6 +111,7 @@ export default function Header({
const [copied, setCopied] = useState(false);
const [sharing, setSharing] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [isDashboardTabletSidebarWidth, setIsDashboardTabletSidebarWidth] = useState(
() => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
);
@ -292,7 +294,7 @@ export default function Header({
</button>
{exportState && (
<button
onClick={exportState.onExport}
onClick={() => setExportMenuOpen(true)}
disabled={exportState.exporting}
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50"
title={t('header.exportToExcel')}
@ -407,6 +409,7 @@ export default function Header({
theme={theme}
onToggleTheme={onToggleTheme}
exportState={exportState}
onOpenExportMenu={() => setExportMenuOpen(true)}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
isEditingSearch={!!editingSearch}
@ -420,6 +423,18 @@ export default function Header({
sharing={sharing}
/>
)}
{/* Export menu modal (shared between desktop and mobile triggers) */}
{exportState && (
<ExportMenu
open={exportMenuOpen}
exporting={exportState.exporting}
onClose={() => setExportMenuOpen(false)}
onExport={(opts) => {
setExportMenuOpen(false);
exportState.onExport(opts);
}}
/>
)}
{/* Mobile "Copied" toast */}
{useSidebarNav && copied && (
<div className="fixed top-14 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 px-4 py-2 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">

View file

@ -19,6 +19,7 @@ interface MobileMenuProps {
theme: 'light' | 'dark';
onToggleTheme: () => void;
exportState: HeaderExportState | null;
onOpenExportMenu: () => void;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
isEditingSearch: boolean;
@ -39,6 +40,7 @@ export default function MobileMenu({
theme,
onToggleTheme,
exportState,
onOpenExportMenu,
onSaveSearch,
savingSearch,
isEditingSearch,
@ -122,8 +124,8 @@ export default function MobileMenu({
{exportState && (
<button
onClick={() => {
exportState.onExport();
onClose();
onOpenExportMenu();
}}
disabled={exportState.exporting}
className={dashboardActionClass}

View file

@ -13,9 +13,9 @@ interface SearchHook {
activeIndex: number;
setActiveIndex: (idx: number) => void;
open: boolean;
setOpen: (open: boolean) => void;
handleInputChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
showEmptySearches: () => void;
}
interface PlaceSearchInputProps {
@ -129,7 +129,7 @@ export function PlaceSearchInput({
onInputChange?.();
}}
onFocus={() => {
if (search.results.length > 0) search.setOpen(true);
search.showEmptySearches();
}}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
placeholder={placeholder}

View file

@ -104,7 +104,11 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
const { poiLayers, visiblePois, popupInfo, clearPopupInfo } = usePoiLayers({
pois,
zoom,
isDark,
});
const { listingLayers, listingPopup, clearListingPopup } = useListingLayers({
listings: actualListings,
zoom,
@ -421,8 +425,50 @@ export function useDeckLayers({
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
const postcodeLayer = useMemo(() => {
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
const ratiosCache = new WeakMap<PostcodeFeature, number[]>();
const getRatios = (f: PostcodeFeature): number[] => {
let r = ratiosCache.get(f);
if (!r) {
r = distToRatios(f.properties[distKey]);
ratiosCache.set(f, r);
}
return r;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: Record<string, any> = isEnum
? {
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
getCenter: (f: PostcodeFeature) => f.properties.centroid,
getRatios0: (f: PostcodeFeature) => {
const r = getRatios(f);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (f: PostcodeFeature) => {
const r = getRatios(f);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (f: PostcodeFeature) => {
const r = getRatios(f);
return [r[8], r[9]];
},
}
: {};
const pieUpdateTriggers: Record<string, unknown> = isEnum
? {
getCenter: [postcodeColorTrigger, postcodeData],
getRatios0: [postcodeColorTrigger, postcodeData],
getRatios1: [postcodeColorTrigger, postcodeData],
getRatios2: [postcodeColorTrigger, postcodeData],
}
: {};
return new GeoJsonLayer<PostcodeProperties>({
id: 'postcode-polygons',
...pieProps,
id: isEnum ? 'postcode-polygons-pie' : 'postcode-polygons',
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
@ -525,6 +571,7 @@ export function useDeckLayers({
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
...pieUpdateTriggers,
},
extruded: false,
pickable: true,
@ -651,6 +698,7 @@ export function useDeckLayers({
return {
layers,
visiblePois,
popupInfo,
clearPopupInfo,
listingPopup,

View file

@ -2,6 +2,9 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import type { AddressResult, PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api';
const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
const RECENT_SEARCH_LIMIT = 3;
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
* Outcodes like "E14" or "SW1A" intentionally do NOT match they go through /api/places instead. */
const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
@ -77,9 +80,84 @@ export type SearchResult =
city?: string;
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isSearchResult(value: unknown): value is SearchResult {
if (!value || typeof value !== 'object') return false;
const result = value as Record<string, unknown>;
if (result.type === 'postcode') {
return typeof result.label === 'string';
}
if (result.type === 'address') {
return (
typeof result.address === 'string' &&
typeof result.postcode === 'string' &&
isFiniteNumber(result.lat) &&
isFiniteNumber(result.lon)
);
}
if (result.type === 'place') {
return (
typeof result.name === 'string' &&
typeof result.slug === 'string' &&
typeof result.place_type === 'string' &&
isFiniteNumber(result.lat) &&
isFiniteNumber(result.lon) &&
(result.city === undefined || typeof result.city === 'string')
);
}
return false;
}
function readRecentSearches(): SearchResult[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isSearchResult).slice(0, RECENT_SEARCH_LIMIT);
} catch {
return [];
}
}
function writeRecentSearches(searches: SearchResult[]) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
RECENT_SEARCHES_STORAGE_KEY,
JSON.stringify(searches.slice(0, RECENT_SEARCH_LIMIT))
);
} catch {
// Recent searches are a convenience only; storage failures should not affect search.
}
}
function searchResultKey(result: SearchResult): string {
if (result.type === 'postcode') {
return `postcode:${normalizePostcode(result.label)}`;
}
if (result.type === 'address') {
return `address:${result.postcode.toUpperCase()}:${result.address.toLowerCase()}`;
}
return `place:${result.slug}`;
}
export function useLocationSearch(mode?: string) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches);
const [activeIndex, setActiveIndex] = useState(-1);
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
@ -98,9 +176,9 @@ export function useLocationSearch(mode?: string) {
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
setResults(recentSearches);
lastResultsRef.current = [];
setOpen(false);
setOpen(recentSearches.length > 0);
return;
}
@ -181,9 +259,20 @@ export function useLocationSearch(mode?: string) {
}
}, 200);
},
[mode]
[mode, recentSearches]
);
const showEmptySearches = useCallback(() => {
if (latestQueryRef.current.trim()) {
setOpen(results.length > 0);
return;
}
setResults(recentSearches);
setActiveIndex(-1);
setOpen(recentSearches.length > 0);
}, [recentSearches, results.length]);
const close = useCallback(() => setOpen(false), []);
const clear = useCallback(() => {
@ -195,6 +284,18 @@ export function useLocationSearch(mode?: string) {
setActiveIndex(-1);
}, []);
const saveRecentSearch = useCallback((result: SearchResult) => {
setRecentSearches((prev) => {
const key = searchResultKey(result);
const next = [result, ...prev.filter((recent) => searchResultKey(recent) !== key)].slice(
0,
RECENT_SEARCH_LIMIT
);
writeRecentSearches(next);
return next;
});
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => {
if (e.key === 'ArrowDown') {
@ -234,6 +335,8 @@ export function useLocationSearch(mode?: string) {
setOpen,
handleInputChange,
handleKeyDown,
showEmptySearches,
saveRecentSearch,
close,
clear,
};

View file

@ -146,10 +146,12 @@ describe('usePoiLayers', () => {
);
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
expect(result.current.visiblePois).toEqual([]);
rerender({ zoom: 14 });
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([busStop]);
expect(result.current.visiblePois).toEqual([busStop]);
});
it('keeps POI hover popup state in sync with layer hover events', () => {

View file

@ -271,5 +271,5 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
return { poiLayers, popupInfo, clearPopupInfo };
return { poiLayers, visiblePois, popupInfo, clearPopupInfo };
}

View file

@ -32,6 +32,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Street tree density percentile': 'Percentile estimé de couverture arborée pour la rue du bien',
'Within conservation area':
'Indique si le point représentatif du code postal se trouve dans une zone de conservation',
'Listed building':
'Indique si ce bien semble correspondre à un bâtiment classé répertorié par Historic England',
'Good+ primary schools within 2km':
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km':
@ -127,7 +129,9 @@ const descriptions: Record<string, Record<string, string>> = {
'Street tree density percentile':
'Geschätztes Perzentil der Baumkronenbedeckung auf der Straße der Immobilie',
'Within conservation area':
'Ob der repräsentative Punkt der Postleitzahl in einer Conservation Area liegt',
'Ob der repräsentative Punkt der Postleitzahl in einem Erhaltungsgebiet liegt',
'Listed building':
'Ob diese Immobilie einem Eintrag für ein denkmalgeschütztes Gebäude bei Historic England zugeordnet werden kann',
'Good+ primary schools within 2km':
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Good+ secondary schools within 2km':
@ -224,6 +228,7 @@ const descriptions: Record<string, Record<string, string>> = {
'Interior height (m)': 'EPC评估的平均层高',
'Street tree density percentile': '该房产所在街道的估计树冠覆盖率百分位',
'Within conservation area': '邮编代表点是否位于指定保护区内',
'Listed building': '该房产是否疑似匹配 Historic England 的受保护建筑条目',
'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
@ -299,6 +304,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Interior height (m)': 'EPC सर्वेक्षण के अनुसार औसत अंदरूनी ऊंचाई',
'Street tree density percentile': 'संपत्ति वाली सड़क का अनुमानित वृक्ष आच्छादन प्रतिशतक',
'Within conservation area': 'पोस्टकोड प्रतिनिधि बिंदु नामित संरक्षण क्षेत्र में है या नहीं',
'Listed building':
'यह संपत्ति Historic England के सूचीबद्ध भवन रिकॉर्ड से मिलती-जुलती है या नहीं',
'Good+ primary schools within 2km':
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
'Good+ secondary schools within 2km':
@ -386,7 +393,9 @@ const descriptions: Record<string, Record<string, string>> = {
'Street tree density percentile':
'Az ingatlan utcájának becsült lombkorona-fedettségi percentilise',
'Within conservation area':
'Az irányítószám reprezentatív pontja kijelölt conservation area területre esik-e',
'Az irányítószám reprezentatív pontja kijelölt műemléki területre esik-e',
'Listed building':
'Az ingatlan látszólag megfelel-e egy Historic England műemléki épület bejegyzésének',
'Good+ primary schools within 2km':
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
'Good+ secondary schools within 2km':

View file

@ -36,9 +36,11 @@ export const details: Record<string, Record<string, string>> = {
'Interior height (m)':
"Hauteur intérieure moyenne (sol au plafond) en mètres telle qu'enregistrée lors de l'évaluation du certificat de performance énergétique (EPC). Calculée en divisant le volume intérieur total par la surface habitable totale.",
'Street tree density percentile':
"Couverture arborée approximative autour du centroïde du code postal, dérivée de la carte Trees Outside Woodland 2025 de Forest Research. Les polygones de couvert arboré des arbres isolés et groupes d'arbres sont comptés dans un rayon de 50 m de chaque centroïde de code postal, puis convertis en percentile parmi les codes postaux anglais. Il s'agit d'un proxy de centroïde de code postal, pas d'une mesure exacte du bien ou du segment de rue.",
"Couverture arborée approximative autour du centroïde du code postal, dérivée de la carte Trees Outside Woodland 2025 de Forest Research. Les polygones de couvert arboré des arbres isolés et groupes d'arbres sont comptés dans un rayon de 50 m de chaque centroïde de code postal, puis convertis en percentile parmi les codes postaux anglais. Il s'agit d'une approximation fondée sur le centroïde du code postal, pas d'une mesure exacte du bien ou du segment de rue.",
'Within conservation area':
"Limites de zones de conservation de Historic England, rattachées au point représentatif du code postal. Le jeu de données national est indicatif plutôt que définitif ; les décisions sensibles aux limites doivent être vérifiées auprès de l'autorité locale de planification.",
'Listed building':
"Points de bâtiments classés de la National Heritage List for England de Historic England, associés prudemment aux adresses des biens à partir du nom de l'entrée classée et de codes postaux proches candidats. À traiter comme un signal de présélection, pas comme une décision juridique : vérifiez tout bien précis dans la NHLE et auprès de l'autorité locale de planification.",
'Good+ primary schools within 2km':
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Good+ secondary schools within 2km':
@ -156,11 +158,11 @@ export const details: Record<string, Record<string, string>> = {
'Property type':
'Aus den HM Land Registry Price Paid-Daten und EPC-Zertifikaten. Freistehend, Doppelhaushälfte, Reihenhaus (umfasst alle Untertypen), Wohnungen/Maisonettes oder Sonstiges (Bungalows, Mobilheime usw.).',
'Leasehold/Freehold':
'Aus den HM Land Registry Price Paid-Daten. Freehold bedeutet, dass Sie das Gebäude und das Grundstück besitzen. Leasehold bedeutet, dass Sie das Gebäude, aber nicht das Grundstück besitzen: Sie haben einen Pachtvertrag vom Freeholder für eine festgelegte Anzahl von Jahren.',
'Aus den HM Land Registry Price Paid-Daten. Volleigentum bedeutet, dass du das Gebäude und das Grundstück besitzt. Erbbaurecht bedeutet, dass du das Gebäude, aber nicht das Grundstück besitzt: Du hast vom Grundeigentümer einen Vertrag für eine festgelegte Anzahl von Jahren.',
'Last known price':
'Der zuletzt erfasste Verkaufspreis für diese Immobilie aus den HM Land Registry Price Paid-Daten. Umfasst Wohnimmobilienverkäufe in England. Kann Jahre alt sein, wenn die Immobilie nicht kürzlich verkauft wurde.',
'Estimated current price':
'Basiert auf dem letzten Verkaufspreis, lokalen Preisbewegungen aus Wiederverkäufen und nahegelegenen kürzlich verkauften Immobilien. Der Repeat-Sales-Index wird nach Postleitzahlensektor und Immobilientyp verfolgt, mit Glättung und Nachbarschafts-Blending bei begrenzten Daten. Kürzliche Verkäufe bleiben nahe am erfassten Preis; ältere Verkäufe hängen stärker vom Modell ab.',
'Basiert auf dem letzten Verkaufspreis, lokalen Preisbewegungen aus Wiederverkäufen und nahegelegenen kürzlich verkauften Immobilien. Der Index wiederholter Verkäufe wird nach Postleitzahlensektor und Immobilientyp verfolgt, mit Glättung und Einbeziehung benachbarter Daten bei begrenzten Daten. Kürzliche Verkäufe bleiben nahe am erfassten Preis; ältere Verkäufe hängen stärker vom Modell ab.',
'Price per sqm':
'Berechnet durch Division des zuletzt bekannten Verkaufspreises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Nützlich zum Vergleich des Wertes verschiedener Immobiliengrößen. Nur verfügbar, wenn sowohl Preis- als auch Flächendaten vorhanden sind.',
'Est. price per sqm':
@ -184,9 +186,11 @@ export const details: Record<string, Record<string, string>> = {
'Interior height (m)':
'Durchschnittliche lichte Raumhöhe in Metern, wie während der Energieausweis-Begutachtung erfasst. Berechnet durch Division des gesamten Innenvolumens durch die Gesamtwohnfläche.',
'Street tree density percentile':
'Ungefähre Baumkronenbedeckung rund um den Postleitzahlen-Zentroiden aus der Forest-Research-Karte Trees Outside Woodland 2025. Baumkronen-Polygone für Einzelbäume und Baumgruppen werden im Umkreis von 50 m um jeden Postleitzahlen-Zentroiden gezählt und dann in ein Perzentil über englische Postleitzahlen umgerechnet. Dies ist ein Postleitzahlen-Zentroid-Proxy, keine exakte Messung für Immobilie oder Straßenabschnitt.',
'Ungefähre Baumkronenbedeckung rund um den Postleitzahlen-Zentroiden aus der Forest-Research-Karte Trees Outside Woodland 2025. Baumkronen-Polygone für Einzelbäume und Baumgruppen werden im Umkreis von 50 m um jeden Postleitzahlen-Zentroiden gezählt und dann in ein Perzentil über englische Postleitzahlen umgerechnet. Dies ist ein Näherungswert auf Basis des Postleitzahlen-Zentroids, keine exakte Messung für Immobilie oder Straßenabschnitt.',
'Within conservation area':
'Historic-England-Grenzen für Conservation Areas, dem repräsentativen Punkt der Postleitzahl zugeordnet. Der nationale Datensatz ist indikativ und nicht rechtsverbindlich; grenznahe Entscheidungen sollten bei der lokalen Planungsbehörde geprüft werden.',
'Historic-England-Grenzen für Erhaltungsgebiete, dem repräsentativen Punkt der Postleitzahl zugeordnet. Der nationale Datensatz ist indikativ und nicht rechtsverbindlich; grenznahe Entscheidungen sollten bei der lokalen Planungsbehörde geprüft werden.',
'Listed building':
'Punktdaten zu denkmalgeschützten Gebäuden aus der National Heritage List for England von Historic England, vorsichtig mit Immobilienadressen abgeglichen anhand des Namens des Denkmaleintrags und nahegelegener Postleitzahlkandidaten. Behandle dies als Vorauswahl-Hinweis, nicht als rechtliche Feststellung: Prüfe jede konkrete Immobilie in der NHLE und bei der lokalen Planungsbehörde.',
'Good+ primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 2km':
@ -335,6 +339,8 @@ export const details: Record<string, Record<string, string>> = {
'基于 Forest Research 2025 年 Trees Outside Woodland 地图估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木和树群树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
'Within conservation area':
'Historic England 保护区边界,与邮编代表点匹配。全国数据集是指示性而非最终权威;涉及边界的决策应向地方规划部门核实。',
'Listed building':
'Historic England 英格兰国家遗产名录NHLE中的受保护建筑点位记录会根据名录条目名称和附近候选邮编谨慎匹配到房产地址。请把它当作初筛信号而不是法律认定具体房产应在 NHLE 和地方规划部门核实。',
'Good+ primary schools within 2km':
'2km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 2km':
@ -475,6 +481,8 @@ export const details: Record<string, Record<string, string>> = {
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
'Within conservation area':
'Historic England संरक्षण क्षेत्र सीमाएं पोस्टकोड प्रतिनिधि बिंदु से मिलाई जाती हैं. राष्ट्रीय डेटासेट संकेतक है, अंतिम आधिकारिक नहीं; सीमा-संवेदनशील निर्णय स्थानीय योजना प्राधिकरण से जांचे जाने चाहिए.',
'Listed building':
'Historic England की इंग्लैंड की राष्ट्रीय धरोहर सूची (NHLE) में सूचीबद्ध भवनों के बिंदु रिकॉर्ड, जिन्हें सूचीबद्ध प्रविष्टि के नाम और पास के संभावित पोस्टकोड के आधार पर संपत्ति पते से सावधानी से मिलाया गया है. इसे केवल प्रारंभिक जांच संकेत मानें, कानूनी निर्णय नहीं: किसी भी विशिष्ट संपत्ति को NHLE और स्थानीय योजना प्राधिकरण से सत्यापित करें.',
'Good+ primary schools within 2km':
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
'Good+ secondary schools within 2km':
@ -590,9 +598,9 @@ export const details: Record<string, Record<string, string>> = {
},
hu: {
'Property type':
'Az HM Land Registry Price Paid adatokból és EPC tanúsítványokból. Különálló, Ikerház, Sorház (minden sorház altípust tartalmaz), Lakás/Maisonette, vagy Egyéb (bungaló, mobilház stb.).',
'Az HM Land Registry Price Paid adatokból és EPC tanúsítványokból. Különálló ház, ikerház, sorház (minden sorház-altípussal), lakás vagy kétszintes lakás, illetve egyéb típus (bungaló, mobilház stb.).',
'Leasehold/Freehold':
'Az HM Land Registry Price Paid adatokból. A Freehold azt jelenti, hogy az épület és a telek is az Ön tulajdona. A Leasehold azt jelenti, hogy az épület az Ön tulajdona, de a telek nem: a telektulajdonostól meghatározott számú évre szóló bérleti jogot kapott.',
'Az HM Land Registry Price Paid adatokból. A teljes tulajdon azt jelenti, hogy az épület és a telek is az Ön tulajdona. A bérleti tulajdonjog azt jelenti, hogy az épület az Ön tulajdona, de a telek nem: a telektulajdonostól meghatározott számú évre szóló jogot kapott.',
'Last known price':
'Az ingatlan utolsó rögzített adásvételi ára az HM Land Registry Price Paid adatokból. Az angliai lakóingatlan-értékesítésekre vonatkozik. Lehet, hogy évekkel ezelőtti adat, ha az ingatlan nem kelt el a közelmúltban.',
'Estimated current price':
@ -620,9 +628,11 @@ export const details: Record<string, Record<string, string>> = {
'Interior height (m)':
'Az EPC-tanúsítvány felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
'Street tree density percentile':
'A Forest Research 2025-os Trees Outside Woodland térképéből származó hozzávetőleges lombkorona-fedettség az irányítószám-középpont körül. A magányos fák és facsoportok lombkorona-poligonjait minden irányítószám-középpont 50 méteres körzetében számoljuk, majd az angliai irányítószámok közötti percentilissé alakítjuk. Ez irányítószám-középponti proxy, nem pontos ingatlan- vagy utcaszakasz-mérés.',
'A Forest Research 2025-os Trees Outside Woodland térképéből származó hozzávetőleges lombkorona-fedettség az irányítószám-középpont körül. A magányos fák és facsoportok lombkorona-poligonjait minden irányítószám-középpont 50 méteres körzetében számoljuk, majd az angliai irányítószámok közötti percentilissé alakítjuk. Ez az irányítószám-középponton alapuló közelítő mutató, nem pontos ingatlan- vagy utcaszakasz-mérés.',
'Within conservation area':
'Historic England conservation area határok az irányítószám reprezentatív pontjához rendelve. Az országos adatállomány tájékoztató jellegű, nem végleges; határérzékeny döntéseknél a helyi tervezési hatóság adatait kell ellenőrizni.',
'A Historic England műemléki területeinek határai az irányítószám reprezentatív pontjához rendelve. Az országos adatállomány tájékoztató jellegű, nem végleges; határérzékeny döntéseknél a helyi tervezési hatóság adatait kell ellenőrizni.',
'Listed building':
'A Historic England National Heritage List for England műemlékiépület-pontrekordjai, amelyeket óvatosan egyeztetünk ingatlancímekhez a műemléki bejegyzés neve és a közeli irányítószám-jelöltek alapján. Előszűrési jelzésként kezelendő, nem jogi megállapításként: minden konkrét ingatlant ellenőrizni kell az NHLE-ben és a helyi tervezési hatóságnál.',
'Good+ primary schools within 2km':
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 2km':

File diff suppressed because it is too large Load diff

View file

@ -70,6 +70,21 @@ const en = {
closeMenu: 'Close menu',
},
// ── Export Menu ────────────────────────────────────
export: {
title: 'Export',
modeFilters: 'Postcodes matching filters',
modeFiltersHint: 'Export every postcode visible on the map that matches your current filters.',
modeList: 'List of postcodes',
modeListHint: 'Add your own postcodes one by one — spacing and casing are fixed for you.',
listLabel: 'Postcodes',
listPlaceholder: 'e.g. SW1A 1AA',
addRow: 'Add postcode',
removeRow: 'Remove postcode',
listCount: '{{count}} postcode',
listCount_other: '{{count}} postcodes',
},
// ── User Menu ──────────────────────────────────────
userMenu: {
fullAccess: 'Full Access',
@ -796,7 +811,8 @@ const en = {
rooms: 'Rooms:',
built: 'Built:',
formerCouncil: 'Ex-council:',
exCouncilBadge: 'Ex-council',
exCouncilBadge: 'Maybe ex-council house',
listedBuildingBadge: 'Maybe listed',
epcRating: 'EPC rating:',
epcPotential: 'EPC potential:',
renovations: 'Renovations',
@ -849,16 +865,6 @@ const en = {
nationalAvg: 'National avg',
},
// ── Histogram Legend ───────────────────────────────
histogramLegend: {
tealBars: 'Teal bars',
tealBarsDesc: 'show the distribution in this selected area',
greyBars: 'Grey bars',
greyBarsDesc: 'show the overall distribution across all areas',
dashedLine: 'Dashed line',
dashedLineDesc: 'indicates the national average',
},
// ── Street View ────────────────────────────────────
streetView: {
title: 'Street View',
@ -1604,6 +1610,14 @@ const en = {
Zoo: 'Zoo',
'Tourist Attraction': 'Tourist Attraction',
School: 'School',
'Nursery school': 'Nursery school',
'Primary school': 'Primary school',
'Secondary school': 'Secondary school',
'All-through school': 'All-through school',
'Sixth form': 'Sixth form',
'Further education college': 'Further education college',
University: 'University',
'Special school': 'Special school',
Hotel: 'Hotel',
'Local Business': 'Local Business',
Offices: 'Offices',

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -53,7 +53,7 @@ const zh: Translations = {
appName: 'Perfect Postcode',
dashboard: '地图面板',
learn: '了解更多',
pricing: '',
pricing: '价',
inviteFriends: '邀请好友',
saved: '已保存',
logIn: '登录',
@ -62,7 +62,7 @@ const zh: Translations = {
exportLabel: '导出',
exporting: '导出中...',
exportToExcel: '导出为 Excel',
exportReady: '导出已就绪。下载应会开始。',
exportReady: '导出已就绪。下载应会自动开始。',
exportFailed: '导出失败。',
exportTimedOut: '导出超时。请重试。',
exportUnavailable: '地图仍在加载。请稍后重试。',
@ -71,9 +71,24 @@ const zh: Translations = {
closeMenu: '关闭菜单',
},
// ── Export Menu ────────────────────────────────────
export: {
title: '导出',
modeFilters: '符合筛选条件的邮编',
modeFiltersHint: '导出地图上所有符合当前筛选条件的邮编。',
modeList: '邮编列表',
modeListHint: '逐个添加您自己的邮编,系统会自动规范空格和大小写。',
listLabel: '邮编',
listPlaceholder: '例如 SW1A 1AA',
addRow: '添加邮编',
removeRow: '删除邮编',
listCount: '{{count}} 个邮编',
listCount_other: '{{count}} 个邮编',
},
// ── User Menu ──────────────────────────────────────
userMenu: {
fullAccess: '完整访问',
fullAccess: '完整访问权限',
demo: '演示版',
themeLight: '主题:浅色',
themeDark: '主题:深色',
@ -118,8 +133,7 @@ const zh: Translations = {
'按邮编筛选历史成交价与当前估值。',
'Compare value with commute, schools, broadband, crime, noise, and amenities.':
'把房价与通勤、学校、宽带、治安、噪音、便利设施放在一起比较。',
'Build a shortlist before spending weekends on viewings.':
'别急着用周末跑看房,先建好候选名单。',
'Build a shortlist before spending weekends on viewings.': '花周末看房之前,先建立候选名单。',
'Find postcodes that fit the budget before listings appear':
'抢在房源上架之前,先锁定符合预算的邮编',
'Start with a maximum price and property type, then colour the map by price per square metre or estimated current price. This helps reveal areas where similar homes have historically traded within reach, even when there are no live listings today.':
@ -144,7 +158,7 @@ const zh: Translations = {
'地图用于横向比较区域、圈出候选范围,并不代替估价、按揭决策、验房、法律检索或实时房源信息。',
'How to validate a promising area': '看好一个区域之后,如何进一步验证',
'Once a postcode looks promising, check current listings, sold-price comparables, agent details, flood searches, legal packs, surveys, and local authority information before making a decision.':
'某个邮编看起来有戏,决定之前请先核对当前房源、可比成交价、中介信息、洪水风险查询、法律资料包、验房报告以及地方政府信息。',
'某个邮编看起来有潜力时,做决定前请先核对当前房源、可比成交价、中介信息、洪水风险查询、法律资料包、验房报告以及地方政府信息。',
'Is this a replacement for Rightmove or Zoopla?': '它能取代 Rightmove 或 Zoopla 吗?',
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show whats currently for sale.':
'不能它应当配合房源平台使用Perfect Postcode 帮您决定该去哪儿找,房源平台告诉您当下哪些房子在售。',
@ -203,7 +217,7 @@ const zh: Translations = {
'可以。已授权用户可以保存搜索,随时回来接着用。保存的搜索专为整理候选名单和比对笔记而设计。',
'Can I search without knowing the area?': '不熟悉当地,也能搜索吗?',
'Yes. The map is designed to surface unfamiliar areas that match practical constraints, not just places you already know.':
'可以。地图天生就为发掘陌生区域而设计——只要符合您的条件,它就会推到您面前,而不只是把您已经知道的地方再列一遍。',
'可以。地图旨在发现符合实际条件的陌生区域,而不只是列出您已经知道的地方。',
'Are the results live property listings?': '搜索结果是实时房源吗?',
'No. The tool compares postcode data and historical/contextual property signals. You still need listing portals for current availability.':
'不是。本工具比较的是邮编数据,以及历史与背景类的房产信号。当下哪些房子在售,仍需去房源平台查看。',
@ -321,8 +335,8 @@ const zh: Translations = {
'动身看房前,用邮编速查把价格走势、周边背景、便利设施、学校与环境信号先过一遍。',
'Compare neighbouring postcodes': '比较相邻邮编',
'If one postcode looks promising, compare adjacent areas using the same filters. This often reveals whether a concern is street-specific or part of a wider pattern.':
'某个邮编看起来有戏,就用同一套筛选条件看相邻区域。这往往能看出某个问题是这条街的特例,还是整个片区的通病。',
'Useful before and alongside listing portals': '搭配房源平台使用,事半功倍',
'某个邮编看起来有潜力时,就用同一套筛选条件查看相邻区域。这往往能看出某个问题是这条街的特例,还是更大范围内的共性。',
'Useful before and alongside listing portals': '适合在使用房源平台前及同时使用',
'Listing photos rarely tell you enough about the surrounding street. Perfect Postcode gives you an evidence-led postcode check before you commit time to a viewing.':
'房源照片很难讲清周围街道的真实情况。Perfect Postcode 让您出门看房前,先用数据把邮编查个清楚。',
'A screening tool, not professional advice': '是初筛工具,不是专业建议',
@ -636,15 +650,15 @@ const zh: Translations = {
primary: '小学',
secondary: '中学',
rating: '评级',
goodPlus: '良好+',
goodPlus: '良好及以上',
outstanding: '优秀',
distance: '距离',
crimeType: '犯罪类型',
ethnicity: '族裔',
poiType: 'POI 类型',
poiType: '兴趣点类型',
party: '政党',
travelTimeKeywords:
'通勤 通勤时间 出行 出行时间 旅行 旅行时间 路程 行程 驾车 开车 汽车 自行车 单车 骑行 骑车 步行 走路 公共交通 公交 交通 运输 车站 地铁 火车 公共汽车 巴士 路线 travel time journey commute car bicycle bike walking transit transport station tube train',
'通勤 通勤时间 出行 出行时间 旅行 旅行时间 路程 行程 驾车 开车 汽车 自行车 单车 骑行 骑车 步行 走路 公共交通 公交 交通 运输 车站 地铁 火车 公共汽车 巴士 路线',
},
// ── Philosophy Popup ───────────────────────────────
@ -658,7 +672,7 @@ const zh: Translations = {
step3Title: '安全',
step3Desc: '(犯罪率、噪音水平、地面稳定性)',
step4Title: '学校',
step4Desc: '(附近 Ofsted 评级为"良好"或"优秀"的学校)',
step4Desc: '(附近 Ofsted 评级为“良好”或“优秀”的学校)',
step5Title: '生活方式',
step5Desc: '(餐厅、公园、宽带速度)',
step6Title: '能源',
@ -678,11 +692,11 @@ const zh: Translations = {
noChange: '无换乘',
noChangeTitle: '仅直达行程',
noChangeDesc:
'仅限<strong>无换乘</strong>行程 —步行、乘坐一次公共交通、再步行到目的地。',
'仅限<strong>无换乘</strong>行程:步行、乘坐一次公共交通,再步行到目的地。适合希望一路直达的通勤。',
noBuses: '不含公交',
noBusesTitle: '排除公交',
noBusesDesc:
'排除公交服务 —仅 <strong>火车、地铁、有轨电车和渡轮</strong>。便于筛选避开交通拥堵的行程。',
'从允许的公共交通方式中排除公交车,只保留<strong>火车、地铁、有轨电车和渡轮</strong>。适合筛选更少受道路拥堵影响的行程。',
previewOnMap: '在地图上预览',
stopPreviewing: '停止预览',
removeTravelTime: '移除通勤时间',
@ -729,7 +743,7 @@ const zh: Translations = {
// ── Map Legend ─────────────────────────────────────
mapLegend: {
clearColourView: '清除颜色视图',
resetColourScale: '重置颜色比例',
resetColourScale: '重置颜色刻度',
historicalMatches: '历史房产匹配',
numberOfProperties: '房产数量',
previewing: '预览\u201c{{name}}\u201d',
@ -757,7 +771,8 @@ const zh: Translations = {
rooms: '房间:',
built: '建造年份:',
formerCouncil: '原公房:',
exCouncilBadge: '原公房',
exCouncilBadge: '可能原公房',
listedBuildingBadge: '可能为登录建筑',
epcRating: '能源评级:',
epcPotential: '潜在能源评级:',
renovations: '翻新记录',
@ -793,7 +808,7 @@ const zh: Translations = {
lowerMinTo: '将最小值降至 {{value}}',
raiseMaxTo: '将最大值提高至 {{value}}',
allowCategory: '允许 {{value}}',
missingFilterValue: '此筛选条件没有值;请移除它或允许缺失值',
missingFilterValue: '此筛选条件没有值;请移除它',
noFilterDataShort: '无数据',
travelTo: '前往 {{destination}} 的出行',
viewProperties: '查看 {{count}} 处房产',
@ -808,16 +823,6 @@ const zh: Translations = {
nationalAvg: '全国平均',
},
// ── Histogram Legend ───────────────────────────────
histogramLegend: {
tealBars: '青色柱状图',
tealBarsDesc: '显示所选区域内的分布情况',
greyBars: '灰色柱状图',
greyBarsDesc: '显示所有区域的整体分布情况',
dashedLine: '虚线',
dashedLineDesc: '表示全国平均值',
},
// ── Street View ────────────────────────────────────
streetView: {
title: '街景视图',
@ -860,7 +865,7 @@ const zh: Translations = {
// ── Home Page ──────────────────────────────────────
home: {
heroEyebrow: '献给那些正在问"我到底该去哪儿找"的买家',
heroEyebrow: '献给那些正在问“我到底该去哪儿找?”的买家',
heroTitle1: '找到真正',
heroTitle2: '适合您生活的邮编',
heroTitle3: '不只局限于您已经熟悉的区域。',
@ -879,11 +884,11 @@ const zh: Translations = {
showcaseFeatureNoiseShort: '噪声',
showcaseFeatureSchoolsShort: '学校',
showcaseFeatureTravelShort: '出行',
showcaseGoodPrimariesNearby: '附近 {{count}}+ 所良好小学',
showcaseWithinRail: '{{count}} 分钟内到达铁路',
showcaseGoodPrimariesNearby: '附近 {{count}}+ 所良好及以上小学',
showcaseWithinRail: '距铁路 {{count}} 分钟内',
showcaseMatchingHomesLabel: '匹配房源',
showcaseMatchingHomes: '{{value}} 个匹配房源',
showcaseMedianPrice: '{{value}} 中位数',
showcaseMedianPrice: '中位数 {{value}}',
showcaseJourneyRoutes: '出行路线',
showcaseNearby: '附近 {{value}} 个',
showcasePoliticalVoteShare: '政党得票份额',
@ -922,7 +927,7 @@ const zh: Translations = {
showcaseStep3Stat4Label: '宽带',
showcaseStep3Stat4Value: '可用 1 Gbps',
showcaseStep3Stat5Label: '小学',
showcaseStep3Stat5Value: '1英里内3所「优秀」',
showcaseStep3Stat5Value: '1 英里内有 3 所“优秀”学校',
showcaseStep4Tab: '踏勘',
showcaseStep4Title: '亲自去看一看',
showcaseStep4Body:
@ -947,7 +952,7 @@ const zh: Translations = {
streetIntro:
'笼统的区域名容易掩盖关键差异:在车站哪一侧、道路噪音、学校组合、真实通勤时间,以及同类房产的实际成交价。',
streetCard1Title: '发现您可能错过的区域',
streetCard1Body: '根据您的条件找出匹配的邮编,不再只凭熟悉的地名、朋友推荐或"潜力区域"的宣传。',
streetCard1Body: '根据您的条件找出匹配的邮编,不再只凭熟悉的地名、朋友推荐或“潜力区域”的宣传。',
streetCard2Title: '看房前先看清取舍',
streetCard2Body:
'把周末花在看房之前,先把价格、空间、通勤、治安、学校、宽带、噪音和能源评级一并对比。',
@ -983,7 +988,7 @@ const zh: Translations = {
filled: '已满',
openDashboard: '打开地图面板',
getStarted: '立即开始',
getStartedPrice: '立即开始 - {{price}}',
getStartedPrice: '立即开始{{price}}',
noCreditCard: '无需信用卡',
soldOut: '已售罄',
@ -1011,7 +1016,7 @@ const zh: Translations = {
'浏览关于房产搜索、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
source: '来源:',
optOut: '退出公开披露',
optOut: '选择不公开',
attribution: '数据引用声明',
attrLandRegistry: '包含 HM Land Registry 数据 © Crown copyright and database right 2025。',
attrOgl: '包含根据以下许可证授权的公共部门信息:',
@ -1029,7 +1034,7 @@ const zh: Translations = {
dsEpcName: '能源性能证书EPC',
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
dsEpcUse:
'住宅能源性能证书,提供建筑面积、房间数量、建造年份、能源评级、房产类型和建筑形式等信息。通过每个邮编内的地址与成交价格数据进行匹配。业主可以退出公开披露。',
'住宅能源性能证书,提供建筑面积、房间数量、建造年份、能源评级、房产类型和建筑形式等信息。通过每个邮编内的地址与成交价格数据进行匹配。业主可以选择不公开。',
dsNsplName: '国家统计邮编查询NSPL',
dsNsplOrigin: 'ONS / ArcGIS',
dsNsplUse: '将邮编映射到坐标和统计区域代码,用于将所有区域级数据集关联到各个房产。',
@ -1064,8 +1069,7 @@ const zh: Translations = {
dsConservationAreasUse: '英格兰指定保护区边界。用于标记邮编代表点是否位于保护区内。',
dsListedBuildingsName: 'Historic England 登录建筑',
dsListedBuildingsOrigin: 'Historic England 英格兰国家遗产名录',
dsListedBuildingsUse:
'英格兰登录建筑点位记录。用于标记地址似乎与附近登录建筑条目匹配的房产。',
dsListedBuildingsUse: '英格兰登录建筑点位记录。用于标记地址似乎与附近登录建筑条目匹配的房产。',
dsNaptanName: 'NaPTAN公共交通站点',
dsNaptanOrigin: 'Department for Transport',
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
@ -1122,9 +1126,9 @@ const zh: Translations = {
faqCommute2Q: '这些出行时间数字有什么局限?',
faqCommute2A:
'公共交通时间基于工作日早晨通勤,出发时间在 07:30 到 08:30 之间,默认显示该时段的典型行程。这些是用于规划的估算,不包含实时延误、交通状况或临时改月台。',
faqCommute3Q: '什么时候用"最佳情况"按钮?',
faqCommute3Q: '什么时候用“最佳情况”按钮?',
faqCommute3A:
'在公共交通模式下,若想查看出发时间踩得准、换乘也顺利时的通勤表现,就开"最佳情况"。日常比较时关掉即可。',
'在公共交通模式下,若想查看出发时间踩得准、换乘也顺利时的通勤表现,就开启“最佳情况”。日常比较时请保持关闭,因为默认设置更接近大多数日子的预期。',
// FAQ items — Budget and Value
faqBudget1Q: '你们如何估算当前房价?',
faqBudget1A:
@ -1172,7 +1176,7 @@ const zh: Translations = {
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: '你们会存储关于我的个人数据吗?',
faqPrivacy1A:
'房产与社区信息本身不含您的个人资料。若您创建账户,我们只存储运行服务所必需的信息:邮箱地址、访问状态、新闻邮件订阅选择、已保存的搜索、已保存的房产,以及由 Stripe 处理的付款。账户数据按英国隐私法律处理。',
'房产与社区信息本身不含您的个人资料。若您创建账户,我们只存储运行服务所必需的信息:邮箱地址、访问状态、新闻邮件订阅选择、已保存的搜索、分享链接,以及由 Stripe 处理的付款记录。账户数据按英国隐私法律处理。',
// FAQ items — Why Perfect Postcode
faqWhy1Q: '它展示了哪些房源门户通常看不到的信息?',
faqWhy1A:
@ -1197,13 +1201,13 @@ const zh: Translations = {
// FAQ items — Tips and Tricks
faqTips1Q: '如何在地图上预览筛选条件?',
faqTips1A:
'点击筛选条件或数据项旁的眼睛图标,就能按该项给地图着色。当前的筛选保持不变,因此可以快速对比价格、出行时间、学校、治安或噪音等单项,候选范围不会改变。',
'点击筛选条件或数据项旁的“地图着色”,就能按该项给地图着色。当前的筛选保持不变,因此可以快速对比价格、出行时间、学校、治安或噪音等单项,候选范围不会改变。',
faqTips2Q: '如何了解某个筛选条件的含义?',
faqTips2A:
'点击筛选条件或数据项旁的信息按钮,会有一段简短说明,告诉您它是什么、该怎么读。地图中的一些部分——例如出行时间卡片——也有各自的信息按钮。',
'点击筛选条件或数据项旁的“关于”,会有一段简短说明,告诉您它是什么、该怎么读。地图中的一些部分——例如出行时间卡片——也有各自的数据说明。',
faqTips3Q: '如何刷新地图颜色?',
faqTips3A:
'当眼睛预览正在给地图着色时,在地图图例里点"重置颜色比例"即可按当前结果重新配色。平移、缩放或调整筛选之后,特别管用。',
'当某个数据项正在给地图着色时,在地图图例里点“重置颜色刻度”即可按当前结果重新配色。平移、缩放或调整筛选之后,特别管用。',
// FAQ items — Behind The Data
faqBehindData1Q: '为什么机场有时看起来比周围的街道更安静?',
@ -1215,15 +1219,15 @@ const zh: Translations = {
faqBehindData3Q: '为什么相邻邮编的犯罪数字相同?',
faqBehindData3A:
'警方街道级犯罪数据按 LSOA 发布——大约 1,500 居民的小型社区单元。同一 LSOA 内每个邮编都继承同一年度总数,因此一条安静的住宅街和一个街区外的繁华街道,如果在同一边界内,可能显示完全相同的数据。覆盖医院、大学校园或工业园区的邮编,人均率可能异常偏高,因为那里事件数正常但纸面居民很少。',
faqBehindData4Q: '"2 公里内的好学校"是否意味着我孩子可以入读?',
faqBehindData4Q: '“2 公里内的好学校”是否意味着我孩子可以入读?',
faqBehindData4A:
'不一定。统计查找的是自身邮编落在您邮编中心点周围圆形范围内的公立学校。学区、宗教或选拔标准、兄弟姐妹优先以及录取规则都没有建模——附近的"好"或"杰出"学校可能从您家其实无法入读。请用此数字对比区域,决策前向学校或地方政府确认实际录取条件。',
faqBehindData5Q: '为什么并非每户都有光纤的邮编也显示"Gigabit"',
'不一定。统计查找的是自身邮编落在您邮编中心点周围圆形范围内的公立学校。学区、宗教或选拔标准、兄弟姐妹优先以及录取规则都没有建模——附近的“良好”或“优秀”学校可能从您家其实无法入读。请用此数字对比区域,决策前向学校或地方政府确认实际录取条件。',
faqBehindData5Q: '为什么并非每户都有光纤的邮编也显示“Gigabit”',
faqBehindData5A:
'Ofcom Connected Nations 的宽带覆盖按邮编给出可达到每个速度档的物业百分比。我们显示有任何可用性的最高档,因此只要邮编内有一户能达到 Gigabit就会显示"Gigabit 可用"。这正确回答了"这条街上到底有没有光纤?",但并不保证楼里每一套今天都能下单。签约前,请始终就您的具体地址向运营商核实。',
'Ofcom Connected Nations 的宽带覆盖按邮编给出可达到每个速度档的物业百分比。我们显示有任何可用性的最高档,因此只要邮编内有一户能达到 Gigabit就会显示“Gigabit 可用”。这正确回答了“这条街上到底有没有光纤?”,但并不保证楼里每一套今天都能下单。签约前,请始终就您的具体地址向运营商核实。',
faqBehindData6Q: '为什么公共交通时间在晚上或周末不变?',
faqBehindData6A:
'每个目的地的公时间是基于完整 GTFS 时刻表按一个周二早上的出发窗口07:3008:30一次性计算的。"普通"值是该窗口内行程的中位数,"最佳情况"是第 5 百分位。非高峰、深夜和周末班次没有建模,因此只有早高峰公交的邮编在地图上仍可能显示交通便利。请把这些数字当作工作日通勤估算,而不是全天平均。',
'每个目的地的公共交通时间是基于完整 GTFS 时刻表按一个周二早上的出发窗口07:3008:30一次性计算的。“普通”值是该窗口内行程的中位数,“最佳情况”是第 5 百分位。非高峰、深夜和周末班次没有建模,因此只有早高峰公交的邮编在地图上仍可能显示交通便利。请把这些数字当作工作日通勤估算,而不是全天平均。',
},
// ── Account Page ───────────────────────────────────
@ -1258,12 +1262,12 @@ const zh: Translations = {
invitesPage: {
inviteLinksLicensed: '邀请链接仅对已授权用户开放。',
inviteAdminLabel: '邀请好友100% 折扣)',
inviteReferralLabel: '邀请好友(7折优惠)',
inviteReferralLabel: '邀请好友(折优惠)',
generateFreeInvite: '生成免费邀请链接',
generateReferralLink: '生成推荐链接',
copyInviteLink: '复制邀请链接',
adminInvitesTitle: '管理员邀请100% 折扣)',
referralInvitesTitle: '推荐邀请(7折优惠)',
referralInvitesTitle: '推荐邀请(折优惠)',
yourInviteLinks: '您的邀请链接',
noInvitesYet: '暂无已生成的邀请',
link: '链接',
@ -1278,9 +1282,9 @@ const zh: Translations = {
youreInvited: '您收到了邀请!',
specialOffer: '特别优惠!',
invitedByFree: '{{name}} 邀请您获取免费终身访问权限。',
invitedByDiscount: '{{name}} 与您分享了终身访问的7折优惠。',
invitedByDiscount: '{{name}} 与您分享了终身访问的折优惠。',
genericFreeInvite: '您已被邀请获取免费终身访问权限。',
genericDiscount: '一位朋友与您分享了终身访问的7折优惠。',
genericDiscount: '一位朋友与您分享了终身访问的折优惠。',
exploreEvery: '找到适合您生活的邮编',
propertyInfo: '价格、通勤、学校、犯罪率、噪音、宽带、EPC 等',
invalidInvite: '无效的邀请',
@ -1311,10 +1315,10 @@ const zh: Translations = {
poiCategories: '{{count}} 个兴趣点类别',
travelDestination: '{{count}} 个出行目的地',
travelDestinations: '{{count}} 个出行目的地',
propertiesMatch: '{{count}} 套房产符合',
propertiesMatch: '匹配 {{count}} 套房产',
setFilters: '设置 {{count}} 个筛选:{{list}}',
noFiltersSet: '未设置筛选',
toDestination: '{{mode}} {{label}} {{bounds}}',
toDestination: '{{mode}}前往 {{label}} {{bounds}}',
lessThanMin: '< {{max}} 分钟',
moreThanMin: '> {{min}} 分钟',
},
@ -1323,7 +1327,7 @@ const zh: Translations = {
tutorial: {
step1Title: '告诉地图什么重要',
step1Content:
'设置预算、通勤上限、学校质量、犯罪门槛、噪音容忍度、宽带需求,或任何您关心的条件。只有匹配区域会保持高亮。使用眼睛图标可按任意指标着色。',
'设置预算、通勤上限、学校质量、犯罪门槛、噪音容忍度、宽带需求,或任何您关心的条件。只有匹配区域会保持高亮。使用“地图着色”可按任意指标给地图着色。',
step2Title: '或者直接描述',
step2Content:
'用自然语言输入您的需求例如“安静的地区靠近好学校£400,000 以下”,我们会为您设置筛选。',
@ -1367,7 +1371,7 @@ const zh: Translations = {
'Number of bedrooms & living rooms': '卧室和客厅数量',
'Construction year': '建造年份',
'Date of last transaction': '上次交易日期',
'Former council house': '原公共住房',
'Former council house': '原公房',
'Current energy rating': '当前能源评级',
'Potential energy rating': '潜在能源评级',
'Interior height (m)': '室内层高(米)',
@ -1379,20 +1383,20 @@ const zh: Translations = {
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': '2公里内良好+小学数量',
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
'Good+ primary schools within 5km': '5公里内良好+小学数量',
'Good+ secondary schools within 5km': '5公里内良好+中学数量',
'Outstanding primary schools within 2km': '2公里内优秀小学数量',
'Outstanding secondary schools within 2km': '2公里内优秀中学数量',
'Outstanding primary schools within 5km': '5公里内优秀小学数量',
'Outstanding secondary schools within 5km': '5公里内优秀中学数量',
'Education, Skills and Training Score': '教育、技能培训得分',
'Good+ primary schools within 2km': '2 公里内良好及以上小学数量',
'Good+ secondary schools within 2km': '2 公里内良好及以上中学数量',
'Good+ primary schools within 5km': '5 公里内良好及以上小学数量',
'Good+ secondary schools within 5km': '5 公里内良好及以上中学数量',
'Outstanding primary schools within 2km': '2 公里内优秀小学数量',
'Outstanding secondary schools within 2km': '2 公里内优秀中学数量',
'Outstanding primary schools within 5km': '5 公里内优秀小学数量',
'Outstanding secondary schools within 5km': '5 公里内优秀中学数量',
'Education, Skills and Training Score': '教育、技能培训得分',
// ─ Feature names (Area development) ─
'Income Score': '收入得分',
'Employment Score': '就业得分',
'Health Deprivation and Disability Score': '健康与残障得分',
'Health Deprivation and Disability Score': '健康剥夺与残障得分',
'Housing Conditions Score': '住房状况得分',
'Air Quality and Road Safety Score': '空气质量与道路安全得分',
@ -1507,8 +1511,8 @@ const zh: Translations = {
Bakery: '面包店',
'Butcher & Fishmonger': '肉铺与鱼铺',
Greengrocer: '果蔬店',
'Off-Licence': '酒类店',
'Deli & Specialty': '熟食与特店',
'Off-Licence': '酒类专卖店',
'Deli & Specialty': '熟食与特色食品店',
'Fashion & Clothing': '时装服饰',
Electronics: '电子产品',
'Charity Shop': '慈善商店',
@ -1529,7 +1533,7 @@ const zh: Translations = {
'Vet & Pet Care': '宠物医院与护理',
Bank: '银行',
'Travel Agent': '旅行社',
Police: '警察',
Police: '警察',
'Fire Station': '消防站',
'Ambulance Station': '急救站',
'GP Surgery': '全科诊所',
@ -1540,7 +1544,7 @@ const zh: Translations = {
Physiotherapy: '理疗',
'Counselling & Therapy': '心理咨询与治疗',
'Care Home': '养老院',
'Medical & Mobility': '医疗器械与辅助设备',
'Medical & Mobility': '医疗用品与行动辅助设备',
Museum: '博物馆',
Gallery: '美术馆',
Library: '图书馆',
@ -1549,6 +1553,14 @@ const zh: Translations = {
Zoo: '动物园',
'Tourist Attraction': '旅游景点',
School: '学校',
'Nursery school': '幼儿园',
'Primary school': '小学',
'Secondary school': '中学',
'All-through school': '一贯制学校',
'Sixth form': '高中16+',
'Further education college': '继续教育学院',
University: '大学',
'Special school': '特殊学校',
Hotel: '酒店',
'Local Business': '本地商业',
Offices: '写字楼',

View file

@ -171,6 +171,15 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
'Off-Licence': '/assets/twemoji/1f377.png',
'Planet Organic': '/assets/poi-icons/logos/planet_organic.svg',
'Rail station': '/assets/twemoji/1f686.png',
School: '/assets/twemoji/1f3eb.png',
'Nursery school': '/assets/twemoji/1f9f8.png',
'Primary school': '/assets/twemoji/1f392.png',
'Secondary school': '/assets/twemoji/1f3eb.png',
'All-through school': '/assets/twemoji/1f3eb.png',
'Sixth form': '/assets/twemoji/1f4da.png',
'Further education college': '/assets/twemoji/1f4da.png',
University: '/assets/twemoji/1f393.png',
'Special school': '/assets/twemoji/1f91d.png',
"Sainsbury's": '/assets/poi-icons/logos/sainsburys.svg',
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
Spar: '/assets/poi-icons/logos/spar.svg',
@ -198,6 +207,9 @@ export const POI_CLUSTER_RADIUS = 50;
/** Zoom level at which supercluster stops clustering */
export const POI_CLUSTER_MAX_ZOOM = 15;
/** Zoom level at which individual POI cards are shown without hovering */
export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1;
/**
* Groups whose features should be collapsed into stacked bar charts.
* Keyed by feature group name. Each entry defines one stacked chart.

View file

@ -119,6 +119,7 @@ export interface SchoolMetadata {
website?: string;
telephone?: string;
head_name?: string;
ofsted_rating?: string;
}
export interface POI {