alright
|
|
@ -8,6 +8,7 @@ SHELL := /bin/bash
|
||||||
|
|
||||||
DATA_DIR := ./property-data
|
DATA_DIR := ./property-data
|
||||||
MANUAL_DATA := ./manual-data
|
MANUAL_DATA := ./manual-data
|
||||||
|
FINDER_DATA := ./finder/data
|
||||||
|
|
||||||
# ── Output files ──────────────────────────────────────────────────────────────
|
# ── Output files ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -27,6 +28,8 @@ MERGE_STAMP := $(DATA_DIR)/.merge_done
|
||||||
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
|
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
|
||||||
PRICES_STAMP := $(DATA_DIR)/.prices_done
|
PRICES_STAMP := $(DATA_DIR)/.prices_done
|
||||||
EPC := $(MANUAL_DATA)/domestic-csv.zip
|
EPC := $(MANUAL_DATA)/domestic-csv.zip
|
||||||
|
ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet
|
||||||
|
ACTUAL_LISTINGS_ENRICHED := $(FINDER_DATA)/online_listings_buy_enriched.parquet
|
||||||
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
|
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
|
||||||
CRIME_DIR := $(DATA_DIR)/crime
|
CRIME_DIR := $(DATA_DIR)/crime
|
||||||
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
|
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
|
||||||
|
|
@ -106,12 +109,13 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_
|
||||||
download-map-assets \
|
download-map-assets \
|
||||||
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
||||||
transform-school-proximity transform-tree-density \
|
transform-school-proximity transform-tree-density \
|
||||||
generate-postcode-boundaries generate-travel-times
|
generate-postcode-boundaries generate-travel-times enrich-actual-listings
|
||||||
|
|
||||||
prepare: $(PRICES_STAMP) download-places tiles overlay-tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX)
|
prepare: $(PRICES_STAMP) download-places tiles overlay-tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX)
|
||||||
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX)
|
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX)
|
||||||
merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ)
|
merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ)
|
||||||
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ)
|
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ)
|
||||||
|
enrich-actual-listings: $(ACTUAL_LISTINGS_ENRICHED)
|
||||||
tiles: $(TILES)
|
tiles: $(TILES)
|
||||||
overlay-tiles: noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles
|
overlay-tiles: noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles
|
||||||
noise-overlay-tiles: $(NOISE_OVERLAY_TILES)
|
noise-overlay-tiles: $(NOISE_OVERLAY_TILES)
|
||||||
|
|
@ -319,8 +323,8 @@ $(MAP_ASSETS_STAMP): $(MAP_ASSETS_DEPS)
|
||||||
|
|
||||||
# ── Transforms ────────────────────────────────────────────────────────────────
|
# ── Transforms ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(ENGLAND_BOUNDARY) pipeline/transform/transform_poi.py pipeline/utils/england_geometry.py
|
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(OFSTED) $(ENGLAND_BOUNDARY) pipeline/transform/transform_poi.py pipeline/utils/england_geometry.py
|
||||||
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --gias $(GIAS) --output $@
|
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --gias $(GIAS) --ofsted $(OFSTED) --output $@
|
||||||
|
|
||||||
$(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py
|
$(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py
|
||||||
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
||||||
|
|
@ -404,3 +408,13 @@ $(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPER
|
||||||
uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX)
|
uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX)
|
||||||
$(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX)
|
$(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX)
|
||||||
@touch $@
|
@touch $@
|
||||||
|
|
||||||
|
$(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(PRICES_STAMP) $(POSTCODES_PQ) $(ARCGIS) $(EPC) \
|
||||||
|
pipeline/transform/enrich_actual_listings.py pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py
|
||||||
|
uv run python -m pipeline.transform.enrich_actual_listings \
|
||||||
|
--listings $(ACTUAL_LISTINGS_RAW) \
|
||||||
|
--properties $(PROPERTIES_PQ) \
|
||||||
|
--postcode-features $(POSTCODES_PQ) \
|
||||||
|
--arcgis $(ARCGIS) \
|
||||||
|
--epc $(EPC) \
|
||||||
|
--output $@
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ services:
|
||||||
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
|
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
|
||||||
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
|
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
|
||||||
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
|
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
|
||||||
ACTUAL_LISTINGS_PATH: /app/finder-data/online_listings_buy.parquet
|
ACTUAL_LISTINGS_PATH: /app/finder-data/online_listings_buy_enriched.parquet
|
||||||
CRIME_BY_YEAR_PATH: /app/data/crime_by_year_by_lsoa.parquet
|
CRIME_BY_YEAR_PATH: /app/data/crime_by_year_by_lsoa.parquet
|
||||||
depends_on:
|
depends_on:
|
||||||
screenshot:
|
screenshot:
|
||||||
|
|
|
||||||
BIN
frontend/public/assets/twemoji/1f392.png
Normal file
|
After Width: | Height: | Size: 1,020 B |
BIN
frontend/public/assets/twemoji/1f393.png
Normal file
|
After Width: | Height: | Size: 935 B |
BIN
frontend/public/assets/twemoji/1f9f8.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 185 KiB |
|
|
@ -41,7 +41,6 @@ import { EmptyState } from '../ui/EmptyState';
|
||||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||||
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
|
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
|
||||||
import StreetViewEmbed from './StreetViewEmbed';
|
import StreetViewEmbed from './StreetViewEmbed';
|
||||||
import HistogramLegend from './HistogramLegend';
|
|
||||||
import JourneyInstructions from './JourneyInstructions';
|
import JourneyInstructions from './JourneyInstructions';
|
||||||
|
|
||||||
interface AreaPaneProps {
|
interface AreaPaneProps {
|
||||||
|
|
@ -462,7 +461,6 @@ export default function AreaPane({
|
||||||
) : stats ? (
|
) : stats ? (
|
||||||
<div>
|
<div>
|
||||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||||
{stats.count > 0 && <HistogramLegend />}
|
|
||||||
{stats.price_history &&
|
{stats.price_history &&
|
||||||
(() => {
|
(() => {
|
||||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
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;
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
const crimeSeries = chart.feature
|
||||||
|
? crimeByYearByFeatureName.get(chart.feature)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ts(chart.label)}
|
key={ts(chart.label)}
|
||||||
|
|
@ -585,6 +587,11 @@ export default function AreaPane({
|
||||||
: STACKED_SEGMENT_COLORS
|
: STACKED_SEGMENT_COLORS
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{crimeSeries && crimeSeries.points.length > 1 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<CrimeYearChart points={crimeSeries.points} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -96,7 +96,7 @@ export default memo(function HoverCard({
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
style={cardStyle}
|
||||||
>
|
>
|
||||||
<div className="animate-pulse space-y-2">
|
<div className="animate-pulse space-y-2">
|
||||||
|
|
@ -109,7 +109,7 @@ export default memo(function HoverCard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
style={cardStyle}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
|
||||||
215
frontend/src/components/map/LocationSearch.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
|
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 { POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||||
|
|
@ -16,6 +16,7 @@ export interface SearchedLocation {
|
||||||
geometry: PostcodeGeometry;
|
geometry: PostcodeGeometry;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
|
zoom: number;
|
||||||
markerLatitude?: number;
|
markerLatitude?: number;
|
||||||
markerLongitude?: number;
|
markerLongitude?: number;
|
||||||
openProperties?: boolean;
|
openProperties?: boolean;
|
||||||
|
|
@ -73,6 +74,34 @@ export default function LocationSearch({
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(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
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -93,106 +122,136 @@ export default function LocationSearch({
|
||||||
}
|
}
|
||||||
}, [isMobile, expanded]);
|
}, [isMobile, expanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => cancelLookup(false);
|
||||||
|
}, [cancelLookup]);
|
||||||
|
|
||||||
const selectResult = useCallback(
|
const selectResult = useCallback(
|
||||||
async (result: SearchResult) => {
|
async (result: SearchResult) => {
|
||||||
|
const { controller, requestId } = beginLookup();
|
||||||
|
|
||||||
if (result.type === 'place') {
|
if (result.type === 'place') {
|
||||||
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
|
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
|
||||||
setError(null);
|
// On mobile the drawer opens after onLocationSearched; MapPage handles
|
||||||
setLoading(true);
|
// the fly-to there with the correct viewport inset so the target isn't
|
||||||
search.close();
|
// hidden behind the drawer. On desktop fly immediately for snappy feedback.
|
||||||
onFlyTo(result.lat, result.lon, zoom);
|
if (!isMobile) onFlyTo(result.lat, result.lon, zoom);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
lat: String(result.lat),
|
lat: String(result.lat),
|
||||||
lng: String(result.lon),
|
lng: String(result.lon),
|
||||||
log: 'false',
|
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) {
|
if (!res.ok) {
|
||||||
setError(t('locationSearch.lookupFailed'));
|
setError(t('locationSearch.lookupFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json: PostcodeLookupResponse = await res.json();
|
const json: PostcodeLookupResponse = await res.json();
|
||||||
|
if (!isCurrentLookup(requestId, controller)) return;
|
||||||
onLocationSearched?.({
|
onLocationSearched?.({
|
||||||
postcode: json.postcode,
|
postcode: json.postcode,
|
||||||
geometry: json.geometry,
|
geometry: json.geometry,
|
||||||
latitude: json.latitude,
|
latitude: json.latitude,
|
||||||
longitude: json.longitude,
|
longitude: json.longitude,
|
||||||
|
zoom,
|
||||||
markerLatitude: result.lat,
|
markerLatitude: result.lat,
|
||||||
markerLongitude: result.lon,
|
markerLongitude: result.lon,
|
||||||
});
|
});
|
||||||
|
search.saveRecentSearch(result);
|
||||||
search.clear();
|
search.clear();
|
||||||
if (isMobile) setExpanded(false);
|
if (isMobile) setExpanded(false);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
|
||||||
setError(t('locationSearch.lookupFailed'));
|
setError(t('locationSearch.lookupFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isCurrentLookup(requestId, controller)) {
|
||||||
|
lookupAbortRef.current = null;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.type === 'address') {
|
if (result.type === 'address') {
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
search.close();
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/postcode/${encodeURIComponent(result.postcode)}`,
|
`/api/postcode/${encodeURIComponent(result.postcode)}`,
|
||||||
authHeaders()
|
authHeaders({ signal: controller.signal })
|
||||||
);
|
);
|
||||||
|
if (!isCurrentLookup(requestId, controller)) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(t('locationSearch.postcodeNotFound'));
|
setError(t('locationSearch.postcodeNotFound'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json: PostcodeLookupResponse = await res.json();
|
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?.({
|
onLocationSearched?.({
|
||||||
postcode: json.postcode,
|
postcode: json.postcode,
|
||||||
geometry: json.geometry,
|
geometry: json.geometry,
|
||||||
latitude: result.lat,
|
latitude: result.lat,
|
||||||
longitude: result.lon,
|
longitude: result.lon,
|
||||||
|
zoom: 17,
|
||||||
markerLatitude: result.lat,
|
markerLatitude: result.lat,
|
||||||
markerLongitude: result.lon,
|
markerLongitude: result.lon,
|
||||||
openProperties: true,
|
openProperties: true,
|
||||||
focusAddress: result.address,
|
focusAddress: result.address,
|
||||||
});
|
});
|
||||||
|
search.saveRecentSearch(result);
|
||||||
search.clear();
|
search.clear();
|
||||||
if (isMobile) setExpanded(false);
|
if (isMobile) setExpanded(false);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
|
||||||
setError(t('locationSearch.lookupFailed'));
|
setError(t('locationSearch.lookupFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isCurrentLookup(requestId, controller)) {
|
||||||
|
lookupAbortRef.current = null;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Postcode — fetch geometry
|
// Postcode — fetch geometry
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
search.close();
|
|
||||||
try {
|
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) {
|
if (!res.ok) {
|
||||||
setError(t('locationSearch.postcodeNotFound'));
|
setError(t('locationSearch.postcodeNotFound'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json: PostcodeLookupResponse = await res.json();
|
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?.({
|
onLocationSearched?.({
|
||||||
postcode: json.postcode,
|
postcode: json.postcode,
|
||||||
geometry: json.geometry,
|
geometry: json.geometry,
|
||||||
latitude: json.latitude,
|
latitude: json.latitude,
|
||||||
longitude: json.longitude,
|
longitude: json.longitude,
|
||||||
|
zoom: POSTCODE_SEARCH_ZOOM,
|
||||||
});
|
});
|
||||||
|
search.saveRecentSearch(result);
|
||||||
search.clear();
|
search.clear();
|
||||||
if (isMobile) setExpanded(false);
|
if (isMobile) setExpanded(false);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
|
||||||
setError(t('locationSearch.lookupFailed'));
|
setError(t('locationSearch.lookupFailed'));
|
||||||
} finally {
|
} 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);
|
const [locating, setLocating] = useState(false);
|
||||||
|
|
@ -203,6 +262,7 @@ export default function LocationSearch({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
cancelLookup();
|
||||||
setLocating(true);
|
setLocating(true);
|
||||||
search.close();
|
search.close();
|
||||||
try {
|
try {
|
||||||
|
|
@ -234,7 +294,7 @@ export default function LocationSearch({
|
||||||
} finally {
|
} finally {
|
||||||
setLocating(false);
|
setLocating(false);
|
||||||
}
|
}
|
||||||
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
|
}, [cancelLookup, onFlyTo, onCurrentLocationFound, isMobile, search, t]);
|
||||||
|
|
||||||
// Mobile collapsed state: search icon + locate button
|
// Mobile collapsed state: search icon + locate button
|
||||||
if (isMobile && !expanded) {
|
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'
|
'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}
|
inputRef={inputRef}
|
||||||
onInputChange={() => setError(null)}
|
onInputChange={() => {
|
||||||
|
setError(null);
|
||||||
|
cancelLookup();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
MAP_BOUNDS,
|
MAP_BOUNDS,
|
||||||
POI_GROUP_COLORS,
|
POI_GROUP_COLORS,
|
||||||
POSTCODE_ZOOM_THRESHOLD,
|
POSTCODE_ZOOM_THRESHOLD,
|
||||||
|
POI_AUTO_CARD_ZOOM_THRESHOLD,
|
||||||
} from '../../lib/consts';
|
} from '../../lib/consts';
|
||||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||||
import MapLegend from './MapLegend';
|
import MapLegend from './MapLegend';
|
||||||
|
|
@ -104,6 +105,15 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n
|
||||||
return parts.length > 0 ? parts.join(' · ') : null;
|
return parts.length > 0 ? parts.join(' · ') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PoiPopupCardData {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
icon_category?: string;
|
||||||
|
group: string;
|
||||||
|
emoji: string;
|
||||||
|
school?: SchoolMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
interface Dimensions {
|
interface Dimensions {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|
@ -289,10 +299,16 @@ function renderSchoolMetadata(school: SchoolMetadata) {
|
||||||
)}
|
)}
|
||||||
{school.fsm_percent !== undefined && (
|
{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>
|
<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' && (
|
{school.sixth_form === 'Has a sixth form' && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
|
<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 {
|
function getRenderedViewState(map: MapRef | null): ViewState | null {
|
||||||
if (!map) return null;
|
if (!map) return null;
|
||||||
|
|
||||||
|
|
@ -575,6 +621,7 @@ export default memo(function Map({
|
||||||
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
||||||
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
|
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
|
||||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
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)
|
// In screenshot mode, use the prop directly for instant updates (no async lag)
|
||||||
const viewState = screenshotMode ? initialViewState : internalViewState;
|
const viewState = screenshotMode ? initialViewState : internalViewState;
|
||||||
|
|
@ -664,6 +711,10 @@ export default memo(function Map({
|
||||||
if (screenshotMode) window.__map_idle = true;
|
if (screenshotMode) window.__map_idle = true;
|
||||||
}, [screenshotMode]);
|
}, [screenshotMode]);
|
||||||
|
|
||||||
|
const handleLoad = useCallback(() => {
|
||||||
|
setMapReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFlyTo = useCallback(
|
const handleFlyTo = useCallback(
|
||||||
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
|
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
|
||||||
setInternalViewState((prev) => {
|
setInternalViewState((prev) => {
|
||||||
|
|
@ -715,6 +766,7 @@ export default memo(function Map({
|
||||||
layers,
|
layers,
|
||||||
popupInfo,
|
popupInfo,
|
||||||
clearPopupInfo,
|
clearPopupInfo,
|
||||||
|
visiblePois,
|
||||||
listingPopup,
|
listingPopup,
|
||||||
clearListingPopup,
|
clearListingPopup,
|
||||||
hoverPosition,
|
hoverPosition,
|
||||||
|
|
@ -744,6 +796,31 @@ export default memo(function Map({
|
||||||
travelTimeEntries,
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
|
className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
|
||||||
|
|
@ -755,7 +832,7 @@ export default memo(function Map({
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
{...viewState}
|
{...viewState}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
onLoad={undefined}
|
onLoad={handleLoad}
|
||||||
onIdle={handleIdle}
|
onIdle={handleIdle}
|
||||||
mapStyle={mapStyle}
|
mapStyle={mapStyle}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
|
@ -896,14 +973,28 @@ export default memo(function Map({
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
<div
|
||||||
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
||||||
style={{
|
style={{
|
||||||
left: popupInfo.x,
|
left: popupInfo.x,
|
||||||
top: popupInfo.y - 50,
|
top: popupInfo.y - 50,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 9999,
|
zIndex: 30,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
@ -922,36 +1013,7 @@ export default memo(function Map({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-2 max-w-[280px]">
|
<PoiPopupCardContent poi={popupInfo} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -962,7 +1024,7 @@ export default memo(function Map({
|
||||||
left: listingPopup.x,
|
left: listingPopup.x,
|
||||||
top: listingPopup.y - 12,
|
top: listingPopup.y - 12,
|
||||||
transform: 'translate(-50%, -100%)',
|
transform: 'translate(-50%, -100%)',
|
||||||
zIndex: 9999,
|
zIndex: 30,
|
||||||
}}
|
}}
|
||||||
onMouseLeave={clearListingPopup}
|
onMouseLeave={clearListingPopup}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
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 type { SearchedLocation } from './LocationSearch';
|
||||||
import { useMapData } from '../../hooks/useMapData';
|
import { useMapData } from '../../hooks/useMapData';
|
||||||
import { usePOIData } from '../../hooks/usePOIData';
|
import { usePOIData } from '../../hooks/usePOIData';
|
||||||
|
|
@ -25,11 +25,7 @@ import {
|
||||||
import { apiUrl, authHeaders, buildFilterString } 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 {
|
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||||
INITIAL_VIEW_STATE,
|
|
||||||
POSTCODE_SEARCH_ZOOM,
|
|
||||||
POSTCODE_ZOOM_THRESHOLD,
|
|
||||||
} from '../../lib/consts';
|
|
||||||
import type { OverlayId } from '../../lib/overlays';
|
import type { OverlayId } from '../../lib/overlays';
|
||||||
import { useLicense } from '../../hooks/useLicense';
|
import { useLicense } from '../../hooks/useLicense';
|
||||||
import { stateToParams } from '../../lib/url-state';
|
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';
|
export type { ExportState } from './map-page/types';
|
||||||
|
|
||||||
type PendingFlyTo = { lat: number; lng: number; zoom: number };
|
type PendingFlyTo = { lat: number; lng: number; zoom: number };
|
||||||
|
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||||
|
|
||||||
|
declare const __DEV__: boolean;
|
||||||
|
|
||||||
export default function MapPage({
|
export default function MapPage({
|
||||||
features,
|
features,
|
||||||
|
|
@ -115,6 +114,7 @@ export default function MapPage({
|
||||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||||
const [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
|
const [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
|
||||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
const [devActualListingsEnabled, setDevActualListingsEnabled] = useState(true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
filters,
|
filters,
|
||||||
|
|
@ -378,7 +378,7 @@ export default function MapPage({
|
||||||
pendingLocationSearchFlyToRef.current = {
|
pendingLocationSearchFlyToRef.current = {
|
||||||
lat: markerLat ?? result.latitude,
|
lat: markerLat ?? result.latitude,
|
||||||
lng: markerLng ?? result.longitude,
|
lng: markerLng ?? result.longitude,
|
||||||
zoom: result.openProperties ? 17 : POSTCODE_SEARCH_ZOOM,
|
zoom: result.zoom,
|
||||||
};
|
};
|
||||||
setMobileDrawerOpen(true);
|
setMobileDrawerOpen(true);
|
||||||
consumePendingLocationSearchFlyTo();
|
consumePendingLocationSearchFlyTo();
|
||||||
|
|
@ -450,11 +450,20 @@ export default function MapPage({
|
||||||
[filters, features]
|
[filters, features]
|
||||||
);
|
);
|
||||||
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
||||||
const { listings: actualListings } = useActualListings(mapData.bounds, {
|
const actualListingsEnabled = !__DEV__ || devActualListingsEnabled;
|
||||||
filterParam: actualListingsFilterParam,
|
const { listings: actualListings } = useActualListings(
|
||||||
travelParam: actualListingsTravelParam,
|
actualListingsEnabled ? mapData.bounds : null,
|
||||||
shareCode,
|
{
|
||||||
});
|
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);
|
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
||||||
|
|
||||||
useUrlSync(
|
useUrlSync(
|
||||||
|
|
@ -798,7 +807,9 @@ export default function MapPage({
|
||||||
onLocationSearched={handleLocationSearchResult}
|
onLocationSearched={handleLocationSearchResult}
|
||||||
onCurrentLocationFound={handleCurrentLocationFound}
|
onCurrentLocationFound={handleCurrentLocationFound}
|
||||||
currentLocation={currentLocation}
|
currentLocation={currentLocation}
|
||||||
actualListings={actualListings}
|
actualListings={visibleActualListings}
|
||||||
|
actualListingsEnabled={actualListingsEnabled}
|
||||||
|
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
|
||||||
travelTimeEntries={entries}
|
travelTimeEntries={entries}
|
||||||
bottomScreenInset={mobileBottomSheetHeight}
|
bottomScreenInset={mobileBottomSheetHeight}
|
||||||
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
|
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
|
||||||
|
|
@ -866,7 +877,9 @@ export default function MapPage({
|
||||||
onLocationSearched={handleLocationSearchResult}
|
onLocationSearched={handleLocationSearchResult}
|
||||||
onCurrentLocationFound={handleCurrentLocationFound}
|
onCurrentLocationFound={handleCurrentLocationFound}
|
||||||
currentLocation={currentLocation}
|
currentLocation={currentLocation}
|
||||||
actualListings={actualListings}
|
actualListings={visibleActualListings}
|
||||||
|
actualListingsEnabled={actualListingsEnabled}
|
||||||
|
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
|
||||||
travelTimeEntries={entries}
|
travelTimeEntries={entries}
|
||||||
densityLabel={densityLabel}
|
densityLabel={densityLabel}
|
||||||
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export function MapPageSelectionPane({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tutorial="right-pane"
|
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 }}
|
style={{ width }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export default function MobileDrawer({
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
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% */}
|
{/* Backdrop — top 10% */}
|
||||||
<div className="h-[10%] bg-black/50" onClick={onClose} />
|
<div className="h-[10%] bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
)}
|
)}
|
||||||
{property.listed_building === 'Yes' && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type { OverlayId } from '../../../lib/overlays';
|
||||||
import type { SearchedLocation } from '../LocationSearch';
|
import type { SearchedLocation } from '../LocationSearch';
|
||||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||||
|
import { HouseIcon } from '../../ui/icons/HouseIcon';
|
||||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||||
|
|
@ -56,6 +57,8 @@ interface DesktopMapPageProps {
|
||||||
onCurrentLocationFound: (lat: number, lng: number) => void;
|
onCurrentLocationFound: (lat: number, lng: number) => void;
|
||||||
currentLocation: { lat: number; lng: number } | null;
|
currentLocation: { lat: number; lng: number } | null;
|
||||||
actualListings: ActualListing[];
|
actualListings: ActualListing[];
|
||||||
|
actualListingsEnabled: boolean;
|
||||||
|
onToggleActualListings?: () => void;
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
densityLabel: string;
|
densityLabel: string;
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
|
|
@ -106,6 +109,8 @@ export function DesktopMapPage({
|
||||||
onCurrentLocationFound,
|
onCurrentLocationFound,
|
||||||
currentLocation,
|
currentLocation,
|
||||||
actualListings,
|
actualListings,
|
||||||
|
actualListingsEnabled,
|
||||||
|
onToggleActualListings,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
densityLabel,
|
densityLabel,
|
||||||
totalCount,
|
totalCount,
|
||||||
|
|
@ -154,7 +159,7 @@ export function DesktopMapPage({
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-tutorial="filters"
|
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 }}
|
style={{ width: leftPaneWidth }}
|
||||||
>
|
>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
||||||
|
|
@ -208,7 +213,20 @@ export function DesktopMapPage({
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</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
|
<button
|
||||||
onClick={onToggleOverlayPane}
|
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'}`}
|
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'}`}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type { SearchedLocation } from '../LocationSearch';
|
||||||
import MobileBottomSheet from '../MobileBottomSheet';
|
import MobileBottomSheet from '../MobileBottomSheet';
|
||||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||||
|
import { HouseIcon } from '../../ui/icons/HouseIcon';
|
||||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||||
import type { MapFlyTo } from './types';
|
import type { MapFlyTo } from './types';
|
||||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||||
|
|
@ -47,6 +48,8 @@ interface MobileMapPageProps {
|
||||||
onCurrentLocationFound: (lat: number, lng: number) => void;
|
onCurrentLocationFound: (lat: number, lng: number) => void;
|
||||||
currentLocation: { lat: number; lng: number } | null;
|
currentLocation: { lat: number; lng: number } | null;
|
||||||
actualListings: ActualListing[];
|
actualListings: ActualListing[];
|
||||||
|
actualListingsEnabled: boolean;
|
||||||
|
onToggleActualListings?: () => void;
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
bottomScreenInset: number;
|
bottomScreenInset: number;
|
||||||
onBottomSheetCoveredHeightChange: (height: number) => void;
|
onBottomSheetCoveredHeightChange: (height: number) => void;
|
||||||
|
|
@ -94,6 +97,8 @@ export function MobileMapPage({
|
||||||
onCurrentLocationFound,
|
onCurrentLocationFound,
|
||||||
currentLocation,
|
currentLocation,
|
||||||
actualListings,
|
actualListings,
|
||||||
|
actualListingsEnabled,
|
||||||
|
onToggleActualListings,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
bottomScreenInset,
|
bottomScreenInset,
|
||||||
onBottomSheetCoveredHeightChange,
|
onBottomSheetCoveredHeightChange,
|
||||||
|
|
@ -161,7 +166,19 @@ export function MobileMapPage({
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={onToggleOverlayPane}
|
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'}`}
|
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'}`}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import type { Page } from '../../ui/Header';
|
||||||
import type { PointerEvent } from 'react';
|
import type { PointerEvent } from 'react';
|
||||||
|
|
||||||
export interface ExportState {
|
export interface ExportState {
|
||||||
onExport: () => void;
|
onExport: (options?: { postcodes?: string[] }) => void;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,73 +135,86 @@ export function useExportController({
|
||||||
|
|
||||||
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
|
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
const handleExport = useCallback(
|
||||||
if (exporting) return;
|
(options?: { postcodes?: string[] }) => {
|
||||||
if (!bounds) {
|
if (exporting) return;
|
||||||
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { south, west, north, east } = bounds;
|
const postcodeList = options?.postcodes?.map((p) => p.trim()).filter(Boolean) ?? [];
|
||||||
const params = new URLSearchParams({
|
const isListMode = postcodeList.length > 0;
|
||||||
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();
|
if (!isListMode && !bounds) {
|
||||||
let timedOut = false;
|
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
|
||||||
const timeoutId = window.setTimeout(() => {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
}, [
|
const params = new URLSearchParams();
|
||||||
bounds,
|
if (isListMode) {
|
||||||
clearExportNotice,
|
params.set('postcodes', postcodeList.join(','));
|
||||||
exporting,
|
if (shareCode) params.set('share', shareCode);
|
||||||
features,
|
} else {
|
||||||
filters,
|
const { south, west, north, east } = bounds!;
|
||||||
selectedOverlays,
|
params.set('bounds', `${south},${west},${north},${east}`);
|
||||||
shareCode,
|
const filterStr = buildFilterString(filters, features);
|
||||||
showExportNotice,
|
if (filterStr) params.set('filters', filterStr);
|
||||||
t,
|
const travelParam = buildTravelParam(travelTimeEntries);
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||||
|
|
|
||||||
221
frontend/src/components/ui/ExportMenu.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||||
import UserMenu from './UserMenu';
|
import UserMenu from './UserMenu';
|
||||||
import MobileMenu from './MobileMenu';
|
import MobileMenu from './MobileMenu';
|
||||||
import LanguageDropdown from './LanguageDropdown';
|
import LanguageDropdown from './LanguageDropdown';
|
||||||
|
import ExportMenu from './ExportMenu';
|
||||||
|
|
||||||
export type Page =
|
export type Page =
|
||||||
| 'home'
|
| 'home'
|
||||||
|
|
@ -37,7 +38,7 @@ export type Page =
|
||||||
| 'invite';
|
| 'invite';
|
||||||
|
|
||||||
export interface HeaderExportState {
|
export interface HeaderExportState {
|
||||||
onExport: () => void;
|
onExport: (options?: { postcodes?: string[] }) => void;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +111,7 @@ export default function Header({
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||||
const [isDashboardTabletSidebarWidth, setIsDashboardTabletSidebarWidth] = useState(
|
const [isDashboardTabletSidebarWidth, setIsDashboardTabletSidebarWidth] = useState(
|
||||||
() => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
|
() => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
|
||||||
);
|
);
|
||||||
|
|
@ -292,7 +294,7 @@ export default function Header({
|
||||||
</button>
|
</button>
|
||||||
{exportState && (
|
{exportState && (
|
||||||
<button
|
<button
|
||||||
onClick={exportState.onExport}
|
onClick={() => setExportMenuOpen(true)}
|
||||||
disabled={exportState.exporting}
|
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"
|
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')}
|
title={t('header.exportToExcel')}
|
||||||
|
|
@ -407,6 +409,7 @@ export default function Header({
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
exportState={exportState}
|
exportState={exportState}
|
||||||
|
onOpenExportMenu={() => setExportMenuOpen(true)}
|
||||||
onSaveSearch={onSaveSearch}
|
onSaveSearch={onSaveSearch}
|
||||||
savingSearch={savingSearch}
|
savingSearch={savingSearch}
|
||||||
isEditingSearch={!!editingSearch}
|
isEditingSearch={!!editingSearch}
|
||||||
|
|
@ -420,6 +423,18 @@ export default function Header({
|
||||||
sharing={sharing}
|
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 */}
|
{/* Mobile "Copied" toast */}
|
||||||
{useSidebarNav && copied && (
|
{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">
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ interface MobileMenuProps {
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
exportState: HeaderExportState | null;
|
exportState: HeaderExportState | null;
|
||||||
|
onOpenExportMenu: () => void;
|
||||||
onSaveSearch: (() => void) | null;
|
onSaveSearch: (() => void) | null;
|
||||||
savingSearch: boolean;
|
savingSearch: boolean;
|
||||||
isEditingSearch: boolean;
|
isEditingSearch: boolean;
|
||||||
|
|
@ -39,6 +40,7 @@ export default function MobileMenu({
|
||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
exportState,
|
exportState,
|
||||||
|
onOpenExportMenu,
|
||||||
onSaveSearch,
|
onSaveSearch,
|
||||||
savingSearch,
|
savingSearch,
|
||||||
isEditingSearch,
|
isEditingSearch,
|
||||||
|
|
@ -122,8 +124,8 @@ export default function MobileMenu({
|
||||||
{exportState && (
|
{exportState && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
exportState.onExport();
|
|
||||||
onClose();
|
onClose();
|
||||||
|
onOpenExportMenu();
|
||||||
}}
|
}}
|
||||||
disabled={exportState.exporting}
|
disabled={exportState.exporting}
|
||||||
className={dashboardActionClass}
|
className={dashboardActionClass}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ interface SearchHook {
|
||||||
activeIndex: number;
|
activeIndex: number;
|
||||||
setActiveIndex: (idx: number) => void;
|
setActiveIndex: (idx: number) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
handleInputChange: (value: string) => void;
|
handleInputChange: (value: string) => void;
|
||||||
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
|
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
|
||||||
|
showEmptySearches: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlaceSearchInputProps {
|
interface PlaceSearchInputProps {
|
||||||
|
|
@ -129,7 +129,7 @@ export function PlaceSearchInput({
|
||||||
onInputChange?.();
|
onInputChange?.();
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (search.results.length > 0) search.setOpen(true);
|
search.showEmptySearches();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
|
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,11 @@ export function useDeckLayers({
|
||||||
|
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
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({
|
const { listingLayers, listingPopup, clearListingPopup } = useListingLayers({
|
||||||
listings: actualListings,
|
listings: actualListings,
|
||||||
zoom,
|
zoom,
|
||||||
|
|
@ -421,8 +425,50 @@ export function useDeckLayers({
|
||||||
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
|
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
|
||||||
|
|
||||||
const postcodeLayer = useMemo(() => {
|
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>({
|
return new GeoJsonLayer<PostcodeProperties>({
|
||||||
id: 'postcode-polygons',
|
...pieProps,
|
||||||
|
id: isEnum ? 'postcode-polygons-pie' : 'postcode-polygons',
|
||||||
data: postcodeData as PostcodeFeature[],
|
data: postcodeData as PostcodeFeature[],
|
||||||
getFillColor: (f) => {
|
getFillColor: (f) => {
|
||||||
const d = f.properties;
|
const d = f.properties;
|
||||||
|
|
@ -525,6 +571,7 @@ export function useDeckLayers({
|
||||||
getFillColor: [postcodeColorTrigger],
|
getFillColor: [postcodeColorTrigger],
|
||||||
getLineColor: [postcodeColorTrigger],
|
getLineColor: [postcodeColorTrigger],
|
||||||
getLineWidth: [postcodeColorTrigger],
|
getLineWidth: [postcodeColorTrigger],
|
||||||
|
...pieUpdateTriggers,
|
||||||
},
|
},
|
||||||
extruded: false,
|
extruded: false,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
|
|
@ -651,6 +698,7 @@ export function useDeckLayers({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layers,
|
layers,
|
||||||
|
visiblePois,
|
||||||
popupInfo,
|
popupInfo,
|
||||||
clearPopupInfo,
|
clearPopupInfo,
|
||||||
listingPopup,
|
listingPopup,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import type { AddressResult, PlaceResult } from '../types';
|
import type { AddressResult, PlaceResult } from '../types';
|
||||||
import { authHeaders, logNonAbortError } from '../lib/api';
|
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").
|
/** 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. */
|
* 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;
|
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;
|
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) {
|
export function useLocationSearch(mode?: string) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches);
|
||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
@ -98,9 +176,9 @@ export function useLocationSearch(mode?: string) {
|
||||||
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setResults([]);
|
setResults(recentSearches);
|
||||||
lastResultsRef.current = [];
|
lastResultsRef.current = [];
|
||||||
setOpen(false);
|
setOpen(recentSearches.length > 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,9 +259,20 @@ export function useLocationSearch(mode?: string) {
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 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 close = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
|
|
@ -195,6 +284,18 @@ export function useLocationSearch(mode?: string) {
|
||||||
setActiveIndex(-1);
|
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(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => {
|
(e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
|
|
@ -234,6 +335,8 @@ export function useLocationSearch(mode?: string) {
|
||||||
setOpen,
|
setOpen,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
|
showEmptySearches,
|
||||||
|
saveRecentSearch,
|
||||||
close,
|
close,
|
||||||
clear,
|
clear,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -146,10 +146,12 @@ describe('usePoiLayers', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
|
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
|
||||||
|
expect(result.current.visiblePois).toEqual([]);
|
||||||
|
|
||||||
rerender({ zoom: 14 });
|
rerender({ zoom: 14 });
|
||||||
|
|
||||||
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([busStop]);
|
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', () => {
|
it('keeps POI hover popup state in sync with layer hover events', () => {
|
||||||
|
|
|
||||||
|
|
@ -271,5 +271,5 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
|
|
||||||
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
||||||
|
|
||||||
return { poiLayers, popupInfo, clearPopupInfo };
|
return { poiLayers, visiblePois, popupInfo, clearPopupInfo };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
'Street tree density percentile': 'Percentile estimé de couverture arborée pour la rue du bien',
|
||||||
'Within conservation area':
|
'Within conservation area':
|
||||||
'Indique si le point représentatif du code postal se trouve dans une zone de conservation',
|
'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':
|
'Good+ primary schools within 2km':
|
||||||
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
|
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -127,7 +129,9 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
'Geschätztes Perzentil der Baumkronenbedeckung auf der Straße der Immobilie',
|
'Geschätztes Perzentil der Baumkronenbedeckung auf der Straße der Immobilie',
|
||||||
'Within conservation area':
|
'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':
|
'Good+ primary schools within 2km':
|
||||||
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
|
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -224,6 +228,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Interior height (m)': 'EPC评估的平均层高',
|
'Interior height (m)': 'EPC评估的平均层高',
|
||||||
'Street tree density percentile': '该房产所在街道的估计树冠覆盖率百分位',
|
'Street tree density percentile': '该房产所在街道的估计树冠覆盖率百分位',
|
||||||
'Within conservation area': '邮编代表点是否位于指定保护区内',
|
'Within conservation area': '邮编代表点是否位于指定保护区内',
|
||||||
|
'Listed building': '该房产是否疑似匹配 Historic England 的受保护建筑条目',
|
||||||
'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
|
'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
|
||||||
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
||||||
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
||||||
|
|
@ -299,6 +304,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Interior height (m)': 'EPC सर्वेक्षण के अनुसार औसत अंदरूनी ऊंचाई',
|
'Interior height (m)': 'EPC सर्वेक्षण के अनुसार औसत अंदरूनी ऊंचाई',
|
||||||
'Street tree density percentile': 'संपत्ति वाली सड़क का अनुमानित वृक्ष आच्छादन प्रतिशतक',
|
'Street tree density percentile': 'संपत्ति वाली सड़क का अनुमानित वृक्ष आच्छादन प्रतिशतक',
|
||||||
'Within conservation area': 'पोस्टकोड प्रतिनिधि बिंदु नामित संरक्षण क्षेत्र में है या नहीं',
|
'Within conservation area': 'पोस्टकोड प्रतिनिधि बिंदु नामित संरक्षण क्षेत्र में है या नहीं',
|
||||||
|
'Listed building':
|
||||||
|
'यह संपत्ति Historic England के सूचीबद्ध भवन रिकॉर्ड से मिलती-जुलती है या नहीं',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
|
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -386,7 +393,9 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
'Az ingatlan utcájának becsült lombkorona-fedettségi percentilise',
|
'Az ingatlan utcájának becsült lombkorona-fedettségi percentilise',
|
||||||
'Within conservation area':
|
'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':
|
'Good+ primary schools within 2km':
|
||||||
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
|
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,11 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Interior height (m)':
|
'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.",
|
"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':
|
'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':
|
'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.",
|
"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':
|
'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.",
|
"É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':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -156,11 +158,11 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Property type':
|
'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.).',
|
'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':
|
'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':
|
'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.',
|
'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':
|
'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':
|
'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.',
|
'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':
|
'Est. price per sqm':
|
||||||
|
|
@ -184,9 +186,11 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Interior height (m)':
|
'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.',
|
'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':
|
'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':
|
'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':
|
'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.',
|
'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':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -335,6 +339,8 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'基于 Forest Research 2025 年 Trees Outside Woodland 地图估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木和树群树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
|
'基于 Forest Research 2025 年 Trees Outside Woodland 地图估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木和树群树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
|
||||||
'Within conservation area':
|
'Within conservation area':
|
||||||
'Historic England 保护区边界,与邮编代表点匹配。全国数据集是指示性而非最终权威;涉及边界的决策应向地方规划部门核实。',
|
'Historic England 保护区边界,与邮编代表点匹配。全国数据集是指示性而非最终权威;涉及边界的决策应向地方规划部门核实。',
|
||||||
|
'Listed building':
|
||||||
|
'Historic England 英格兰国家遗产名录(NHLE)中的受保护建筑点位记录,会根据名录条目名称和附近候选邮编,谨慎匹配到房产地址。请把它当作初筛信号,而不是法律认定:具体房产应在 NHLE 和地方规划部门核实。',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'2km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
|
'2km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -475,6 +481,8 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
|
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
|
||||||
'Within conservation area':
|
'Within conservation area':
|
||||||
'Historic England संरक्षण क्षेत्र सीमाएं पोस्टकोड प्रतिनिधि बिंदु से मिलाई जाती हैं. राष्ट्रीय डेटासेट संकेतक है, अंतिम आधिकारिक नहीं; सीमा-संवेदनशील निर्णय स्थानीय योजना प्राधिकरण से जांचे जाने चाहिए.',
|
'Historic England संरक्षण क्षेत्र सीमाएं पोस्टकोड प्रतिनिधि बिंदु से मिलाई जाती हैं. राष्ट्रीय डेटासेट संकेतक है, अंतिम आधिकारिक नहीं; सीमा-संवेदनशील निर्णय स्थानीय योजना प्राधिकरण से जांचे जाने चाहिए.',
|
||||||
|
'Listed building':
|
||||||
|
'Historic England की इंग्लैंड की राष्ट्रीय धरोहर सूची (NHLE) में सूचीबद्ध भवनों के बिंदु रिकॉर्ड, जिन्हें सूचीबद्ध प्रविष्टि के नाम और पास के संभावित पोस्टकोड के आधार पर संपत्ति पते से सावधानी से मिलाया गया है. इसे केवल प्रारंभिक जांच संकेत मानें, कानूनी निर्णय नहीं: किसी भी विशिष्ट संपत्ति को NHLE और स्थानीय योजना प्राधिकरण से सत्यापित करें.',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
|
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -590,9 +598,9 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
},
|
},
|
||||||
hu: {
|
hu: {
|
||||||
'Property type':
|
'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':
|
'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':
|
'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.',
|
'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':
|
'Estimated current price':
|
||||||
|
|
@ -620,9 +628,11 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Interior height (m)':
|
'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.',
|
'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':
|
'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':
|
'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':
|
'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.',
|
'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':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,21 @@ const en = {
|
||||||
closeMenu: 'Close menu',
|
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 ──────────────────────────────────────
|
// ── User Menu ──────────────────────────────────────
|
||||||
userMenu: {
|
userMenu: {
|
||||||
fullAccess: 'Full Access',
|
fullAccess: 'Full Access',
|
||||||
|
|
@ -796,7 +811,8 @@ const en = {
|
||||||
rooms: 'Rooms:',
|
rooms: 'Rooms:',
|
||||||
built: 'Built:',
|
built: 'Built:',
|
||||||
formerCouncil: 'Ex-council:',
|
formerCouncil: 'Ex-council:',
|
||||||
exCouncilBadge: 'Ex-council',
|
exCouncilBadge: 'Maybe ex-council house',
|
||||||
|
listedBuildingBadge: 'Maybe listed',
|
||||||
epcRating: 'EPC rating:',
|
epcRating: 'EPC rating:',
|
||||||
epcPotential: 'EPC potential:',
|
epcPotential: 'EPC potential:',
|
||||||
renovations: 'Renovations',
|
renovations: 'Renovations',
|
||||||
|
|
@ -849,16 +865,6 @@ const en = {
|
||||||
nationalAvg: 'National avg',
|
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 ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: 'Street View',
|
title: 'Street View',
|
||||||
|
|
@ -1604,6 +1610,14 @@ const en = {
|
||||||
Zoo: 'Zoo',
|
Zoo: 'Zoo',
|
||||||
'Tourist Attraction': 'Tourist Attraction',
|
'Tourist Attraction': 'Tourist Attraction',
|
||||||
School: 'School',
|
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',
|
Hotel: 'Hotel',
|
||||||
'Local Business': 'Local Business',
|
'Local Business': 'Local Business',
|
||||||
Offices: 'Offices',
|
Offices: 'Offices',
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ const zh: Translations = {
|
||||||
appName: 'Perfect Postcode',
|
appName: 'Perfect Postcode',
|
||||||
dashboard: '地图面板',
|
dashboard: '地图面板',
|
||||||
learn: '了解更多',
|
learn: '了解更多',
|
||||||
pricing: '价格',
|
pricing: '定价',
|
||||||
inviteFriends: '邀请好友',
|
inviteFriends: '邀请好友',
|
||||||
saved: '已保存',
|
saved: '已保存',
|
||||||
logIn: '登录',
|
logIn: '登录',
|
||||||
|
|
@ -62,7 +62,7 @@ const zh: Translations = {
|
||||||
exportLabel: '导出',
|
exportLabel: '导出',
|
||||||
exporting: '导出中...',
|
exporting: '导出中...',
|
||||||
exportToExcel: '导出为 Excel',
|
exportToExcel: '导出为 Excel',
|
||||||
exportReady: '导出已就绪。下载应会开始。',
|
exportReady: '导出已就绪。下载应会自动开始。',
|
||||||
exportFailed: '导出失败。',
|
exportFailed: '导出失败。',
|
||||||
exportTimedOut: '导出超时。请重试。',
|
exportTimedOut: '导出超时。请重试。',
|
||||||
exportUnavailable: '地图仍在加载。请稍后重试。',
|
exportUnavailable: '地图仍在加载。请稍后重试。',
|
||||||
|
|
@ -71,9 +71,24 @@ const zh: Translations = {
|
||||||
closeMenu: '关闭菜单',
|
closeMenu: '关闭菜单',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Export Menu ────────────────────────────────────
|
||||||
|
export: {
|
||||||
|
title: '导出',
|
||||||
|
modeFilters: '符合筛选条件的邮编',
|
||||||
|
modeFiltersHint: '导出地图上所有符合当前筛选条件的邮编。',
|
||||||
|
modeList: '邮编列表',
|
||||||
|
modeListHint: '逐个添加您自己的邮编,系统会自动规范空格和大小写。',
|
||||||
|
listLabel: '邮编',
|
||||||
|
listPlaceholder: '例如 SW1A 1AA',
|
||||||
|
addRow: '添加邮编',
|
||||||
|
removeRow: '删除邮编',
|
||||||
|
listCount: '{{count}} 个邮编',
|
||||||
|
listCount_other: '{{count}} 个邮编',
|
||||||
|
},
|
||||||
|
|
||||||
// ── User Menu ──────────────────────────────────────
|
// ── User Menu ──────────────────────────────────────
|
||||||
userMenu: {
|
userMenu: {
|
||||||
fullAccess: '完整访问',
|
fullAccess: '完整访问权限',
|
||||||
demo: '演示版',
|
demo: '演示版',
|
||||||
themeLight: '主题:浅色',
|
themeLight: '主题:浅色',
|
||||||
themeDark: '主题:深色',
|
themeDark: '主题:深色',
|
||||||
|
|
@ -118,8 +133,7 @@ const zh: Translations = {
|
||||||
'按邮编筛选历史成交价与当前估值。',
|
'按邮编筛选历史成交价与当前估值。',
|
||||||
'Compare value with commute, schools, broadband, crime, noise, and amenities.':
|
'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':
|
'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.':
|
'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': '看好一个区域之后,如何进一步验证',
|
'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.':
|
'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 吗?',
|
'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 what’s currently for sale.':
|
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show what’s currently for sale.':
|
||||||
'不能,它应当配合房源平台使用:Perfect Postcode 帮您决定该去哪儿找,房源平台告诉您当下哪些房子在售。',
|
'不能,它应当配合房源平台使用:Perfect Postcode 帮您决定该去哪儿找,房源平台告诉您当下哪些房子在售。',
|
||||||
|
|
@ -203,7 +217,7 @@ const zh: Translations = {
|
||||||
'可以。已授权用户可以保存搜索,随时回来接着用。保存的搜索专为整理候选名单和比对笔记而设计。',
|
'可以。已授权用户可以保存搜索,随时回来接着用。保存的搜索专为整理候选名单和比对笔记而设计。',
|
||||||
'Can I search without knowing the area?': '不熟悉当地,也能搜索吗?',
|
'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.':
|
'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?': '搜索结果是实时房源吗?',
|
'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.':
|
'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': '比较相邻邮编',
|
'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.':
|
'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.':
|
'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 让您出门看房前,先用数据把邮编查个清楚。',
|
'房源照片很难讲清周围街道的真实情况。Perfect Postcode 让您出门看房前,先用数据把邮编查个清楚。',
|
||||||
'A screening tool, not professional advice': '是初筛工具,不是专业建议',
|
'A screening tool, not professional advice': '是初筛工具,不是专业建议',
|
||||||
|
|
@ -636,15 +650,15 @@ const zh: Translations = {
|
||||||
primary: '小学',
|
primary: '小学',
|
||||||
secondary: '中学',
|
secondary: '中学',
|
||||||
rating: '评级',
|
rating: '评级',
|
||||||
goodPlus: '良好+',
|
goodPlus: '良好及以上',
|
||||||
outstanding: '优秀',
|
outstanding: '优秀',
|
||||||
distance: '距离',
|
distance: '距离',
|
||||||
crimeType: '犯罪类型',
|
crimeType: '犯罪类型',
|
||||||
ethnicity: '族裔',
|
ethnicity: '族裔',
|
||||||
poiType: 'POI 类型',
|
poiType: '兴趣点类型',
|
||||||
party: '政党',
|
party: '政党',
|
||||||
travelTimeKeywords:
|
travelTimeKeywords:
|
||||||
'通勤 通勤时间 出行 出行时间 旅行 旅行时间 路程 行程 驾车 开车 汽车 自行车 单车 骑行 骑车 步行 走路 公共交通 公交 交通 运输 车站 地铁 火车 公共汽车 巴士 路线 travel time journey commute car bicycle bike walking transit transport station tube train',
|
'通勤 通勤时间 出行 出行时间 旅行 旅行时间 路程 行程 驾车 开车 汽车 自行车 单车 骑行 骑车 步行 走路 公共交通 公交 交通 运输 车站 地铁 火车 公共汽车 巴士 路线',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Philosophy Popup ───────────────────────────────
|
// ── Philosophy Popup ───────────────────────────────
|
||||||
|
|
@ -658,7 +672,7 @@ const zh: Translations = {
|
||||||
step3Title: '安全',
|
step3Title: '安全',
|
||||||
step3Desc: '(犯罪率、噪音水平、地面稳定性)',
|
step3Desc: '(犯罪率、噪音水平、地面稳定性)',
|
||||||
step4Title: '学校',
|
step4Title: '学校',
|
||||||
step4Desc: '(附近 Ofsted 评级为"良好"或"优秀"的学校)',
|
step4Desc: '(附近 Ofsted 评级为“良好”或“优秀”的学校)',
|
||||||
step5Title: '生活方式',
|
step5Title: '生活方式',
|
||||||
step5Desc: '(餐厅、公园、宽带速度)',
|
step5Desc: '(餐厅、公园、宽带速度)',
|
||||||
step6Title: '能源',
|
step6Title: '能源',
|
||||||
|
|
@ -678,11 +692,11 @@ const zh: Translations = {
|
||||||
noChange: '无换乘',
|
noChange: '无换乘',
|
||||||
noChangeTitle: '仅直达行程',
|
noChangeTitle: '仅直达行程',
|
||||||
noChangeDesc:
|
noChangeDesc:
|
||||||
'仅限<strong>无换乘</strong>行程 —步行、乘坐一次公共交通、再步行到目的地。',
|
'仅限<strong>无换乘</strong>行程:步行、乘坐一次公共交通,再步行到目的地。适合希望一路直达的通勤。',
|
||||||
noBuses: '不含公交',
|
noBuses: '不含公交',
|
||||||
noBusesTitle: '排除公交',
|
noBusesTitle: '排除公交',
|
||||||
noBusesDesc:
|
noBusesDesc:
|
||||||
'排除公交服务 —仅 <strong>火车、地铁、有轨电车和渡轮</strong>。便于筛选避开交通拥堵的行程。',
|
'从允许的公共交通方式中排除公交车,只保留<strong>火车、地铁、有轨电车和渡轮</strong>。适合筛选更少受道路拥堵影响的行程。',
|
||||||
previewOnMap: '在地图上预览',
|
previewOnMap: '在地图上预览',
|
||||||
stopPreviewing: '停止预览',
|
stopPreviewing: '停止预览',
|
||||||
removeTravelTime: '移除通勤时间',
|
removeTravelTime: '移除通勤时间',
|
||||||
|
|
@ -729,7 +743,7 @@ const zh: Translations = {
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
mapLegend: {
|
mapLegend: {
|
||||||
clearColourView: '清除颜色视图',
|
clearColourView: '清除颜色视图',
|
||||||
resetColourScale: '重置颜色比例',
|
resetColourScale: '重置颜色刻度',
|
||||||
historicalMatches: '历史房产匹配',
|
historicalMatches: '历史房产匹配',
|
||||||
numberOfProperties: '房产数量',
|
numberOfProperties: '房产数量',
|
||||||
previewing: '预览\u201c{{name}}\u201d',
|
previewing: '预览\u201c{{name}}\u201d',
|
||||||
|
|
@ -757,7 +771,8 @@ const zh: Translations = {
|
||||||
rooms: '房间:',
|
rooms: '房间:',
|
||||||
built: '建造年份:',
|
built: '建造年份:',
|
||||||
formerCouncil: '原公房:',
|
formerCouncil: '原公房:',
|
||||||
exCouncilBadge: '原公房',
|
exCouncilBadge: '可能原公房',
|
||||||
|
listedBuildingBadge: '可能为登录建筑',
|
||||||
epcRating: '能源评级:',
|
epcRating: '能源评级:',
|
||||||
epcPotential: '潜在能源评级:',
|
epcPotential: '潜在能源评级:',
|
||||||
renovations: '翻新记录',
|
renovations: '翻新记录',
|
||||||
|
|
@ -793,7 +808,7 @@ const zh: Translations = {
|
||||||
lowerMinTo: '将最小值降至 {{value}}',
|
lowerMinTo: '将最小值降至 {{value}}',
|
||||||
raiseMaxTo: '将最大值提高至 {{value}}',
|
raiseMaxTo: '将最大值提高至 {{value}}',
|
||||||
allowCategory: '允许 {{value}}',
|
allowCategory: '允许 {{value}}',
|
||||||
missingFilterValue: '此筛选条件没有值;请移除它或允许缺失值',
|
missingFilterValue: '此筛选条件没有值;请移除它',
|
||||||
noFilterDataShort: '无数据',
|
noFilterDataShort: '无数据',
|
||||||
travelTo: '前往 {{destination}} 的出行',
|
travelTo: '前往 {{destination}} 的出行',
|
||||||
viewProperties: '查看 {{count}} 处房产',
|
viewProperties: '查看 {{count}} 处房产',
|
||||||
|
|
@ -808,16 +823,6 @@ const zh: Translations = {
|
||||||
nationalAvg: '全国平均',
|
nationalAvg: '全国平均',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Histogram Legend ───────────────────────────────
|
|
||||||
histogramLegend: {
|
|
||||||
tealBars: '青色柱状图',
|
|
||||||
tealBarsDesc: '显示所选区域内的分布情况',
|
|
||||||
greyBars: '灰色柱状图',
|
|
||||||
greyBarsDesc: '显示所有区域的整体分布情况',
|
|
||||||
dashedLine: '虚线',
|
|
||||||
dashedLineDesc: '表示全国平均值',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
streetView: {
|
streetView: {
|
||||||
title: '街景视图',
|
title: '街景视图',
|
||||||
|
|
@ -860,7 +865,7 @@ const zh: Translations = {
|
||||||
|
|
||||||
// ── Home Page ──────────────────────────────────────
|
// ── Home Page ──────────────────────────────────────
|
||||||
home: {
|
home: {
|
||||||
heroEyebrow: '献给那些正在问"我到底该去哪儿找"的买家',
|
heroEyebrow: '献给那些正在问“我到底该去哪儿找?”的买家',
|
||||||
heroTitle1: '找到真正',
|
heroTitle1: '找到真正',
|
||||||
heroTitle2: '适合您生活的邮编',
|
heroTitle2: '适合您生活的邮编',
|
||||||
heroTitle3: '不只局限于您已经熟悉的区域。',
|
heroTitle3: '不只局限于您已经熟悉的区域。',
|
||||||
|
|
@ -879,11 +884,11 @@ const zh: Translations = {
|
||||||
showcaseFeatureNoiseShort: '噪声',
|
showcaseFeatureNoiseShort: '噪声',
|
||||||
showcaseFeatureSchoolsShort: '学校',
|
showcaseFeatureSchoolsShort: '学校',
|
||||||
showcaseFeatureTravelShort: '出行',
|
showcaseFeatureTravelShort: '出行',
|
||||||
showcaseGoodPrimariesNearby: '附近 {{count}}+ 所良好小学',
|
showcaseGoodPrimariesNearby: '附近 {{count}}+ 所良好及以上小学',
|
||||||
showcaseWithinRail: '{{count}} 分钟内到达铁路',
|
showcaseWithinRail: '距铁路 {{count}} 分钟内',
|
||||||
showcaseMatchingHomesLabel: '匹配房源',
|
showcaseMatchingHomesLabel: '匹配房源',
|
||||||
showcaseMatchingHomes: '{{value}} 个匹配房源',
|
showcaseMatchingHomes: '{{value}} 个匹配房源',
|
||||||
showcaseMedianPrice: '{{value}} 中位数',
|
showcaseMedianPrice: '中位数 {{value}}',
|
||||||
showcaseJourneyRoutes: '出行路线',
|
showcaseJourneyRoutes: '出行路线',
|
||||||
showcaseNearby: '附近 {{value}} 个',
|
showcaseNearby: '附近 {{value}} 个',
|
||||||
showcasePoliticalVoteShare: '政党得票份额',
|
showcasePoliticalVoteShare: '政党得票份额',
|
||||||
|
|
@ -922,7 +927,7 @@ const zh: Translations = {
|
||||||
showcaseStep3Stat4Label: '宽带',
|
showcaseStep3Stat4Label: '宽带',
|
||||||
showcaseStep3Stat4Value: '可用 1 Gbps',
|
showcaseStep3Stat4Value: '可用 1 Gbps',
|
||||||
showcaseStep3Stat5Label: '小学',
|
showcaseStep3Stat5Label: '小学',
|
||||||
showcaseStep3Stat5Value: '1英里内3所「优秀」',
|
showcaseStep3Stat5Value: '1 英里内有 3 所“优秀”学校',
|
||||||
showcaseStep4Tab: '踏勘',
|
showcaseStep4Tab: '踏勘',
|
||||||
showcaseStep4Title: '亲自去看一看',
|
showcaseStep4Title: '亲自去看一看',
|
||||||
showcaseStep4Body:
|
showcaseStep4Body:
|
||||||
|
|
@ -947,7 +952,7 @@ const zh: Translations = {
|
||||||
streetIntro:
|
streetIntro:
|
||||||
'笼统的区域名容易掩盖关键差异:在车站哪一侧、道路噪音、学校组合、真实通勤时间,以及同类房产的实际成交价。',
|
'笼统的区域名容易掩盖关键差异:在车站哪一侧、道路噪音、学校组合、真实通勤时间,以及同类房产的实际成交价。',
|
||||||
streetCard1Title: '发现您可能错过的区域',
|
streetCard1Title: '发现您可能错过的区域',
|
||||||
streetCard1Body: '根据您的条件找出匹配的邮编,不再只凭熟悉的地名、朋友推荐或"潜力区域"的宣传。',
|
streetCard1Body: '根据您的条件找出匹配的邮编,不再只凭熟悉的地名、朋友推荐或“潜力区域”的宣传。',
|
||||||
streetCard2Title: '看房前先看清取舍',
|
streetCard2Title: '看房前先看清取舍',
|
||||||
streetCard2Body:
|
streetCard2Body:
|
||||||
'把周末花在看房之前,先把价格、空间、通勤、治安、学校、宽带、噪音和能源评级一并对比。',
|
'把周末花在看房之前,先把价格、空间、通勤、治安、学校、宽带、噪音和能源评级一并对比。',
|
||||||
|
|
@ -983,7 +988,7 @@ const zh: Translations = {
|
||||||
filled: '已满',
|
filled: '已满',
|
||||||
openDashboard: '打开地图面板',
|
openDashboard: '打开地图面板',
|
||||||
getStarted: '立即开始',
|
getStarted: '立即开始',
|
||||||
getStartedPrice: '立即开始 - {{price}}',
|
getStartedPrice: '立即开始:{{price}}',
|
||||||
noCreditCard: '无需信用卡',
|
noCreditCard: '无需信用卡',
|
||||||
|
|
||||||
soldOut: '已售罄',
|
soldOut: '已售罄',
|
||||||
|
|
@ -1011,7 +1016,7 @@ const zh: Translations = {
|
||||||
'浏览关于房产搜索、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
|
'浏览关于房产搜索、通勤、学校、邮编速查、区域对比、数据覆盖、方法论和隐私的公开指南。',
|
||||||
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
|
supportIntro: '还有疑问?欢迎查看常见问题,或直接与我们联系。',
|
||||||
source: '来源:',
|
source: '来源:',
|
||||||
optOut: '退出公开披露',
|
optOut: '选择不公开',
|
||||||
attribution: '数据引用声明',
|
attribution: '数据引用声明',
|
||||||
attrLandRegistry: '包含 HM Land Registry 数据 © Crown copyright and database right 2025。',
|
attrLandRegistry: '包含 HM Land Registry 数据 © Crown copyright and database right 2025。',
|
||||||
attrOgl: '包含根据以下许可证授权的公共部门信息:',
|
attrOgl: '包含根据以下许可证授权的公共部门信息:',
|
||||||
|
|
@ -1029,7 +1034,7 @@ const zh: Translations = {
|
||||||
dsEpcName: '能源性能证书(EPC)',
|
dsEpcName: '能源性能证书(EPC)',
|
||||||
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
|
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||||
dsEpcUse:
|
dsEpcUse:
|
||||||
'住宅能源性能证书,提供建筑面积、房间数量、建造年份、能源评级、房产类型和建筑形式等信息。通过每个邮编内的地址与成交价格数据进行匹配。业主可以退出公开披露。',
|
'住宅能源性能证书,提供建筑面积、房间数量、建造年份、能源评级、房产类型和建筑形式等信息。通过每个邮编内的地址与成交价格数据进行匹配。业主可以选择不公开。',
|
||||||
dsNsplName: '国家统计邮编查询(NSPL)',
|
dsNsplName: '国家统计邮编查询(NSPL)',
|
||||||
dsNsplOrigin: 'ONS / ArcGIS',
|
dsNsplOrigin: 'ONS / ArcGIS',
|
||||||
dsNsplUse: '将邮编映射到坐标和统计区域代码,用于将所有区域级数据集关联到各个房产。',
|
dsNsplUse: '将邮编映射到坐标和统计区域代码,用于将所有区域级数据集关联到各个房产。',
|
||||||
|
|
@ -1064,8 +1069,7 @@ const zh: Translations = {
|
||||||
dsConservationAreasUse: '英格兰指定保护区边界。用于标记邮编代表点是否位于保护区内。',
|
dsConservationAreasUse: '英格兰指定保护区边界。用于标记邮编代表点是否位于保护区内。',
|
||||||
dsListedBuildingsName: 'Historic England 登录建筑',
|
dsListedBuildingsName: 'Historic England 登录建筑',
|
||||||
dsListedBuildingsOrigin: 'Historic England 英格兰国家遗产名录',
|
dsListedBuildingsOrigin: 'Historic England 英格兰国家遗产名录',
|
||||||
dsListedBuildingsUse:
|
dsListedBuildingsUse: '英格兰登录建筑点位记录。用于标记地址似乎与附近登录建筑条目匹配的房产。',
|
||||||
'英格兰登录建筑点位记录。用于标记地址似乎与附近登录建筑条目匹配的房产。',
|
|
||||||
dsNaptanName: 'NaPTAN(公共交通站点)',
|
dsNaptanName: 'NaPTAN(公共交通站点)',
|
||||||
dsNaptanOrigin: 'Department for Transport',
|
dsNaptanOrigin: 'Department for Transport',
|
||||||
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
|
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
|
||||||
|
|
@ -1122,9 +1126,9 @@ const zh: Translations = {
|
||||||
faqCommute2Q: '这些出行时间数字有什么局限?',
|
faqCommute2Q: '这些出行时间数字有什么局限?',
|
||||||
faqCommute2A:
|
faqCommute2A:
|
||||||
'公共交通时间基于工作日早晨通勤,出发时间在 07:30 到 08:30 之间,默认显示该时段的典型行程。这些是用于规划的估算,不包含实时延误、交通状况或临时改月台。',
|
'公共交通时间基于工作日早晨通勤,出发时间在 07:30 到 08:30 之间,默认显示该时段的典型行程。这些是用于规划的估算,不包含实时延误、交通状况或临时改月台。',
|
||||||
faqCommute3Q: '什么时候用"最佳情况"按钮?',
|
faqCommute3Q: '什么时候用“最佳情况”按钮?',
|
||||||
faqCommute3A:
|
faqCommute3A:
|
||||||
'在公共交通模式下,若想查看出发时间踩得准、换乘也顺利时的通勤表现,就开"最佳情况"。日常比较时关掉即可。',
|
'在公共交通模式下,若想查看出发时间踩得准、换乘也顺利时的通勤表现,就开启“最佳情况”。日常比较时请保持关闭,因为默认设置更接近大多数日子的预期。',
|
||||||
// FAQ items — Budget and Value
|
// FAQ items — Budget and Value
|
||||||
faqBudget1Q: '你们如何估算当前房价?',
|
faqBudget1Q: '你们如何估算当前房价?',
|
||||||
faqBudget1A:
|
faqBudget1A:
|
||||||
|
|
@ -1172,7 +1176,7 @@ const zh: Translations = {
|
||||||
// FAQ items — Privacy and Data Protection
|
// FAQ items — Privacy and Data Protection
|
||||||
faqPrivacy1Q: '你们会存储关于我的个人数据吗?',
|
faqPrivacy1Q: '你们会存储关于我的个人数据吗?',
|
||||||
faqPrivacy1A:
|
faqPrivacy1A:
|
||||||
'房产与社区信息本身不含您的个人资料。若您创建账户,我们只存储运行服务所必需的信息:邮箱地址、访问状态、新闻邮件订阅选择、已保存的搜索、已保存的房产,以及由 Stripe 处理的付款。账户数据按英国隐私法律处理。',
|
'房产与社区信息本身不含您的个人资料。若您创建账户,我们只存储运行服务所必需的信息:邮箱地址、访问状态、新闻邮件订阅选择、已保存的搜索、分享链接,以及由 Stripe 处理的付款记录。账户数据按英国隐私法律处理。',
|
||||||
// FAQ items — Why Perfect Postcode
|
// FAQ items — Why Perfect Postcode
|
||||||
faqWhy1Q: '它展示了哪些房源门户通常看不到的信息?',
|
faqWhy1Q: '它展示了哪些房源门户通常看不到的信息?',
|
||||||
faqWhy1A:
|
faqWhy1A:
|
||||||
|
|
@ -1197,13 +1201,13 @@ const zh: Translations = {
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: '如何在地图上预览筛选条件?',
|
faqTips1Q: '如何在地图上预览筛选条件?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
'点击筛选条件或数据项旁的眼睛图标,就能按该项给地图着色。当前的筛选保持不变,因此可以快速对比价格、出行时间、学校、治安或噪音等单项,候选范围不会改变。',
|
'点击筛选条件或数据项旁的“地图着色”,就能按该项给地图着色。当前的筛选保持不变,因此可以快速对比价格、出行时间、学校、治安或噪音等单项,候选范围不会改变。',
|
||||||
faqTips2Q: '如何了解某个筛选条件的含义?',
|
faqTips2Q: '如何了解某个筛选条件的含义?',
|
||||||
faqTips2A:
|
faqTips2A:
|
||||||
'点击筛选条件或数据项旁的信息按钮,会有一段简短说明,告诉您它是什么、该怎么读。地图中的一些部分——例如出行时间卡片——也有各自的信息按钮。',
|
'点击筛选条件或数据项旁的“关于”,会有一段简短说明,告诉您它是什么、该怎么读。地图中的一些部分——例如出行时间卡片——也有各自的数据说明。',
|
||||||
faqTips3Q: '如何刷新地图颜色?',
|
faqTips3Q: '如何刷新地图颜色?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'当眼睛预览正在给地图着色时,在地图图例里点"重置颜色比例"即可按当前结果重新配色。平移、缩放或调整筛选之后,特别管用。',
|
'当某个数据项正在给地图着色时,在地图图例里点“重置颜色刻度”即可按当前结果重新配色。平移、缩放或调整筛选之后,特别管用。',
|
||||||
|
|
||||||
// FAQ items — Behind The Data
|
// FAQ items — Behind The Data
|
||||||
faqBehindData1Q: '为什么机场有时看起来比周围的街道更安静?',
|
faqBehindData1Q: '为什么机场有时看起来比周围的街道更安静?',
|
||||||
|
|
@ -1215,15 +1219,15 @@ const zh: Translations = {
|
||||||
faqBehindData3Q: '为什么相邻邮编的犯罪数字相同?',
|
faqBehindData3Q: '为什么相邻邮编的犯罪数字相同?',
|
||||||
faqBehindData3A:
|
faqBehindData3A:
|
||||||
'警方街道级犯罪数据按 LSOA 发布——大约 1,500 居民的小型社区单元。同一 LSOA 内每个邮编都继承同一年度总数,因此一条安静的住宅街和一个街区外的繁华街道,如果在同一边界内,可能显示完全相同的数据。覆盖医院、大学校园或工业园区的邮编,人均率可能异常偏高,因为那里事件数正常但纸面居民很少。',
|
'警方街道级犯罪数据按 LSOA 发布——大约 1,500 居民的小型社区单元。同一 LSOA 内每个邮编都继承同一年度总数,因此一条安静的住宅街和一个街区外的繁华街道,如果在同一边界内,可能显示完全相同的数据。覆盖医院、大学校园或工业园区的邮编,人均率可能异常偏高,因为那里事件数正常但纸面居民很少。',
|
||||||
faqBehindData4Q: '"2 公里内的好学校"是否意味着我孩子可以入读?',
|
faqBehindData4Q: '“2 公里内的好学校”是否意味着我孩子可以入读?',
|
||||||
faqBehindData4A:
|
faqBehindData4A:
|
||||||
'不一定。统计查找的是自身邮编落在您邮编中心点周围圆形范围内的公立学校。学区、宗教或选拔标准、兄弟姐妹优先以及录取规则都没有建模——附近的"好"或"杰出"学校可能从您家其实无法入读。请用此数字对比区域,决策前向学校或地方政府确认实际录取条件。',
|
'不一定。统计查找的是自身邮编落在您邮编中心点周围圆形范围内的公立学校。学区、宗教或选拔标准、兄弟姐妹优先以及录取规则都没有建模——附近的“良好”或“优秀”学校可能从您家其实无法入读。请用此数字对比区域,决策前向学校或地方政府确认实际录取条件。',
|
||||||
faqBehindData5Q: '为什么并非每户都有光纤的邮编也显示"Gigabit"?',
|
faqBehindData5Q: '为什么并非每户都有光纤的邮编也显示“Gigabit”?',
|
||||||
faqBehindData5A:
|
faqBehindData5A:
|
||||||
'Ofcom Connected Nations 的宽带覆盖按邮编给出可达到每个速度档的物业百分比。我们显示有任何可用性的最高档,因此只要邮编内有一户能达到 Gigabit,就会显示"Gigabit 可用"。这正确回答了"这条街上到底有没有光纤?",但并不保证楼里每一套今天都能下单。签约前,请始终就您的具体地址向运营商核实。',
|
'Ofcom Connected Nations 的宽带覆盖按邮编给出可达到每个速度档的物业百分比。我们显示有任何可用性的最高档,因此只要邮编内有一户能达到 Gigabit,就会显示“Gigabit 可用”。这正确回答了“这条街上到底有没有光纤?”,但并不保证楼里每一套今天都能下单。签约前,请始终就您的具体地址向运营商核实。',
|
||||||
faqBehindData6Q: '为什么公共交通时间在晚上或周末不变?',
|
faqBehindData6Q: '为什么公共交通时间在晚上或周末不变?',
|
||||||
faqBehindData6A:
|
faqBehindData6A:
|
||||||
'每个目的地的公交时间是基于完整 GTFS 时刻表,按一个周二早上的出发窗口(07:30–08:30)一次性计算的。"普通"值是该窗口内行程的中位数,"最佳情况"是第 5 百分位。非高峰、深夜和周末班次没有建模,因此只有早高峰公交的邮编在地图上仍可能显示交通便利。请把这些数字当作工作日通勤估算,而不是全天平均。',
|
'每个目的地的公共交通时间是基于完整 GTFS 时刻表,按一个周二早上的出发窗口(07:30–08:30)一次性计算的。“普通”值是该窗口内行程的中位数,“最佳情况”是第 5 百分位。非高峰、深夜和周末班次没有建模,因此只有早高峰公交的邮编在地图上仍可能显示交通便利。请把这些数字当作工作日通勤估算,而不是全天平均。',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Account Page ───────────────────────────────────
|
// ── Account Page ───────────────────────────────────
|
||||||
|
|
@ -1258,12 +1262,12 @@ const zh: Translations = {
|
||||||
invitesPage: {
|
invitesPage: {
|
||||||
inviteLinksLicensed: '邀请链接仅对已授权用户开放。',
|
inviteLinksLicensed: '邀请链接仅对已授权用户开放。',
|
||||||
inviteAdminLabel: '邀请好友(100% 折扣)',
|
inviteAdminLabel: '邀请好友(100% 折扣)',
|
||||||
inviteReferralLabel: '邀请好友(7折优惠)',
|
inviteReferralLabel: '邀请好友(七折优惠)',
|
||||||
generateFreeInvite: '生成免费邀请链接',
|
generateFreeInvite: '生成免费邀请链接',
|
||||||
generateReferralLink: '生成推荐链接',
|
generateReferralLink: '生成推荐链接',
|
||||||
copyInviteLink: '复制邀请链接',
|
copyInviteLink: '复制邀请链接',
|
||||||
adminInvitesTitle: '管理员邀请(100% 折扣)',
|
adminInvitesTitle: '管理员邀请(100% 折扣)',
|
||||||
referralInvitesTitle: '推荐邀请(7折优惠)',
|
referralInvitesTitle: '推荐邀请(七折优惠)',
|
||||||
yourInviteLinks: '您的邀请链接',
|
yourInviteLinks: '您的邀请链接',
|
||||||
noInvitesYet: '暂无已生成的邀请',
|
noInvitesYet: '暂无已生成的邀请',
|
||||||
link: '链接',
|
link: '链接',
|
||||||
|
|
@ -1278,9 +1282,9 @@ const zh: Translations = {
|
||||||
youreInvited: '您收到了邀请!',
|
youreInvited: '您收到了邀请!',
|
||||||
specialOffer: '特别优惠!',
|
specialOffer: '特别优惠!',
|
||||||
invitedByFree: '{{name}} 邀请您获取免费终身访问权限。',
|
invitedByFree: '{{name}} 邀请您获取免费终身访问权限。',
|
||||||
invitedByDiscount: '{{name}} 与您分享了终身访问的7折优惠。',
|
invitedByDiscount: '{{name}} 与您分享了终身访问的七折优惠。',
|
||||||
genericFreeInvite: '您已被邀请获取免费终身访问权限。',
|
genericFreeInvite: '您已被邀请获取免费终身访问权限。',
|
||||||
genericDiscount: '一位朋友与您分享了终身访问的7折优惠。',
|
genericDiscount: '一位朋友与您分享了终身访问的七折优惠。',
|
||||||
exploreEvery: '找到适合您生活的邮编',
|
exploreEvery: '找到适合您生活的邮编',
|
||||||
propertyInfo: '价格、通勤、学校、犯罪率、噪音、宽带、EPC 等',
|
propertyInfo: '价格、通勤、学校、犯罪率、噪音、宽带、EPC 等',
|
||||||
invalidInvite: '无效的邀请',
|
invalidInvite: '无效的邀请',
|
||||||
|
|
@ -1311,10 +1315,10 @@ const zh: Translations = {
|
||||||
poiCategories: '{{count}} 个兴趣点类别',
|
poiCategories: '{{count}} 个兴趣点类别',
|
||||||
travelDestination: '{{count}} 个出行目的地',
|
travelDestination: '{{count}} 个出行目的地',
|
||||||
travelDestinations: '{{count}} 个出行目的地',
|
travelDestinations: '{{count}} 个出行目的地',
|
||||||
propertiesMatch: '{{count}} 套房产符合',
|
propertiesMatch: '匹配 {{count}} 套房产',
|
||||||
setFilters: '设置 {{count}} 个筛选:{{list}}',
|
setFilters: '设置 {{count}} 个筛选:{{list}}',
|
||||||
noFiltersSet: '未设置筛选',
|
noFiltersSet: '未设置筛选',
|
||||||
toDestination: '{{mode}}到 {{label}} {{bounds}}',
|
toDestination: '{{mode}}前往 {{label}} {{bounds}}',
|
||||||
lessThanMin: '< {{max}} 分钟',
|
lessThanMin: '< {{max}} 分钟',
|
||||||
moreThanMin: '> {{min}} 分钟',
|
moreThanMin: '> {{min}} 分钟',
|
||||||
},
|
},
|
||||||
|
|
@ -1323,7 +1327,7 @@ const zh: Translations = {
|
||||||
tutorial: {
|
tutorial: {
|
||||||
step1Title: '告诉地图什么重要',
|
step1Title: '告诉地图什么重要',
|
||||||
step1Content:
|
step1Content:
|
||||||
'设置预算、通勤上限、学校质量、犯罪门槛、噪音容忍度、宽带需求,或任何您关心的条件。只有匹配区域会保持高亮。使用眼睛图标可按任意指标着色。',
|
'设置预算、通勤上限、学校质量、犯罪门槛、噪音容忍度、宽带需求,或任何您关心的条件。只有匹配区域会保持高亮。使用“地图着色”可按任意指标给地图着色。',
|
||||||
step2Title: '或者直接描述',
|
step2Title: '或者直接描述',
|
||||||
step2Content:
|
step2Content:
|
||||||
'用自然语言输入您的需求,例如“安静的地区,靠近好学校,£400,000 以下”,我们会为您设置筛选。',
|
'用自然语言输入您的需求,例如“安静的地区,靠近好学校,£400,000 以下”,我们会为您设置筛选。',
|
||||||
|
|
@ -1367,7 +1371,7 @@ const zh: Translations = {
|
||||||
'Number of bedrooms & living rooms': '卧室和客厅数量',
|
'Number of bedrooms & living rooms': '卧室和客厅数量',
|
||||||
'Construction year': '建造年份',
|
'Construction year': '建造年份',
|
||||||
'Date of last transaction': '上次交易日期',
|
'Date of last transaction': '上次交易日期',
|
||||||
'Former council house': '原公共住房',
|
'Former council house': '原公房',
|
||||||
'Current energy rating': '当前能源评级',
|
'Current energy rating': '当前能源评级',
|
||||||
'Potential energy rating': '潜在能源评级',
|
'Potential energy rating': '潜在能源评级',
|
||||||
'Interior height (m)': '室内层高(米)',
|
'Interior height (m)': '室内层高(米)',
|
||||||
|
|
@ -1379,20 +1383,20 @@ const zh: Translations = {
|
||||||
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
|
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
|
||||||
|
|
||||||
// ─ Feature names (Education) ─
|
// ─ Feature names (Education) ─
|
||||||
'Good+ primary schools within 2km': '2公里内良好+小学数量',
|
'Good+ primary schools within 2km': '2 公里内良好及以上小学数量',
|
||||||
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
|
'Good+ secondary schools within 2km': '2 公里内良好及以上中学数量',
|
||||||
'Good+ primary schools within 5km': '5公里内良好+小学数量',
|
'Good+ primary schools within 5km': '5 公里内良好及以上小学数量',
|
||||||
'Good+ secondary schools within 5km': '5公里内良好+中学数量',
|
'Good+ secondary schools within 5km': '5 公里内良好及以上中学数量',
|
||||||
'Outstanding primary schools within 2km': '2公里内优秀小学数量',
|
'Outstanding primary schools within 2km': '2 公里内优秀小学数量',
|
||||||
'Outstanding secondary schools within 2km': '2公里内优秀中学数量',
|
'Outstanding secondary schools within 2km': '2 公里内优秀中学数量',
|
||||||
'Outstanding primary schools within 5km': '5公里内优秀小学数量',
|
'Outstanding primary schools within 5km': '5 公里内优秀小学数量',
|
||||||
'Outstanding secondary schools within 5km': '5公里内优秀中学数量',
|
'Outstanding secondary schools within 5km': '5 公里内优秀中学数量',
|
||||||
'Education, Skills and Training Score': '教育、技能和培训得分',
|
'Education, Skills and Training Score': '教育、技能与培训得分',
|
||||||
|
|
||||||
// ─ Feature names (Area development) ─
|
// ─ Feature names (Area development) ─
|
||||||
'Income Score': '收入得分',
|
'Income Score': '收入得分',
|
||||||
'Employment Score': '就业得分',
|
'Employment Score': '就业得分',
|
||||||
'Health Deprivation and Disability Score': '健康与残障得分',
|
'Health Deprivation and Disability Score': '健康剥夺与残障得分',
|
||||||
'Housing Conditions Score': '住房状况得分',
|
'Housing Conditions Score': '住房状况得分',
|
||||||
'Air Quality and Road Safety Score': '空气质量与道路安全得分',
|
'Air Quality and Road Safety Score': '空气质量与道路安全得分',
|
||||||
|
|
||||||
|
|
@ -1507,8 +1511,8 @@ const zh: Translations = {
|
||||||
Bakery: '面包店',
|
Bakery: '面包店',
|
||||||
'Butcher & Fishmonger': '肉铺与鱼铺',
|
'Butcher & Fishmonger': '肉铺与鱼铺',
|
||||||
Greengrocer: '果蔬店',
|
Greengrocer: '果蔬店',
|
||||||
'Off-Licence': '酒类商店',
|
'Off-Licence': '酒类专卖店',
|
||||||
'Deli & Specialty': '熟食与特产店',
|
'Deli & Specialty': '熟食与特色食品店',
|
||||||
'Fashion & Clothing': '时装服饰',
|
'Fashion & Clothing': '时装服饰',
|
||||||
Electronics: '电子产品',
|
Electronics: '电子产品',
|
||||||
'Charity Shop': '慈善商店',
|
'Charity Shop': '慈善商店',
|
||||||
|
|
@ -1529,7 +1533,7 @@ const zh: Translations = {
|
||||||
'Vet & Pet Care': '宠物医院与护理',
|
'Vet & Pet Care': '宠物医院与护理',
|
||||||
Bank: '银行',
|
Bank: '银行',
|
||||||
'Travel Agent': '旅行社',
|
'Travel Agent': '旅行社',
|
||||||
Police: '警察',
|
Police: '警察局',
|
||||||
'Fire Station': '消防站',
|
'Fire Station': '消防站',
|
||||||
'Ambulance Station': '急救站',
|
'Ambulance Station': '急救站',
|
||||||
'GP Surgery': '全科诊所',
|
'GP Surgery': '全科诊所',
|
||||||
|
|
@ -1540,7 +1544,7 @@ const zh: Translations = {
|
||||||
Physiotherapy: '理疗',
|
Physiotherapy: '理疗',
|
||||||
'Counselling & Therapy': '心理咨询与治疗',
|
'Counselling & Therapy': '心理咨询与治疗',
|
||||||
'Care Home': '养老院',
|
'Care Home': '养老院',
|
||||||
'Medical & Mobility': '医疗器械与辅助设备',
|
'Medical & Mobility': '医疗用品与行动辅助设备',
|
||||||
Museum: '博物馆',
|
Museum: '博物馆',
|
||||||
Gallery: '美术馆',
|
Gallery: '美术馆',
|
||||||
Library: '图书馆',
|
Library: '图书馆',
|
||||||
|
|
@ -1549,6 +1553,14 @@ const zh: Translations = {
|
||||||
Zoo: '动物园',
|
Zoo: '动物园',
|
||||||
'Tourist Attraction': '旅游景点',
|
'Tourist Attraction': '旅游景点',
|
||||||
School: '学校',
|
School: '学校',
|
||||||
|
'Nursery school': '幼儿园',
|
||||||
|
'Primary school': '小学',
|
||||||
|
'Secondary school': '中学',
|
||||||
|
'All-through school': '一贯制学校',
|
||||||
|
'Sixth form': '高中(16+)',
|
||||||
|
'Further education college': '继续教育学院',
|
||||||
|
University: '大学',
|
||||||
|
'Special school': '特殊学校',
|
||||||
Hotel: '酒店',
|
Hotel: '酒店',
|
||||||
'Local Business': '本地商业',
|
'Local Business': '本地商业',
|
||||||
Offices: '写字楼',
|
Offices: '写字楼',
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,15 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
||||||
'Off-Licence': '/assets/twemoji/1f377.png',
|
'Off-Licence': '/assets/twemoji/1f377.png',
|
||||||
'Planet Organic': '/assets/poi-icons/logos/planet_organic.svg',
|
'Planet Organic': '/assets/poi-icons/logos/planet_organic.svg',
|
||||||
'Rail station': '/assets/twemoji/1f686.png',
|
'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": '/assets/poi-icons/logos/sainsburys.svg',
|
||||||
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
|
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
|
||||||
Spar: '/assets/poi-icons/logos/spar.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 */
|
/** Zoom level at which supercluster stops clustering */
|
||||||
export const POI_CLUSTER_MAX_ZOOM = 15;
|
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.
|
* Groups whose features should be collapsed into stacked bar charts.
|
||||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ export interface SchoolMetadata {
|
||||||
website?: string;
|
website?: string;
|
||||||
telephone?: string;
|
telephone?: string;
|
||||||
head_name?: string;
|
head_name?: string;
|
||||||
|
ofsted_rating?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface POI {
|
export interface POI {
|
||||||
|
|
|
||||||
48
output/recording-de-mobile/narration-script.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"storyboard": "recording-de-mobile",
|
||||||
|
"voice": {
|
||||||
|
"instruct": "Calm and cheerful German male narrator with clear standard German pronunciation and a friendly, practical delivery.",
|
||||||
|
"language": "German",
|
||||||
|
"referenceText": "Willkommen zur Demonstration. Diese Sprecherstimme hören Sie im gesamten Video.",
|
||||||
|
"temperature": 0.6,
|
||||||
|
"topP": 0.9,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cueIndex": 0,
|
||||||
|
"text": "Wählen Sie kein Zuhause durch endloses Scrollen.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 1,
|
||||||
|
"text": "Beschreiben Sie, was Ihnen wichtig ist. Budget, Pendelzeit, Schulen, alles.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 2,
|
||||||
|
"text": "Die Karte zeigt jede passende Postleitzahl in ganz England.",
|
||||||
|
"gapBeforeMs": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 3,
|
||||||
|
"text": "Ein Regler bewegt sich. Die Karte antwortet sofort.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 4,
|
||||||
|
"text": "Öffnen Sie eine Postleitzahl. Preise. Schulen. Kriminalität. Lärm. Alles auf einer Karte.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 5,
|
||||||
|
"text": "Mit dieser Auswahl zu den Inseraten. Sie wissen jetzt, wo Sie suchen sollen.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 6,
|
||||||
|
"text": "Perfect Postcode. Wissen, wo Sie suchen sollten, bevor Inserate Ihre Suche bestimmen.",
|
||||||
|
"gapBeforeMs": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
48
output/recording-de/narration-script.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"storyboard": "recording-de",
|
||||||
|
"voice": {
|
||||||
|
"instruct": "Calm and cheerful German male narrator with clear standard German pronunciation and a friendly, practical delivery.",
|
||||||
|
"language": "German",
|
||||||
|
"referenceText": "Willkommen zur Demonstration. Diese Sprecherstimme hören Sie im gesamten Video.",
|
||||||
|
"temperature": 0.6,
|
||||||
|
"topP": 0.9,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cueIndex": 0,
|
||||||
|
"text": "Wählen Sie kein Zuhause durch endloses Scrollen.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 1,
|
||||||
|
"text": "Beschreiben Sie, was Ihnen wichtig ist. Budget, Pendelzeit, Schulen, alles.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 2,
|
||||||
|
"text": "Die Karte zeigt jede passende Postleitzahl in ganz England.",
|
||||||
|
"gapBeforeMs": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 3,
|
||||||
|
"text": "Ein Regler bewegt sich. Die Karte antwortet sofort.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 4,
|
||||||
|
"text": "Öffnen Sie eine Postleitzahl. Preise. Schulen. Kriminalität. Lärm. Alles auf einer Karte.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 5,
|
||||||
|
"text": "Mit dieser Auswahl zu den Inseraten. Sie wissen jetzt, wo Sie suchen sollen.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 6,
|
||||||
|
"text": "Perfect Postcode. Wissen, wo Sie suchen sollten, bevor Inserate Ihre Suche bestimmen.",
|
||||||
|
"gapBeforeMs": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
48
output/recording-hi-mobile/narration-script.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"storyboard": "recording-hi-mobile",
|
||||||
|
"voice": {
|
||||||
|
"instruct": "Calm and cheerful Indian male narrator speaking English with a strong Indian accent and a friendly, practical delivery.",
|
||||||
|
"language": "English",
|
||||||
|
"referenceText": "Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
|
"temperature": 0.6,
|
||||||
|
"topP": 0.9,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cueIndex": 0,
|
||||||
|
"text": "Don't pick a home by scrolling listings.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 1,
|
||||||
|
"text": "Describe what you want. Budget, commute, schools, whatever matters.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 2,
|
||||||
|
"text": "The map lights up with every postcode in England that fits.",
|
||||||
|
"gapBeforeMs": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 3,
|
||||||
|
"text": "Move one slider. The map answers instantly.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 4,
|
||||||
|
"text": "Open any postcode. Sold prices. Schools. Crime. Noise. All on one screen.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 5,
|
||||||
|
"text": "Take your shortlist to the listings. Now you know where to search.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 6,
|
||||||
|
"text": "Perfect Postcode. Know where to look before listings take over.",
|
||||||
|
"gapBeforeMs": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
48
output/recording-hi/narration-script.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"storyboard": "recording-hi",
|
||||||
|
"voice": {
|
||||||
|
"instruct": "Calm and cheerful Indian male narrator speaking English with a strong Indian accent and a friendly, practical delivery.",
|
||||||
|
"language": "English",
|
||||||
|
"referenceText": "Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
|
"temperature": 0.6,
|
||||||
|
"topP": 0.9,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cueIndex": 0,
|
||||||
|
"text": "Don't pick a home by scrolling listings.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 1,
|
||||||
|
"text": "Describe what you want. Budget, commute, schools, whatever matters.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 2,
|
||||||
|
"text": "The map lights up with every postcode in England that fits.",
|
||||||
|
"gapBeforeMs": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 3,
|
||||||
|
"text": "Move one slider. The map answers instantly.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 4,
|
||||||
|
"text": "Open any postcode. Sold prices. Schools. Crime. Noise. All on one screen.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 5,
|
||||||
|
"text": "Take your shortlist to the listings. Now you know where to search.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 6,
|
||||||
|
"text": "Perfect Postcode. Know where to look before listings take over.",
|
||||||
|
"gapBeforeMs": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
48
output/recording-mobile/narration-script.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"storyboard": "recording-mobile",
|
||||||
|
"voice": {
|
||||||
|
"instruct": "Calm and cheerful young British male narrator from the North of England with a strong Manchester accent.",
|
||||||
|
"language": "English",
|
||||||
|
"referenceText": "Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
|
"temperature": 0.6,
|
||||||
|
"topP": 0.9,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cueIndex": 0,
|
||||||
|
"text": "Don't pick a home by scrolling listings.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 1,
|
||||||
|
"text": "Describe what you want. Budget, commute, schools, whatever matters.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 2,
|
||||||
|
"text": "The map lights up with every postcode in England that fits.",
|
||||||
|
"gapBeforeMs": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 3,
|
||||||
|
"text": "Move one slider. The map answers instantly.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 4,
|
||||||
|
"text": "Open any postcode. Sold prices. Schools. Crime. Noise. All on one screen.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 5,
|
||||||
|
"text": "Take your shortlist to the listings. Now you know where to search.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 6,
|
||||||
|
"text": "Perfect Postcode. Know where to look before listings take over.",
|
||||||
|
"gapBeforeMs": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
48
output/recording-zh-mobile/narration-script.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"storyboard": "recording-zh-mobile",
|
||||||
|
"voice": {
|
||||||
|
"instruct": "Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin pronunciation and a friendly, practical delivery.",
|
||||||
|
"language": "Chinese",
|
||||||
|
"referenceText": "欢迎观看演示。整段视频都会使用这位旁白的声音。",
|
||||||
|
"temperature": 0.6,
|
||||||
|
"topP": 0.9,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cueIndex": 0,
|
||||||
|
"text": "别再靠刷房源挑家了。",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 1,
|
||||||
|
"text": "用日常话告诉地图你想要的家。预算、通勤、学校,什么都行。",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 2,
|
||||||
|
"text": "地图点亮每一个符合条件的英格兰邮编。",
|
||||||
|
"gapBeforeMs": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 3,
|
||||||
|
"text": "动一个滑块,地图立刻给答案。",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 4,
|
||||||
|
"text": "打开任意邮编。成交价、学校、犯罪率、噪音,一目了然。",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 5,
|
||||||
|
"text": "带着这份清单去房源网站。现在你知道该在哪儿找了。",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 6,
|
||||||
|
"text": "Perfect Postcode. 先知道该看哪里,再让房源牵着你走。",
|
||||||
|
"gapBeforeMs": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
48
output/recording-zh/narration-script.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"storyboard": "recording-zh",
|
||||||
|
"voice": {
|
||||||
|
"instruct": "Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin pronunciation and a friendly, practical delivery.",
|
||||||
|
"language": "Chinese",
|
||||||
|
"referenceText": "欢迎观看演示。整段视频都会使用这位旁白的声音。",
|
||||||
|
"temperature": 0.6,
|
||||||
|
"topP": 0.9,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cueIndex": 0,
|
||||||
|
"text": "别再靠刷房源挑家了。",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 1,
|
||||||
|
"text": "用日常话告诉地图你想要的家。预算、通勤、学校,什么都行。",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 2,
|
||||||
|
"text": "地图点亮每一个符合条件的英格兰邮编。",
|
||||||
|
"gapBeforeMs": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 3,
|
||||||
|
"text": "动一个滑块,地图立刻给答案。",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 4,
|
||||||
|
"text": "打开任意邮编。成交价、学校、犯罪率、噪音,一目了然。",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 5,
|
||||||
|
"text": "带着这份清单去房源网站。现在你知道该在哪儿找了。",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 6,
|
||||||
|
"text": "Perfect Postcode. 先知道该看哪里,再让房源牵着你走。",
|
||||||
|
"gapBeforeMs": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
48
output/recording/narration-script.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"storyboard": "recording",
|
||||||
|
"voice": {
|
||||||
|
"instruct": "Calm and cheerful young British male narrator from the North of England with a strong Manchester accent.",
|
||||||
|
"language": "English",
|
||||||
|
"referenceText": "Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
|
"temperature": 0.6,
|
||||||
|
"topP": 0.9,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"cueIndex": 0,
|
||||||
|
"text": "Don't pick a home by scrolling listings.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 1,
|
||||||
|
"text": "Describe what you want. Budget, commute, schools, whatever matters.",
|
||||||
|
"gapBeforeMs": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 2,
|
||||||
|
"text": "The map lights up with every postcode in England that fits.",
|
||||||
|
"gapBeforeMs": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 3,
|
||||||
|
"text": "Move one slider. The map answers instantly.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 4,
|
||||||
|
"text": "Open any postcode. Sold prices. Schools. Crime. Noise. All on one screen.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 5,
|
||||||
|
"text": "Take your shortlist to the listings. Now you know where to search.",
|
||||||
|
"gapBeforeMs": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cueIndex": 6,
|
||||||
|
"text": "Perfect Postcode. Know where to look before listings take over.",
|
||||||
|
"gapBeforeMs": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
108
output/storyboards.json
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
{
|
||||||
|
"storyboards": [
|
||||||
|
{
|
||||||
|
"name": "recording",
|
||||||
|
"locale": "en",
|
||||||
|
"aspect": "16x9",
|
||||||
|
"outputFps": 50,
|
||||||
|
"minDurationS": 10,
|
||||||
|
"maxDurationS": 75,
|
||||||
|
"posterTimeS": 16,
|
||||||
|
"publishedSize": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recording-mobile",
|
||||||
|
"locale": "en-mobile",
|
||||||
|
"aspect": "9x16",
|
||||||
|
"outputFps": 50,
|
||||||
|
"minDurationS": 10,
|
||||||
|
"maxDurationS": 75,
|
||||||
|
"posterTimeS": 12,
|
||||||
|
"publishedSize": {
|
||||||
|
"width": 1080,
|
||||||
|
"height": 1920
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recording-de",
|
||||||
|
"locale": "de",
|
||||||
|
"aspect": "16x9",
|
||||||
|
"outputFps": 50,
|
||||||
|
"minDurationS": 10,
|
||||||
|
"maxDurationS": 75,
|
||||||
|
"posterTimeS": 16,
|
||||||
|
"publishedSize": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recording-de-mobile",
|
||||||
|
"locale": "de-mobile",
|
||||||
|
"aspect": "9x16",
|
||||||
|
"outputFps": 50,
|
||||||
|
"minDurationS": 10,
|
||||||
|
"maxDurationS": 75,
|
||||||
|
"posterTimeS": 12,
|
||||||
|
"publishedSize": {
|
||||||
|
"width": 1080,
|
||||||
|
"height": 1920
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recording-zh",
|
||||||
|
"locale": "zh",
|
||||||
|
"aspect": "16x9",
|
||||||
|
"outputFps": 50,
|
||||||
|
"minDurationS": 10,
|
||||||
|
"maxDurationS": 75,
|
||||||
|
"posterTimeS": 16,
|
||||||
|
"publishedSize": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recording-zh-mobile",
|
||||||
|
"locale": "zh-mobile",
|
||||||
|
"aspect": "9x16",
|
||||||
|
"outputFps": 50,
|
||||||
|
"minDurationS": 10,
|
||||||
|
"maxDurationS": 75,
|
||||||
|
"posterTimeS": 12,
|
||||||
|
"publishedSize": {
|
||||||
|
"width": 1080,
|
||||||
|
"height": 1920
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recording-hi",
|
||||||
|
"locale": "hi",
|
||||||
|
"aspect": "16x9",
|
||||||
|
"outputFps": 50,
|
||||||
|
"minDurationS": 10,
|
||||||
|
"maxDurationS": 75,
|
||||||
|
"posterTimeS": 16,
|
||||||
|
"publishedSize": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recording-hi-mobile",
|
||||||
|
"locale": "hi-mobile",
|
||||||
|
"aspect": "9x16",
|
||||||
|
"outputFps": 50,
|
||||||
|
"minDurationS": 10,
|
||||||
|
"maxDurationS": 75,
|
||||||
|
"posterTimeS": 12,
|
||||||
|
"publishedSize": {
|
||||||
|
"width": 1080,
|
||||||
|
"height": 1920
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,11 @@ from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from pipeline.transform.transform_poi import NAPTAN_EMOJIS, _CATEGORIES
|
from pipeline.transform.transform_poi import (
|
||||||
|
NAPTAN_EMOJIS,
|
||||||
|
SCHOOL_ICON_CATEGORIES,
|
||||||
|
_CATEGORIES,
|
||||||
|
)
|
||||||
|
|
||||||
GLYPHS_BASE = "https://protomaps.github.io/basemaps-assets/fonts"
|
GLYPHS_BASE = "https://protomaps.github.io/basemaps-assets/fonts"
|
||||||
SPRITES_BASE = "https://protomaps.github.io/basemaps-assets/sprites/v4"
|
SPRITES_BASE = "https://protomaps.github.io/basemaps-assets/sprites/v4"
|
||||||
|
|
@ -109,6 +113,9 @@ def collect_twemoji_codes() -> list[str]:
|
||||||
for emoji in NAPTAN_EMOJIS.values():
|
for emoji in NAPTAN_EMOJIS.values():
|
||||||
emojis.add(emoji)
|
emojis.add(emoji)
|
||||||
|
|
||||||
|
for emoji in SCHOOL_ICON_CATEGORIES.values():
|
||||||
|
emojis.add(emoji)
|
||||||
|
|
||||||
# First codepoint hex, matching frontend logic
|
# First codepoint hex, matching frontend logic
|
||||||
return sorted({f"{ord(e[0]):x}" for e in emojis})
|
return sorted({f"{ord(e[0]):x}" for e in emojis})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,8 @@ def build_crime_hotspot_tiles(
|
||||||
str(max_zoom),
|
str(max_zoom),
|
||||||
"--drop-densest-as-needed",
|
"--drop-densest-as-needed",
|
||||||
"--extend-zooms-if-still-dropping",
|
"--extend-zooms-if-still-dropping",
|
||||||
|
"--temporary-directory",
|
||||||
|
tmp,
|
||||||
str(ndjson_path),
|
str(ndjson_path),
|
||||||
],
|
],
|
||||||
check=True,
|
check=True,
|
||||||
|
|
|
||||||
960
pipeline/transform/enrich_actual_listings.py
Normal file
|
|
@ -0,0 +1,960 @@
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
from thefuzz import fuzz
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from pipeline.local_temp import local_tmp_dir
|
||||||
|
from pipeline.transform.join_epc_pp import _scan_epc_certificates
|
||||||
|
from pipeline.utils.fuzzy_join import normalize_address_key, normalize_postcode_key
|
||||||
|
from pipeline.utils.postcode_mapping import build_postcode_mapping
|
||||||
|
|
||||||
|
MIN_FLOOR_AREA_M2 = 10.0
|
||||||
|
PROPERTY_MATCH_MIN_SCORE_WITH_NUMBERS = 82.0
|
||||||
|
PROPERTY_MATCH_MIN_SCORE_WITHOUT_NUMBERS = 96.0
|
||||||
|
PROPERTY_MATCH_MIN_MARGIN = 4.0
|
||||||
|
EPC_MATCH_MIN_SCORE_WITH_NUMBERS = 82.0
|
||||||
|
EPC_MATCH_MIN_SCORE_WITHOUT_NUMBERS = 96.0
|
||||||
|
EPC_MATCH_MIN_MARGIN = 4.0
|
||||||
|
ENRICHMENT_VERSION = 1
|
||||||
|
|
||||||
|
_NUMBER_RE = re.compile(r"\d+")
|
||||||
|
|
||||||
|
LISTING_REQUIRED_COLUMNS = [
|
||||||
|
"Bedrooms",
|
||||||
|
"Bathrooms",
|
||||||
|
"Number of bedrooms & living rooms",
|
||||||
|
"lon",
|
||||||
|
"lat",
|
||||||
|
"Postcode",
|
||||||
|
"Address per Property Register",
|
||||||
|
"Leasehold/Freehold",
|
||||||
|
"Property type",
|
||||||
|
"Property sub-type",
|
||||||
|
"Price qualifier",
|
||||||
|
"Total floor area (sqm)",
|
||||||
|
"Listing URL",
|
||||||
|
"Listing features",
|
||||||
|
"Listing date",
|
||||||
|
"Listing status",
|
||||||
|
"Asking price",
|
||||||
|
"Asking price per sqm",
|
||||||
|
]
|
||||||
|
|
||||||
|
PROPERTY_CANDIDATE_COLUMNS = [
|
||||||
|
"Address per Property Register",
|
||||||
|
"Postcode",
|
||||||
|
"Leasehold/Freehold",
|
||||||
|
"Last known price",
|
||||||
|
"Date of last transaction",
|
||||||
|
"Address per EPC",
|
||||||
|
"Current energy rating",
|
||||||
|
"Potential energy rating",
|
||||||
|
"Total floor area (sqm)",
|
||||||
|
"Number of bedrooms & living rooms",
|
||||||
|
"Interior height (m)",
|
||||||
|
"Construction year",
|
||||||
|
"Former council house",
|
||||||
|
"Is construction date approximate",
|
||||||
|
"Listed building",
|
||||||
|
"Estimated monthly rent",
|
||||||
|
"Street tree density percentile",
|
||||||
|
"Property type",
|
||||||
|
"Price per sqm",
|
||||||
|
"Estimated current price",
|
||||||
|
"Est. price per sqm",
|
||||||
|
]
|
||||||
|
|
||||||
|
PROPERTY_ENRICHMENT_COLUMNS = [
|
||||||
|
"Address per EPC",
|
||||||
|
"Current energy rating",
|
||||||
|
"Potential energy rating",
|
||||||
|
"Interior height (m)",
|
||||||
|
"Construction year",
|
||||||
|
"Former council house",
|
||||||
|
"Is construction date approximate",
|
||||||
|
"Listed building",
|
||||||
|
"Estimated monthly rent",
|
||||||
|
"Street tree density percentile",
|
||||||
|
"Date of last transaction",
|
||||||
|
]
|
||||||
|
|
||||||
|
EPC_ENRICHMENT_COLUMNS = [
|
||||||
|
"Address per EPC",
|
||||||
|
"Current energy rating",
|
||||||
|
"Potential energy rating",
|
||||||
|
"Total floor area (sqm)",
|
||||||
|
"Number of bedrooms & living rooms",
|
||||||
|
"Interior height (m)",
|
||||||
|
"Construction year",
|
||||||
|
"Former council house",
|
||||||
|
]
|
||||||
|
|
||||||
|
EPC_RATING_VALUES = ["A", "B", "C", "D", "E", "F", "G"]
|
||||||
|
TENURE_VALUES = ["Freehold", "Leasehold"]
|
||||||
|
PROPERTY_TYPE_VALUES = [
|
||||||
|
"Detached",
|
||||||
|
"Semi-Detached",
|
||||||
|
"Terraced",
|
||||||
|
"Flats/Maisonettes",
|
||||||
|
"Other",
|
||||||
|
]
|
||||||
|
|
||||||
|
COLUMN_DTYPES = {
|
||||||
|
"Address per EPC": pl.Utf8,
|
||||||
|
"Current energy rating": pl.Utf8,
|
||||||
|
"Potential energy rating": pl.Utf8,
|
||||||
|
"Total floor area (sqm)": pl.Float64,
|
||||||
|
"Number of bedrooms & living rooms": pl.Int32,
|
||||||
|
"Interior height (m)": pl.Float64,
|
||||||
|
"Construction year": pl.UInt16,
|
||||||
|
"Former council house": pl.Utf8,
|
||||||
|
"Is construction date approximate": pl.UInt8,
|
||||||
|
"Listed building": pl.Utf8,
|
||||||
|
"Estimated monthly rent": pl.Float32,
|
||||||
|
"Street tree density percentile": pl.Float32,
|
||||||
|
"Date of last transaction": pl.Datetime("us"),
|
||||||
|
"Property type": pl.Utf8,
|
||||||
|
"Leasehold/Freehold": pl.Utf8,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_postcode_expr(column: str) -> pl.Expr:
|
||||||
|
compact = (
|
||||||
|
pl.col(column)
|
||||||
|
.cast(pl.Utf8)
|
||||||
|
.str.to_uppercase()
|
||||||
|
.str.replace_all(r"[^A-Z0-9]+", "")
|
||||||
|
.str.strip_chars()
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
pl.when(compact.str.contains(r"^[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2}$"))
|
||||||
|
.then(compact.str.replace(r"^(.+)([0-9][A-Z]{2})$", "${1} ${2}"))
|
||||||
|
.otherwise(None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_string_expr(column: str) -> pl.Expr:
|
||||||
|
stripped = pl.col(column).cast(pl.Utf8).str.strip_chars()
|
||||||
|
return pl.when(stripped == "").then(None).otherwise(stripped)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce_non_empty(*columns: str) -> pl.Expr:
|
||||||
|
return pl.coalesce(
|
||||||
|
[
|
||||||
|
pl.when(pl.col(column).cast(pl.Utf8).str.strip_chars() == "")
|
||||||
|
.then(None)
|
||||||
|
.otherwise(pl.col(column).cast(pl.Utf8))
|
||||||
|
for column in columns
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_number_expr(column: str) -> pl.Expr:
|
||||||
|
return pl.when(pl.col(column).is_finite()).then(pl.col(column)).otherwise(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_listings(listings_path: Path, arcgis_path: Path) -> pl.DataFrame:
|
||||||
|
schema = pl.scan_parquet(listings_path).collect_schema()
|
||||||
|
missing = sorted(set(LISTING_REQUIRED_COLUMNS) - set(schema.names()))
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"{listings_path} is missing listing columns: {missing}")
|
||||||
|
|
||||||
|
listings = (
|
||||||
|
pl.scan_parquet(listings_path)
|
||||||
|
.with_row_index("_listing_idx")
|
||||||
|
.with_columns(
|
||||||
|
_canonical_postcode_expr("Postcode").alias("_original_postcode"),
|
||||||
|
normalize_address_key(pl.col("Address per Property Register")).alias(
|
||||||
|
"_listing_match_address"
|
||||||
|
),
|
||||||
|
normalize_postcode_key(pl.col("Postcode")).alias("_listing_match_postcode"),
|
||||||
|
)
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
|
||||||
|
postcode_mapping = build_postcode_mapping(arcgis_path)
|
||||||
|
listings = (
|
||||||
|
listings.join(
|
||||||
|
postcode_mapping,
|
||||||
|
left_on="_original_postcode",
|
||||||
|
right_on="old_postcode",
|
||||||
|
how="left",
|
||||||
|
)
|
||||||
|
.with_columns(
|
||||||
|
pl.coalesce("new_postcode", "_original_postcode", "Postcode").alias(
|
||||||
|
"Postcode"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.drop("new_postcode", strict=False)
|
||||||
|
.with_columns(
|
||||||
|
normalize_postcode_key(pl.col("Postcode")).alias("_listing_match_postcode"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return listings
|
||||||
|
|
||||||
|
|
||||||
|
def _load_property_candidates(
|
||||||
|
properties_path: Path, listing_postcodes: list[str]
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
schema = pl.scan_parquet(properties_path).collect_schema()
|
||||||
|
columns = [
|
||||||
|
column for column in PROPERTY_CANDIDATE_COLUMNS if column in schema.names()
|
||||||
|
]
|
||||||
|
missing = sorted(
|
||||||
|
set(
|
||||||
|
[
|
||||||
|
"Address per Property Register",
|
||||||
|
"Postcode",
|
||||||
|
"Property type",
|
||||||
|
"Total floor area (sqm)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
- set(columns)
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"{properties_path} is missing property columns: {missing}")
|
||||||
|
|
||||||
|
return (
|
||||||
|
pl.scan_parquet(properties_path)
|
||||||
|
.select(columns)
|
||||||
|
.with_columns(
|
||||||
|
normalize_postcode_key(pl.col("Postcode")).alias("_match_postcode")
|
||||||
|
)
|
||||||
|
.filter(pl.col("_match_postcode").is_in(listing_postcodes))
|
||||||
|
.with_columns(
|
||||||
|
normalize_address_key(pl.col("Address per Property Register")).alias(
|
||||||
|
"_match_register_address"
|
||||||
|
),
|
||||||
|
normalize_address_key(pl.col("Address per EPC")).alias("_match_epc_address")
|
||||||
|
if "Address per EPC" in columns
|
||||||
|
else pl.lit(None, dtype=pl.Utf8).alias("_match_epc_address"),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
pl.col("_match_register_address").is_not_null()
|
||||||
|
| pl.col("_match_epc_address").is_not_null()
|
||||||
|
)
|
||||||
|
.with_row_index("_property_row")
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _property_candidates_by_postcode(
|
||||||
|
candidates: pl.DataFrame,
|
||||||
|
) -> dict[str, list[dict]]:
|
||||||
|
buckets: dict[str, list[dict]] = {}
|
||||||
|
for row in candidates.iter_rows(named=True):
|
||||||
|
postcode = row.get("_match_postcode")
|
||||||
|
if postcode:
|
||||||
|
buckets.setdefault(postcode, []).append(row)
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def _numbers_compatible(left: str | None, right: str | None) -> bool:
|
||||||
|
if not left or not right:
|
||||||
|
return False
|
||||||
|
left_nums = set(_NUMBER_RE.findall(left))
|
||||||
|
right_nums = set(_NUMBER_RE.findall(right))
|
||||||
|
smaller, larger = (
|
||||||
|
(left_nums, right_nums)
|
||||||
|
if len(left_nums) <= len(right_nums)
|
||||||
|
else (right_nums, left_nums)
|
||||||
|
)
|
||||||
|
if not smaller and larger:
|
||||||
|
return False
|
||||||
|
return smaller.issubset(larger)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_number(address: str | None) -> bool:
|
||||||
|
return bool(address and _NUMBER_RE.search(address))
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio_bonus(
|
||||||
|
left: float | int | None, right: float | int | None, pct: float, cap: float
|
||||||
|
) -> float:
|
||||||
|
if left is None or right is None:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
left_f = float(left)
|
||||||
|
right_f = float(right)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
if left_f <= 0 or right_f <= 0:
|
||||||
|
return 0.0
|
||||||
|
rel = abs(left_f - right_f) / max(left_f, right_f)
|
||||||
|
if rel > pct:
|
||||||
|
return 0.0
|
||||||
|
return cap * (1.0 - rel / pct)
|
||||||
|
|
||||||
|
|
||||||
|
def _rooms_bonus(left: int | None, right: int | None) -> float:
|
||||||
|
if left is None or right is None:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
diff = abs(int(left) - int(right))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
if diff == 0:
|
||||||
|
return 4.0
|
||||||
|
if diff == 1:
|
||||||
|
return 2.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_bonus(
|
||||||
|
left: str | None, right: str | None, *, exact: float, mismatch: float
|
||||||
|
) -> float:
|
||||||
|
if not left or not right:
|
||||||
|
return 0.0
|
||||||
|
return exact if left == right else mismatch
|
||||||
|
|
||||||
|
|
||||||
|
def _address_score(query: str, candidate: str | None) -> int:
|
||||||
|
if not candidate:
|
||||||
|
return 0
|
||||||
|
return max(
|
||||||
|
fuzz.token_set_ratio(query, candidate),
|
||||||
|
fuzz.token_sort_ratio(query, candidate),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _best_property_candidate(listing: dict, candidates: list[dict]) -> dict | None:
|
||||||
|
query = listing.get("_listing_match_address")
|
||||||
|
if not query:
|
||||||
|
return None
|
||||||
|
|
||||||
|
listing_has_numbers = _has_number(query)
|
||||||
|
scored: list[tuple[float, int, dict, str]] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
register_address = candidate.get("_match_register_address")
|
||||||
|
epc_address = candidate.get("_match_epc_address")
|
||||||
|
if listing_has_numbers and not (
|
||||||
|
_numbers_compatible(query, register_address)
|
||||||
|
or _numbers_compatible(query, epc_address)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
register_score = _address_score(query, register_address)
|
||||||
|
epc_score = _address_score(query, epc_address)
|
||||||
|
base_score = max(register_score, epc_score)
|
||||||
|
if base_score == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
score = float(base_score)
|
||||||
|
score += _enum_bonus(
|
||||||
|
listing.get("Property type"),
|
||||||
|
candidate.get("Property type"),
|
||||||
|
exact=7.0,
|
||||||
|
mismatch=-8.0,
|
||||||
|
)
|
||||||
|
score += _enum_bonus(
|
||||||
|
listing.get("Leasehold/Freehold"),
|
||||||
|
candidate.get("Leasehold/Freehold"),
|
||||||
|
exact=3.0,
|
||||||
|
mismatch=-3.0,
|
||||||
|
)
|
||||||
|
score += _ratio_bonus(
|
||||||
|
listing.get("Total floor area (sqm)"),
|
||||||
|
candidate.get("Total floor area (sqm)"),
|
||||||
|
pct=0.15,
|
||||||
|
cap=8.0,
|
||||||
|
)
|
||||||
|
score += _rooms_bonus(
|
||||||
|
listing.get("Number of bedrooms & living rooms"),
|
||||||
|
candidate.get("Number of bedrooms & living rooms"),
|
||||||
|
)
|
||||||
|
score += _ratio_bonus(
|
||||||
|
listing.get("Asking price"),
|
||||||
|
candidate.get("Estimated current price")
|
||||||
|
or candidate.get("Last known price"),
|
||||||
|
pct=0.25,
|
||||||
|
cap=3.0,
|
||||||
|
)
|
||||||
|
matched_address = (
|
||||||
|
"Address per Property Register"
|
||||||
|
if register_score >= epc_score
|
||||||
|
else "Address per EPC"
|
||||||
|
)
|
||||||
|
scored.append((score, base_score, candidate, matched_address))
|
||||||
|
|
||||||
|
if not scored:
|
||||||
|
return None
|
||||||
|
scored.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
top = scored[0]
|
||||||
|
runner_up = scored[1][0] if len(scored) > 1 else None
|
||||||
|
margin = top[0] - runner_up if runner_up is not None else top[0]
|
||||||
|
threshold = (
|
||||||
|
PROPERTY_MATCH_MIN_SCORE_WITH_NUMBERS
|
||||||
|
if listing_has_numbers
|
||||||
|
else PROPERTY_MATCH_MIN_SCORE_WITHOUT_NUMBERS
|
||||||
|
)
|
||||||
|
if top[0] < threshold or margin < PROPERTY_MATCH_MIN_MARGIN:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"_listing_idx": listing["_listing_idx"],
|
||||||
|
"_property_row": top[2]["_property_row"],
|
||||||
|
"Historical property match score": round(top[0], 1),
|
||||||
|
"Historical property address score": top[1],
|
||||||
|
"Historical property match margin": round(margin, 1),
|
||||||
|
"Historical property match field": top[3],
|
||||||
|
"Historical property match status": "matched",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _match_properties(listings: pl.DataFrame, candidates: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
schema = {
|
||||||
|
"_listing_idx": pl.UInt32,
|
||||||
|
"_property_row": pl.UInt32,
|
||||||
|
"Historical property match score": pl.Float32,
|
||||||
|
"Historical property address score": pl.Int32,
|
||||||
|
"Historical property match margin": pl.Float32,
|
||||||
|
"Historical property match field": pl.Utf8,
|
||||||
|
"Historical property match status": pl.Utf8,
|
||||||
|
}
|
||||||
|
if candidates.is_empty():
|
||||||
|
return pl.DataFrame(schema=schema)
|
||||||
|
|
||||||
|
buckets = _property_candidates_by_postcode(candidates)
|
||||||
|
matches = []
|
||||||
|
for listing in tqdm(
|
||||||
|
listings.iter_rows(named=True),
|
||||||
|
total=listings.height,
|
||||||
|
desc="Matching historical properties",
|
||||||
|
):
|
||||||
|
postcode = listing.get("_listing_match_postcode")
|
||||||
|
if not postcode:
|
||||||
|
continue
|
||||||
|
match = _best_property_candidate(listing, buckets.get(postcode, []))
|
||||||
|
if match is not None:
|
||||||
|
matches.append(match)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return pl.DataFrame(schema=schema)
|
||||||
|
return pl.DataFrame(matches, schema=schema)
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_columns(df: pl.DataFrame, columns: list[str], prefix: str) -> pl.DataFrame:
|
||||||
|
rename = {column: f"{prefix}{column}" for column in columns if column in df.columns}
|
||||||
|
return df.rename(rename)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_prefixed_columns(
|
||||||
|
df: pl.DataFrame, columns: list[str], prefix: str
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
missing_exprs = [
|
||||||
|
pl.lit(None, dtype=COLUMN_DTYPES.get(column, pl.Utf8)).alias(
|
||||||
|
f"{prefix}{column}"
|
||||||
|
)
|
||||||
|
for column in columns
|
||||||
|
if f"{prefix}{column}" not in df.columns
|
||||||
|
]
|
||||||
|
if not missing_exprs:
|
||||||
|
return df
|
||||||
|
return df.with_columns(missing_exprs)
|
||||||
|
|
||||||
|
|
||||||
|
def _property_match_frame(
|
||||||
|
matches: pl.DataFrame, candidates: pl.DataFrame
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
if matches.is_empty():
|
||||||
|
return matches
|
||||||
|
selected_columns = [
|
||||||
|
"_property_row",
|
||||||
|
*[
|
||||||
|
column
|
||||||
|
for column in PROPERTY_CANDIDATE_COLUMNS
|
||||||
|
if column in candidates.columns
|
||||||
|
],
|
||||||
|
]
|
||||||
|
matched = matches.join(
|
||||||
|
candidates.select(selected_columns), on="_property_row", how="left"
|
||||||
|
)
|
||||||
|
return _prefix_columns(
|
||||||
|
matched,
|
||||||
|
[column for column in PROPERTY_CANDIDATE_COLUMNS if column in matched.columns],
|
||||||
|
"_property_",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_epc_property_type_expr() -> pl.Expr:
|
||||||
|
bad_built_form = pl.col("built_form").is_null() | pl.col("built_form").is_in(
|
||||||
|
["NO DATA!", "Not Recorded"]
|
||||||
|
)
|
||||||
|
has_epc = pl.col("epc_property_type").is_not_null()
|
||||||
|
is_house = pl.col("epc_property_type") == "House"
|
||||||
|
return (
|
||||||
|
pl.when(has_epc & is_house & ~bad_built_form)
|
||||||
|
.then(pl.col("built_form"))
|
||||||
|
.when(has_epc)
|
||||||
|
.then(pl.col("epc_property_type"))
|
||||||
|
.otherwise(None)
|
||||||
|
.replace(
|
||||||
|
{
|
||||||
|
"Flat": "Flats/Maisonettes",
|
||||||
|
"Maisonette": "Flats/Maisonettes",
|
||||||
|
"End-Terrace": "Terraced",
|
||||||
|
"Mid-Terrace": "Terraced",
|
||||||
|
"Enclosed End-Terrace": "Terraced",
|
||||||
|
"Enclosed Mid-Terrace": "Terraced",
|
||||||
|
"Bungalow": "Other",
|
||||||
|
"Park home": "Other",
|
||||||
|
"House": "Other",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _construction_year_expr(column: str = "construction_age_band") -> pl.Expr:
|
||||||
|
return (
|
||||||
|
pl.col(column)
|
||||||
|
.cast(pl.Utf8)
|
||||||
|
.str.replace("England and Wales: ", "")
|
||||||
|
.str.replace(" onwards", "")
|
||||||
|
.str.extract(r"(\d{4})", 1)
|
||||||
|
.cast(pl.UInt16, strict=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fractional_year_expr(column: str) -> pl.Expr:
|
||||||
|
return (
|
||||||
|
pl.col(column).dt.year().cast(pl.Float32)
|
||||||
|
+ (pl.col(column).dt.month().cast(pl.Float32) - 1.0) / 12.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_epc_candidates(
|
||||||
|
epc_path: Path, listing_postcodes: list[str], temp_dir: Path
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
epc_base = _scan_epc_certificates(epc_path, temp_dir).with_columns(
|
||||||
|
normalize_address_key(pl.col("epc_address")).alias("_epc_match_address"),
|
||||||
|
normalize_postcode_key(pl.col("epc_postcode")).alias("_epc_match_postcode"),
|
||||||
|
)
|
||||||
|
|
||||||
|
epc = (
|
||||||
|
epc_base.filter(pl.col("_epc_match_postcode").is_in(listing_postcodes))
|
||||||
|
.sort("inspection_date", descending=True)
|
||||||
|
.group_by("_epc_match_address", "_epc_match_postcode")
|
||||||
|
.first()
|
||||||
|
.with_columns(
|
||||||
|
_canonical_epc_property_type_expr().alias("_epc_canonical_property_type"),
|
||||||
|
_construction_year_expr().alias("Construction year"),
|
||||||
|
pl.when(pl.col("current_energy_rating").is_in(EPC_RATING_VALUES))
|
||||||
|
.then(pl.col("current_energy_rating"))
|
||||||
|
.otherwise(None)
|
||||||
|
.alias("Current energy rating"),
|
||||||
|
pl.when(pl.col("potential_energy_rating").is_in(EPC_RATING_VALUES))
|
||||||
|
.then(pl.col("potential_energy_rating"))
|
||||||
|
.otherwise(None)
|
||||||
|
.alias("Potential energy rating"),
|
||||||
|
pl.col("total_floor_area").alias("Total floor area (sqm)"),
|
||||||
|
pl.col("number_habitable_rooms").alias("Number of bedrooms & living rooms"),
|
||||||
|
pl.col("floor_height").alias("Interior height (m)"),
|
||||||
|
pl.col("epc_address").alias("Address per EPC"),
|
||||||
|
)
|
||||||
|
.drop("tenure", strict=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
social_tenure = (
|
||||||
|
epc_base.filter(pl.col("_epc_match_postcode").is_in(listing_postcodes))
|
||||||
|
.filter(pl.col("tenure").str.to_lowercase().str.contains("social"))
|
||||||
|
.select("_epc_match_address", "_epc_match_postcode")
|
||||||
|
.unique()
|
||||||
|
.with_columns(pl.lit("Yes").alias("Former council house"))
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
epc.join(
|
||||||
|
social_tenure,
|
||||||
|
on=["_epc_match_address", "_epc_match_postcode"],
|
||||||
|
how="left",
|
||||||
|
)
|
||||||
|
.with_columns(pl.col("Former council house").fill_null("No"))
|
||||||
|
.filter(pl.col("_epc_match_address").is_not_null())
|
||||||
|
.with_row_index("_epc_row")
|
||||||
|
.select(
|
||||||
|
"_epc_row",
|
||||||
|
"_epc_match_address",
|
||||||
|
"_epc_match_postcode",
|
||||||
|
"_epc_canonical_property_type",
|
||||||
|
*EPC_ENRICHMENT_COLUMNS,
|
||||||
|
)
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _epc_candidates_by_postcode(candidates: pl.DataFrame) -> dict[str, list[dict]]:
|
||||||
|
buckets: dict[str, list[dict]] = {}
|
||||||
|
for row in candidates.iter_rows(named=True):
|
||||||
|
postcode = row.get("_epc_match_postcode")
|
||||||
|
if postcode:
|
||||||
|
buckets.setdefault(postcode, []).append(row)
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def _best_epc_candidate(listing: dict, candidates: list[dict]) -> dict | None:
|
||||||
|
query = listing.get("_listing_match_address")
|
||||||
|
if not query:
|
||||||
|
return None
|
||||||
|
|
||||||
|
listing_has_numbers = _has_number(query)
|
||||||
|
scored: list[tuple[float, int, dict]] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
address = candidate.get("_epc_match_address")
|
||||||
|
if listing_has_numbers and not _numbers_compatible(query, address):
|
||||||
|
continue
|
||||||
|
base_score = _address_score(query, address)
|
||||||
|
if base_score == 0:
|
||||||
|
continue
|
||||||
|
score = float(base_score)
|
||||||
|
score += _enum_bonus(
|
||||||
|
listing.get("Property type"),
|
||||||
|
candidate.get("_epc_canonical_property_type"),
|
||||||
|
exact=6.0,
|
||||||
|
mismatch=-6.0,
|
||||||
|
)
|
||||||
|
score += _ratio_bonus(
|
||||||
|
listing.get("Total floor area (sqm)"),
|
||||||
|
candidate.get("Total floor area (sqm)"),
|
||||||
|
pct=0.12,
|
||||||
|
cap=8.0,
|
||||||
|
)
|
||||||
|
score += _rooms_bonus(
|
||||||
|
listing.get("Number of bedrooms & living rooms"),
|
||||||
|
candidate.get("Number of bedrooms & living rooms"),
|
||||||
|
)
|
||||||
|
scored.append((score, base_score, candidate))
|
||||||
|
|
||||||
|
if not scored:
|
||||||
|
return None
|
||||||
|
scored.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
top = scored[0]
|
||||||
|
runner_up = scored[1][0] if len(scored) > 1 else None
|
||||||
|
margin = top[0] - runner_up if runner_up is not None else top[0]
|
||||||
|
threshold = (
|
||||||
|
EPC_MATCH_MIN_SCORE_WITH_NUMBERS
|
||||||
|
if listing_has_numbers
|
||||||
|
else EPC_MATCH_MIN_SCORE_WITHOUT_NUMBERS
|
||||||
|
)
|
||||||
|
if top[0] < threshold or margin < EPC_MATCH_MIN_MARGIN:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"_listing_idx": listing["_listing_idx"],
|
||||||
|
"_epc_row": top[2]["_epc_row"],
|
||||||
|
"EPC match score": round(top[0], 1),
|
||||||
|
"EPC address score": top[1],
|
||||||
|
"EPC match margin": round(margin, 1),
|
||||||
|
"EPC match status": "matched",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _match_epc(listings: pl.DataFrame, candidates: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
schema = {
|
||||||
|
"_listing_idx": pl.UInt32,
|
||||||
|
"_epc_row": pl.UInt32,
|
||||||
|
"EPC match score": pl.Float32,
|
||||||
|
"EPC address score": pl.Int32,
|
||||||
|
"EPC match margin": pl.Float32,
|
||||||
|
"EPC match status": pl.Utf8,
|
||||||
|
}
|
||||||
|
if candidates.is_empty():
|
||||||
|
return pl.DataFrame(schema=schema)
|
||||||
|
|
||||||
|
buckets = _epc_candidates_by_postcode(candidates)
|
||||||
|
matches = []
|
||||||
|
for listing in tqdm(
|
||||||
|
listings.iter_rows(named=True),
|
||||||
|
total=listings.height,
|
||||||
|
desc="Matching EPC certificates",
|
||||||
|
):
|
||||||
|
postcode = listing.get("_listing_match_postcode")
|
||||||
|
if not postcode:
|
||||||
|
continue
|
||||||
|
match = _best_epc_candidate(listing, buckets.get(postcode, []))
|
||||||
|
if match is not None:
|
||||||
|
matches.append(match)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return pl.DataFrame(schema=schema)
|
||||||
|
return pl.DataFrame(matches, schema=schema)
|
||||||
|
|
||||||
|
|
||||||
|
def _epc_match_frame(matches: pl.DataFrame, candidates: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
if matches.is_empty():
|
||||||
|
return matches
|
||||||
|
matched = matches.join(
|
||||||
|
candidates.select("_epc_row", *EPC_ENRICHMENT_COLUMNS),
|
||||||
|
on="_epc_row",
|
||||||
|
how="left",
|
||||||
|
)
|
||||||
|
return _prefix_columns(
|
||||||
|
matched,
|
||||||
|
[column for column in EPC_ENRICHMENT_COLUMNS if column in matched.columns],
|
||||||
|
"_epc_",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _join_postcode_features(
|
||||||
|
listings: pl.DataFrame, postcode_features_path: Path
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
postcode_features = pl.scan_parquet(postcode_features_path).collect(
|
||||||
|
engine="streaming"
|
||||||
|
)
|
||||||
|
return listings.join(
|
||||||
|
postcode_features, on="Postcode", how="left", suffix="_postcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce_feature_columns(df: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
with_columns: list[pl.Expr] = [
|
||||||
|
pl.lit(ENRICHMENT_VERSION, dtype=pl.UInt16).alias(
|
||||||
|
"Actual listing enrichment version"
|
||||||
|
),
|
||||||
|
_coalesce_non_empty(
|
||||||
|
"_epc_Address per EPC",
|
||||||
|
"_property_Address per EPC",
|
||||||
|
).alias("Address per EPC"),
|
||||||
|
pl.when(pl.col("Property type").is_in(PROPERTY_TYPE_VALUES))
|
||||||
|
.then(pl.col("Property type"))
|
||||||
|
.otherwise(pl.col("_property_Property type"))
|
||||||
|
.alias("Property type"),
|
||||||
|
pl.when(pl.col("Leasehold/Freehold").is_in(TENURE_VALUES))
|
||||||
|
.then(pl.col("Leasehold/Freehold"))
|
||||||
|
.otherwise(pl.col("_property_Leasehold/Freehold"))
|
||||||
|
.alias("Leasehold/Freehold"),
|
||||||
|
pl.coalesce(
|
||||||
|
_valid_number_expr("Total floor area (sqm)"),
|
||||||
|
_valid_number_expr("_epc_Total floor area (sqm)"),
|
||||||
|
_valid_number_expr("_property_Total floor area (sqm)"),
|
||||||
|
).alias("Total floor area (sqm)"),
|
||||||
|
pl.when(pl.col("Number of bedrooms & living rooms") > 0)
|
||||||
|
.then(pl.col("Number of bedrooms & living rooms"))
|
||||||
|
.otherwise(
|
||||||
|
pl.coalesce(
|
||||||
|
pl.col("_epc_Number of bedrooms & living rooms"),
|
||||||
|
pl.col("_property_Number of bedrooms & living rooms"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.cast(pl.Int32, strict=False)
|
||||||
|
.alias("Number of bedrooms & living rooms"),
|
||||||
|
pl.col("Asking price").alias("Estimated current price"),
|
||||||
|
pl.col("Asking price").alias("Last known price"),
|
||||||
|
_coalesce_non_empty(
|
||||||
|
"_epc_Current energy rating",
|
||||||
|
"_property_Current energy rating",
|
||||||
|
).alias("Current energy rating"),
|
||||||
|
_coalesce_non_empty(
|
||||||
|
"_epc_Potential energy rating",
|
||||||
|
"_property_Potential energy rating",
|
||||||
|
).alias("Potential energy rating"),
|
||||||
|
pl.coalesce(
|
||||||
|
_valid_number_expr("_epc_Interior height (m)"),
|
||||||
|
_valid_number_expr("_property_Interior height (m)"),
|
||||||
|
).alias("Interior height (m)"),
|
||||||
|
pl.coalesce(
|
||||||
|
pl.col("_epc_Construction year"),
|
||||||
|
pl.col("_property_Construction year"),
|
||||||
|
)
|
||||||
|
.cast(pl.UInt16, strict=False)
|
||||||
|
.alias("Construction year"),
|
||||||
|
_coalesce_non_empty(
|
||||||
|
"_epc_Former council house",
|
||||||
|
"_property_Former council house",
|
||||||
|
)
|
||||||
|
.fill_null("No")
|
||||||
|
.alias("Former council house"),
|
||||||
|
pl.col("_property_Is construction date approximate").alias(
|
||||||
|
"Is construction date approximate"
|
||||||
|
),
|
||||||
|
pl.col("_property_Listed building").fill_null("No").alias("Listed building"),
|
||||||
|
pl.col("_property_Estimated monthly rent").alias("Estimated monthly rent"),
|
||||||
|
pl.col("_property_Street tree density percentile").alias(
|
||||||
|
"Street tree density percentile"
|
||||||
|
),
|
||||||
|
_fractional_year_expr("_property_Date of last transaction").alias(
|
||||||
|
"Date of last transaction"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
df = df.with_columns(with_columns)
|
||||||
|
df = df.with_columns(
|
||||||
|
pl.when(
|
||||||
|
pl.col("Asking price").is_not_null()
|
||||||
|
& pl.col("Total floor area (sqm)").is_not_null()
|
||||||
|
& (pl.col("Total floor area (sqm)") > 0)
|
||||||
|
)
|
||||||
|
.then((pl.col("Asking price") / pl.col("Total floor area (sqm)")).round(0))
|
||||||
|
.otherwise(None)
|
||||||
|
.cast(pl.Int32, strict=False)
|
||||||
|
.alias("Asking price per sqm"),
|
||||||
|
).with_columns(
|
||||||
|
pl.col("Asking price per sqm").alias("Est. price per sqm"),
|
||||||
|
pl.col("Asking price per sqm").alias("Price per sqm"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_internal_columns(df: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
internal_prefixes = ("_property_", "_epc_")
|
||||||
|
internal_exact = {
|
||||||
|
"_listing_idx",
|
||||||
|
"_listing_match_address",
|
||||||
|
"_listing_match_postcode",
|
||||||
|
"_original_postcode",
|
||||||
|
"_property_row",
|
||||||
|
"_epc_row",
|
||||||
|
"lat_postcode",
|
||||||
|
"lon_postcode",
|
||||||
|
}
|
||||||
|
drop_cols = [
|
||||||
|
column
|
||||||
|
for column in df.columns
|
||||||
|
if column in internal_exact or column.startswith(internal_prefixes)
|
||||||
|
]
|
||||||
|
return df.drop(drop_cols, strict=False)
|
||||||
|
|
||||||
|
|
||||||
|
def build_enriched_actual_listings(
|
||||||
|
listings_path: Path,
|
||||||
|
properties_path: Path,
|
||||||
|
postcode_features_path: Path,
|
||||||
|
arcgis_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
*,
|
||||||
|
epc_path: Path | None = None,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
print(f"Loading listings from {listings_path}...")
|
||||||
|
listings = _read_listings(listings_path, arcgis_path)
|
||||||
|
listing_postcodes = (
|
||||||
|
listings.select("_listing_match_postcode")
|
||||||
|
.drop_nulls()
|
||||||
|
.unique()
|
||||||
|
.to_series()
|
||||||
|
.to_list()
|
||||||
|
)
|
||||||
|
print(f"Listings: {listings.height}; unique postcodes: {len(listing_postcodes)}")
|
||||||
|
|
||||||
|
print(f"Loading property candidates from {properties_path}...")
|
||||||
|
property_candidates = _load_property_candidates(properties_path, listing_postcodes)
|
||||||
|
print(f"Property candidates: {property_candidates.height}")
|
||||||
|
property_matches = _match_properties(listings, property_candidates)
|
||||||
|
print(f"Historical property matches: {property_matches.height}")
|
||||||
|
property_match_frame = _property_match_frame(property_matches, property_candidates)
|
||||||
|
|
||||||
|
enriched = _join_postcode_features(listings, postcode_features_path)
|
||||||
|
if not property_match_frame.is_empty():
|
||||||
|
enriched = enriched.join(property_match_frame, on="_listing_idx", how="left")
|
||||||
|
else:
|
||||||
|
enriched = enriched.with_columns(
|
||||||
|
pl.lit(None, dtype=pl.Utf8).alias("Historical property match status")
|
||||||
|
)
|
||||||
|
|
||||||
|
if epc_path is not None:
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="actual_listing_epc_", dir=local_tmp_dir()
|
||||||
|
) as tmpdir:
|
||||||
|
print(f"Loading EPC candidates from {epc_path}...")
|
||||||
|
epc_candidates = _load_epc_candidates(
|
||||||
|
epc_path, listing_postcodes, Path(tmpdir)
|
||||||
|
)
|
||||||
|
print(f"EPC candidates: {epc_candidates.height}")
|
||||||
|
epc_matches = _match_epc(listings, epc_candidates)
|
||||||
|
print(f"EPC matches: {epc_matches.height}")
|
||||||
|
epc_match_frame = _epc_match_frame(epc_matches, epc_candidates)
|
||||||
|
if not epc_match_frame.is_empty():
|
||||||
|
enriched = enriched.join(epc_match_frame, on="_listing_idx", how="left")
|
||||||
|
else:
|
||||||
|
enriched = enriched.with_columns(
|
||||||
|
pl.lit(None, dtype=pl.Utf8).alias("EPC match status")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
enriched = enriched.with_columns(
|
||||||
|
pl.lit(None, dtype=pl.Utf8).alias("EPC match status")
|
||||||
|
)
|
||||||
|
|
||||||
|
enriched = _ensure_prefixed_columns(
|
||||||
|
enriched, PROPERTY_CANDIDATE_COLUMNS, "_property_"
|
||||||
|
)
|
||||||
|
enriched = _ensure_prefixed_columns(enriched, EPC_ENRICHMENT_COLUMNS, "_epc_")
|
||||||
|
enriched = _coalesce_feature_columns(enriched)
|
||||||
|
enriched = _drop_internal_columns(enriched)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
enriched.write_parquet(output_path)
|
||||||
|
size_mb = output_path.stat().st_size / (1024 * 1024)
|
||||||
|
print(
|
||||||
|
f"Wrote {enriched.height} enriched listings to {output_path} ({size_mb:.1f} MB)"
|
||||||
|
)
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build a pre-enriched actual-listings parquet for the server"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--listings",
|
||||||
|
type=Path,
|
||||||
|
default=Path("finder/data/online_listings_buy.parquet"),
|
||||||
|
help="Input scraped listings parquet",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--properties",
|
||||||
|
type=Path,
|
||||||
|
default=Path("property-data/properties.parquet"),
|
||||||
|
help="Historical properties parquet",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--postcode-features",
|
||||||
|
type=Path,
|
||||||
|
default=Path("property-data/postcode.parquet"),
|
||||||
|
help="Postcode feature parquet",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--arcgis",
|
||||||
|
type=Path,
|
||||||
|
default=Path("property-data/arcgis_data.parquet"),
|
||||||
|
help="ArcGIS/NSPL postcode parquet used for terminated-postcode remapping",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--epc",
|
||||||
|
type=Path,
|
||||||
|
default=Path("manual-data/domestic-csv.zip"),
|
||||||
|
help="Optional EPC certificates CSV/zip for direct listing-to-EPC fuzzy matching",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-epc",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip direct EPC matching even when --epc exists",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("finder/data/online_listings_buy_enriched.parquet"),
|
||||||
|
help="Output enriched listings parquet",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
epc_path = None if args.no_epc else args.epc
|
||||||
|
if epc_path is not None and not epc_path.exists():
|
||||||
|
print(
|
||||||
|
f"EPC source not found at {epc_path}; continuing without direct EPC matching"
|
||||||
|
)
|
||||||
|
epc_path = None
|
||||||
|
|
||||||
|
build_enriched_actual_listings(
|
||||||
|
listings_path=args.listings,
|
||||||
|
properties_path=args.properties,
|
||||||
|
postcode_features_path=args.postcode_features,
|
||||||
|
arcgis_path=args.arcgis,
|
||||||
|
epc_path=epc_path,
|
||||||
|
output_path=args.output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -22,6 +22,7 @@ LISTED_BUILDING_FEATURE = "Listed building"
|
||||||
LISTED_BUILDING_MATCH_RADIUS_M = 250.0
|
LISTED_BUILDING_MATCH_RADIUS_M = 250.0
|
||||||
LISTED_BUILDING_NEAREST_POSTCODES = 3
|
LISTED_BUILDING_NEAREST_POSTCODES = 3
|
||||||
LISTED_BUILDING_MIN_MATCH_SCORE = 95
|
LISTED_BUILDING_MIN_MATCH_SCORE = 95
|
||||||
|
_UNPUBLISHED_CONSERVATION_AREA_PREFIX = "no data available for publication"
|
||||||
|
|
||||||
_IOD_PERCENTILE_COLUMNS = [
|
_IOD_PERCENTILE_COLUMNS = [
|
||||||
"Education, Skills and Training Score",
|
"Education, Skills and Training Score",
|
||||||
|
|
@ -429,19 +430,38 @@ def _normalise_crs(crs: object | None) -> str:
|
||||||
return str(crs) if crs else "EPSG:4326"
|
return str(crs) if crs else "EPSG:4326"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_unpublished_conservation_area_record(name: object) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(name, str)
|
||||||
|
and name.strip().casefold().startswith(_UNPUBLISHED_CONSERVATION_AREA_PREFIX)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_conservation_area_geometries(
|
def _load_conservation_area_geometries(
|
||||||
conservation_areas_path: Path,
|
conservation_areas_path: Path,
|
||||||
) -> tuple[list[BaseGeometry], str]:
|
) -> tuple[list[BaseGeometry], str]:
|
||||||
metadata, table = pyogrio.read_arrow(conservation_areas_path, columns=[])
|
metadata, table = pyogrio.read_arrow(conservation_areas_path, columns=["NAME"])
|
||||||
geometry_name = metadata.get("geometry_name") or table.column_names[-1]
|
geometry_name = metadata.get("geometry_name") or table.column_names[-1]
|
||||||
|
names = table["NAME"].combine_chunks().to_pylist()
|
||||||
geometries = []
|
geometries = []
|
||||||
for geom in from_wkb(table[geometry_name].combine_chunks().to_pylist()):
|
skipped_unpublished = 0
|
||||||
if geom is not None and not geom.is_empty:
|
for name, geom in zip(
|
||||||
|
names, from_wkb(table[geometry_name].combine_chunks().to_pylist()), strict=True
|
||||||
|
):
|
||||||
|
if _is_unpublished_conservation_area_record(name):
|
||||||
|
skipped_unpublished += 1
|
||||||
|
elif geom is not None and not geom.is_empty:
|
||||||
geometries.append(geom)
|
geometries.append(geom)
|
||||||
if not geometries:
|
if not geometries:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{conservation_areas_path} does not contain any usable polygon geometries"
|
f"{conservation_areas_path} does not contain any usable polygon geometries"
|
||||||
)
|
)
|
||||||
|
if skipped_unpublished:
|
||||||
|
print(
|
||||||
|
"Skipped "
|
||||||
|
f"{skipped_unpublished} Historic England unpublished conservation-area "
|
||||||
|
"placeholder polygons"
|
||||||
|
)
|
||||||
return geometries, _normalise_crs(metadata.get("crs"))
|
return geometries, _normalise_crs(metadata.get("crs"))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ GREENSPACE_PARK_FUNCTIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
GROCERY_DYNAMIC_FILTER_MIN_POIS = 100
|
GROCERY_DYNAMIC_FILTER_MIN_POIS = 100
|
||||||
DYNAMIC_FILTER_ALL_GROUPS = {"Public Transport", "Leisure"}
|
DYNAMIC_FILTER_ALL_GROUPS = {"Public Transport", "Leisure", "Health"}
|
||||||
DYNAMIC_FILTER_COUNT_THRESHOLD_GROUPS = {"Groceries"}
|
DYNAMIC_FILTER_COUNT_THRESHOLD_GROUPS = {"Groceries"}
|
||||||
DYNAMIC_FILTER_EXCLUDED_CATEGORIES = {"Park"}
|
DYNAMIC_FILTER_EXCLUDED_CATEGORIES = {"Park"}
|
||||||
|
|
||||||
|
|
|
||||||
143
pipeline/transform/test_enrich_actual_listings.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.transform.enrich_actual_listings import build_enriched_actual_listings
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_enriched_actual_listings_joins_postcode_and_property_features(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
listings_path = tmp_path / "listings.parquet"
|
||||||
|
properties_path = tmp_path / "properties.parquet"
|
||||||
|
postcode_path = tmp_path / "postcode.parquet"
|
||||||
|
arcgis_path = tmp_path / "arcgis.parquet"
|
||||||
|
output_path = tmp_path / "online_listings_buy_enriched.parquet"
|
||||||
|
|
||||||
|
pl.DataFrame(
|
||||||
|
{
|
||||||
|
"Bedrooms": [2],
|
||||||
|
"Bathrooms": [1],
|
||||||
|
"Number of bedrooms & living rooms": [3],
|
||||||
|
"lon": [-0.1],
|
||||||
|
"lat": [51.5],
|
||||||
|
"Postcode": ["AA1 1AB"],
|
||||||
|
"Address per Property Register": ["1 High Street"],
|
||||||
|
"Leasehold/Freehold": [None],
|
||||||
|
"Property type": ["Terraced"],
|
||||||
|
"Property sub-type": ["Terraced"],
|
||||||
|
"Price qualifier": [""],
|
||||||
|
"Total floor area (sqm)": [None],
|
||||||
|
"Listing URL": ["https://example.test/listing"],
|
||||||
|
"Listing features": [["Garden"]],
|
||||||
|
"Listing date": [None],
|
||||||
|
"Listing status": ["For sale"],
|
||||||
|
"Asking price": [300_000],
|
||||||
|
"Asking price per sqm": [None],
|
||||||
|
},
|
||||||
|
schema={
|
||||||
|
"Bedrooms": pl.Int32,
|
||||||
|
"Bathrooms": pl.Int32,
|
||||||
|
"Number of bedrooms & living rooms": pl.Int32,
|
||||||
|
"lon": pl.Float64,
|
||||||
|
"lat": pl.Float64,
|
||||||
|
"Postcode": pl.Utf8,
|
||||||
|
"Address per Property Register": pl.Utf8,
|
||||||
|
"Leasehold/Freehold": pl.Utf8,
|
||||||
|
"Property type": pl.Utf8,
|
||||||
|
"Property sub-type": pl.Utf8,
|
||||||
|
"Price qualifier": pl.Utf8,
|
||||||
|
"Total floor area (sqm)": pl.Float64,
|
||||||
|
"Listing URL": pl.Utf8,
|
||||||
|
"Listing features": pl.List(pl.Utf8),
|
||||||
|
"Listing date": pl.Datetime("us"),
|
||||||
|
"Listing status": pl.Utf8,
|
||||||
|
"Asking price": pl.Int64,
|
||||||
|
"Asking price per sqm": pl.Int32,
|
||||||
|
},
|
||||||
|
).write_parquet(listings_path)
|
||||||
|
|
||||||
|
pl.DataFrame(
|
||||||
|
{
|
||||||
|
"Address per Property Register": ["1 HIGH STREET"],
|
||||||
|
"Postcode": ["AA1 1AA"],
|
||||||
|
"Leasehold/Freehold": ["Freehold"],
|
||||||
|
"Address per EPC": ["1 High Street"],
|
||||||
|
"Current energy rating": ["C"],
|
||||||
|
"Potential energy rating": ["B"],
|
||||||
|
"Total floor area (sqm)": [80.0],
|
||||||
|
"Number of bedrooms & living rooms": [4],
|
||||||
|
"Interior height (m)": [2.4],
|
||||||
|
"Construction year": [1935],
|
||||||
|
"Former council house": ["No"],
|
||||||
|
"Listed building": ["No"],
|
||||||
|
"Estimated monthly rent": [1200.0],
|
||||||
|
"Street tree density percentile": [75.0],
|
||||||
|
"Property type": ["Terraced"],
|
||||||
|
"Estimated current price": [310_000.0],
|
||||||
|
},
|
||||||
|
schema={
|
||||||
|
"Address per Property Register": pl.Utf8,
|
||||||
|
"Postcode": pl.Utf8,
|
||||||
|
"Leasehold/Freehold": pl.Utf8,
|
||||||
|
"Address per EPC": pl.Utf8,
|
||||||
|
"Current energy rating": pl.Utf8,
|
||||||
|
"Potential energy rating": pl.Utf8,
|
||||||
|
"Total floor area (sqm)": pl.Float64,
|
||||||
|
"Number of bedrooms & living rooms": pl.Int32,
|
||||||
|
"Interior height (m)": pl.Float64,
|
||||||
|
"Construction year": pl.UInt16,
|
||||||
|
"Former council house": pl.Utf8,
|
||||||
|
"Listed building": pl.Utf8,
|
||||||
|
"Estimated monthly rent": pl.Float32,
|
||||||
|
"Street tree density percentile": pl.Float32,
|
||||||
|
"Property type": pl.Utf8,
|
||||||
|
"Estimated current price": pl.Float64,
|
||||||
|
},
|
||||||
|
).write_parquet(properties_path)
|
||||||
|
|
||||||
|
pl.DataFrame(
|
||||||
|
{
|
||||||
|
"Postcode": ["AA1 1AA"],
|
||||||
|
"Income Score": [82.5],
|
||||||
|
"Within conservation area": ["Yes"],
|
||||||
|
}
|
||||||
|
).write_parquet(postcode_path)
|
||||||
|
|
||||||
|
pl.DataFrame(
|
||||||
|
{
|
||||||
|
"pcds": ["AA1 1AA", "AA1 1AB"],
|
||||||
|
"ctry25cd": ["E92000001", "E92000001"],
|
||||||
|
"doterm": [None, "202401"],
|
||||||
|
"east1m": [100.0, 105.0],
|
||||||
|
"north1m": [100.0, 105.0],
|
||||||
|
},
|
||||||
|
schema={
|
||||||
|
"pcds": pl.Utf8,
|
||||||
|
"ctry25cd": pl.Utf8,
|
||||||
|
"doterm": pl.Utf8,
|
||||||
|
"east1m": pl.Float64,
|
||||||
|
"north1m": pl.Float64,
|
||||||
|
},
|
||||||
|
).write_parquet(arcgis_path)
|
||||||
|
|
||||||
|
result = build_enriched_actual_listings(
|
||||||
|
listings_path=listings_path,
|
||||||
|
properties_path=properties_path,
|
||||||
|
postcode_features_path=postcode_path,
|
||||||
|
arcgis_path=arcgis_path,
|
||||||
|
output_path=output_path,
|
||||||
|
epc_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = result.row(0, named=True)
|
||||||
|
assert output_path.exists()
|
||||||
|
assert row["Postcode"] == "AA1 1AA"
|
||||||
|
assert row["Historical property match status"] == "matched"
|
||||||
|
assert row["Income Score"] == 82.5
|
||||||
|
assert row["Within conservation area"] == "Yes"
|
||||||
|
assert row["Leasehold/Freehold"] == "Freehold"
|
||||||
|
assert row["Total floor area (sqm)"] == 80.0
|
||||||
|
assert row["Asking price per sqm"] == 3750
|
||||||
|
assert row["Estimated current price"] == 300_000
|
||||||
|
assert row["Current energy rating"] == "C"
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
import pyarrow as pa
|
||||||
import pytest
|
import pytest
|
||||||
from shapely import box
|
from shapely import box, to_wkb
|
||||||
|
|
||||||
from pipeline.transform.merge import (
|
from pipeline.transform.merge import (
|
||||||
_AREA_COLUMNS,
|
_AREA_COLUMNS,
|
||||||
CONSERVATION_AREA_FEATURE,
|
CONSERVATION_AREA_FEATURE,
|
||||||
LISTED_BUILDING_FEATURE,
|
LISTED_BUILDING_FEATURE,
|
||||||
TREE_DENSITY_FEATURE,
|
TREE_DENSITY_FEATURE,
|
||||||
|
_is_unpublished_conservation_area_record,
|
||||||
_is_dynamic_poi_metric_column,
|
_is_dynamic_poi_metric_column,
|
||||||
_less_deprived_percentile_expr,
|
_less_deprived_percentile_expr,
|
||||||
|
_load_conservation_area_geometries,
|
||||||
_matched_listed_building_flags,
|
_matched_listed_building_flags,
|
||||||
_postcode_conservation_area_flags,
|
_postcode_conservation_area_flags,
|
||||||
_postcode_listed_building_candidates,
|
_postcode_listed_building_candidates,
|
||||||
|
|
@ -82,6 +85,45 @@ def test_postcode_conservation_area_flags_marks_point_membership() -> None:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unpublished_conservation_area_records_are_identified() -> None:
|
||||||
|
assert _is_unpublished_conservation_area_record(
|
||||||
|
"No data available for publication by HE"
|
||||||
|
)
|
||||||
|
assert not _is_unpublished_conservation_area_record("Bloomsbury")
|
||||||
|
assert not _is_unpublished_conservation_area_record(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_conservation_area_geometries_skips_unpublished_placeholders(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
real_area = box(0, 0, 1, 1)
|
||||||
|
placeholder_area = box(-100, -100, 100, 100)
|
||||||
|
|
||||||
|
def fake_read_arrow(path, columns):
|
||||||
|
assert path == tmp_path / "conservation_areas.gpkg"
|
||||||
|
assert columns == ["NAME"]
|
||||||
|
table = pa.table(
|
||||||
|
{
|
||||||
|
"NAME": [
|
||||||
|
"Central Village",
|
||||||
|
"No data available for publication by HE",
|
||||||
|
],
|
||||||
|
"SHAPE": to_wkb([real_area, placeholder_area]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"geometry_name": "SHAPE", "crs": "EPSG:4326"}, table
|
||||||
|
|
||||||
|
monkeypatch.setattr("pipeline.transform.merge.pyogrio.read_arrow", fake_read_arrow)
|
||||||
|
|
||||||
|
geometries, crs = _load_conservation_area_geometries(
|
||||||
|
tmp_path / "conservation_areas.gpkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert crs == "EPSG:4326"
|
||||||
|
assert geometries == [real_area]
|
||||||
|
|
||||||
|
|
||||||
def test_postcode_listed_building_candidates_uses_nearby_postcodes() -> None:
|
def test_postcode_listed_building_candidates_uses_nearby_postcodes() -> None:
|
||||||
listed_points = pl.DataFrame(
|
listed_points = pl.DataFrame(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ def test_dynamic_poi_groups_include_requested_categories_only() -> None:
|
||||||
assert set(display_names.values()) == {
|
assert set(display_names.values()) == {
|
||||||
"Bus stop",
|
"Bus stop",
|
||||||
"Café",
|
"Café",
|
||||||
|
"Pharmacy",
|
||||||
"Rail station",
|
"Rail station",
|
||||||
"Restaurant",
|
"Restaurant",
|
||||||
"Tesco",
|
"Tesco",
|
||||||
|
|
@ -44,7 +45,6 @@ def test_dynamic_poi_groups_include_requested_categories_only() -> None:
|
||||||
assert "poi_waitrose" not in groups
|
assert "poi_waitrose" not in groups
|
||||||
assert "poi_park" not in groups
|
assert "poi_park" not in groups
|
||||||
assert "poi_school" not in groups
|
assert "poi_school" not in groups
|
||||||
assert "poi_pharmacy" not in groups
|
|
||||||
|
|
||||||
|
|
||||||
def test_dynamic_poi_metric_renames_support_park_count_options() -> None:
|
def test_dynamic_poi_metric_renames_support_park_count_options() -> None:
|
||||||
|
|
|
||||||
|
|
@ -1316,17 +1316,122 @@ def transform_grocery_retail_points(
|
||||||
).select("id", "name", "category", "icon_category", "group", "lat", "lng", "emoji")
|
).select("id", "name", "category", "icon_category", "group", "lat", "lng", "emoji")
|
||||||
|
|
||||||
|
|
||||||
def transform_gias_schools(gias_path: Path) -> pl.LazyFrame:
|
SCHOOL_ICON_CATEGORIES: dict[str, str] = {
|
||||||
"""Convert the GIAS register parquet into POI rows with school metadata."""
|
"Nursery school": "🧸",
|
||||||
return pl.scan_parquet(gias_path).select(
|
"Primary school": "🎒",
|
||||||
|
"Secondary school": "🏫",
|
||||||
|
"All-through school": "🏫",
|
||||||
|
"Sixth form": "📚",
|
||||||
|
"Further education college": "📚",
|
||||||
|
"University": "🎓",
|
||||||
|
"Special school": "🤝",
|
||||||
|
"School": "🏫",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _school_icon_category_expr() -> pl.Expr:
|
||||||
|
"""Pick an icon category from GIAS phase/type_group/age_range. type_group
|
||||||
|
wins for universities, FE colleges and special schools (which span multiple
|
||||||
|
phases); otherwise phase determines the bucket. For independent and other
|
||||||
|
non-statutory schools where GIAS leaves phase null, fall back to the
|
||||||
|
age_range bounds so they still split into the right pill."""
|
||||||
|
# GIAS phase mixes casing ("Middle deemed Primary" vs "Middle deemed
|
||||||
|
# primary") so we normalise before matching.
|
||||||
|
phase = pl.col("phase").str.to_lowercase()
|
||||||
|
# age_range is "<min>–<max>" using an em-dash; both ends may be missing.
|
||||||
|
age_parts = pl.col("age_range").str.split_exact("–", 1)
|
||||||
|
min_age = age_parts.struct.field("field_0").cast(pl.Int32, strict=False)
|
||||||
|
max_age = age_parts.struct.field("field_1").cast(pl.Int32, strict=False)
|
||||||
|
return (
|
||||||
|
pl.when(pl.col("type_group") == "Universities")
|
||||||
|
.then(pl.lit("University"))
|
||||||
|
.when(pl.col("type_group") == "Special schools")
|
||||||
|
.then(pl.lit("Special school"))
|
||||||
|
.when(pl.col("type_group") == "Colleges")
|
||||||
|
.then(pl.lit("Further education college"))
|
||||||
|
.when(phase == "nursery")
|
||||||
|
.then(pl.lit("Nursery school"))
|
||||||
|
.when(phase.is_in(["primary", "middle deemed primary"]))
|
||||||
|
.then(pl.lit("Primary school"))
|
||||||
|
.when(phase.is_in(["secondary", "middle deemed secondary"]))
|
||||||
|
.then(pl.lit("Secondary school"))
|
||||||
|
.when(phase == "all-through")
|
||||||
|
.then(pl.lit("All-through school"))
|
||||||
|
.when(phase.is_in(["16 plus", "sixth form"]))
|
||||||
|
.then(pl.lit("Sixth form"))
|
||||||
|
# Age-range fallback for null-phase rows (≈3k Independents + Academies
|
||||||
|
# GIAS doesn't classify by phase).
|
||||||
|
.when(max_age <= 5)
|
||||||
|
.then(pl.lit("Nursery school"))
|
||||||
|
.when(min_age >= 16)
|
||||||
|
.then(pl.lit("Sixth form"))
|
||||||
|
.when((min_age <= 6) & (max_age >= 16))
|
||||||
|
.then(pl.lit("All-through school"))
|
||||||
|
.when(max_age <= 11)
|
||||||
|
.then(pl.lit("Primary school"))
|
||||||
|
.when(min_age >= 10)
|
||||||
|
.then(pl.lit("Secondary school"))
|
||||||
|
.otherwise(pl.lit("School"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
OFSTED_OEIF_LABELS = {
|
||||||
|
"1": "Outstanding",
|
||||||
|
"2": "Good",
|
||||||
|
"3": "Requires improvement",
|
||||||
|
"4": "Inadequate",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ofsted_ratings(ofsted_path: Path) -> pl.LazyFrame:
|
||||||
|
"""Project the latest OEIF effectiveness grade to a human-readable label,
|
||||||
|
keyed by URN so it can be joined onto the GIAS register. Grades 1-4 map to
|
||||||
|
the conventional Ofsted labels; "Not judged" (post-2025 reform schools that
|
||||||
|
only have a report card) is preserved verbatim; null grades drop out."""
|
||||||
|
grade_col = pl.col("Latest OEIF overall effectiveness")
|
||||||
|
label = (
|
||||||
|
pl.when(grade_col == "1")
|
||||||
|
.then(pl.lit(OFSTED_OEIF_LABELS["1"]))
|
||||||
|
.when(grade_col == "2")
|
||||||
|
.then(pl.lit(OFSTED_OEIF_LABELS["2"]))
|
||||||
|
.when(grade_col == "3")
|
||||||
|
.then(pl.lit(OFSTED_OEIF_LABELS["3"]))
|
||||||
|
.when(grade_col == "4")
|
||||||
|
.then(pl.lit(OFSTED_OEIF_LABELS["4"]))
|
||||||
|
.when(grade_col == "Not judged")
|
||||||
|
.then(pl.lit("Not judged"))
|
||||||
|
.otherwise(None)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
pl.scan_parquet(ofsted_path)
|
||||||
|
.select(
|
||||||
|
pl.col("URN").cast(pl.Int64).alias("urn"),
|
||||||
|
label.alias("ofsted_rating"),
|
||||||
|
)
|
||||||
|
.filter(pl.col("ofsted_rating").is_not_null())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_gias_schools(gias_path: Path, ofsted_path: Path) -> pl.LazyFrame:
|
||||||
|
"""Convert the GIAS register parquet into POI rows with school metadata.
|
||||||
|
Ofsted ratings are joined by URN so each school carries its latest OEIF
|
||||||
|
overall effectiveness grade (Outstanding/Good/Requires improvement/
|
||||||
|
Inadequate/Not judged), surfaced in the map popup."""
|
||||||
|
icon_category_expr = _school_icon_category_expr()
|
||||||
|
emoji_expr = icon_category_expr.replace_strict(SCHOOL_ICON_CATEGORIES)
|
||||||
|
ofsted = _load_ofsted_ratings(ofsted_path)
|
||||||
|
# category mirrors icon_category so the dashboard renders one toggle per
|
||||||
|
# school type (Nursery / Primary / Secondary / Sixth form / University /…)
|
||||||
|
# instead of bundling every GIAS row under a single "School" pill.
|
||||||
|
return pl.scan_parquet(gias_path).join(ofsted, on="urn", how="left").select(
|
||||||
pl.concat_str([pl.lit("gias-"), pl.col("urn").cast(pl.String)]).alias("id"),
|
pl.concat_str([pl.lit("gias-"), pl.col("urn").cast(pl.String)]).alias("id"),
|
||||||
pl.col("name"),
|
pl.col("name"),
|
||||||
pl.lit("School").alias("category"),
|
icon_category_expr.alias("category"),
|
||||||
pl.lit("School").alias("icon_category"),
|
icon_category_expr.alias("icon_category"),
|
||||||
pl.lit("Education").alias("group"),
|
pl.lit("Education").alias("group"),
|
||||||
pl.col("lat").cast(pl.Float64),
|
pl.col("lat").cast(pl.Float64),
|
||||||
pl.col("lng").cast(pl.Float64),
|
pl.col("lng").cast(pl.Float64),
|
||||||
pl.lit("🏫").alias("emoji"),
|
emoji_expr.alias("emoji"),
|
||||||
pl.col("phase").alias("school_phase"),
|
pl.col("phase").alias("school_phase"),
|
||||||
pl.col("type").alias("school_type"),
|
pl.col("type").alias("school_type"),
|
||||||
pl.col("type_group").alias("school_type_group"),
|
pl.col("type_group").alias("school_type_group"),
|
||||||
|
|
@ -1346,6 +1451,7 @@ def transform_gias_schools(gias_path: Path) -> pl.LazyFrame:
|
||||||
pl.col("website").alias("school_website"),
|
pl.col("website").alias("school_website"),
|
||||||
pl.col("telephone").cast(pl.String, strict=False).alias("school_telephone"),
|
pl.col("telephone").cast(pl.String, strict=False).alias("school_telephone"),
|
||||||
pl.col("head_name").alias("school_head_name"),
|
pl.col("head_name").alias("school_head_name"),
|
||||||
|
pl.col("ofsted_rating").alias("school_ofsted_rating"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1355,6 +1461,7 @@ def transform(
|
||||||
boundary_path: Path,
|
boundary_path: Path,
|
||||||
grocery_retail_points_path: Path,
|
grocery_retail_points_path: Path,
|
||||||
gias_path: Path,
|
gias_path: Path,
|
||||||
|
ofsted_path: Path,
|
||||||
) -> pl.LazyFrame:
|
) -> pl.LazyFrame:
|
||||||
lf = pl.scan_parquet(input_path)
|
lf = pl.scan_parquet(input_path)
|
||||||
|
|
||||||
|
|
@ -1420,7 +1527,12 @@ def transform(
|
||||||
|
|
||||||
grocery_df = pl.read_parquet(grocery_retail_points_path)
|
grocery_df = pl.read_parquet(grocery_retail_points_path)
|
||||||
grocery_pois = transform_grocery_retail_points(grocery_df, boundary_path)
|
grocery_pois = transform_grocery_retail_points(grocery_df, boundary_path)
|
||||||
frames = [lf, naptan, grocery_pois.lazy(), transform_gias_schools(gias_path)]
|
frames = [
|
||||||
|
lf,
|
||||||
|
naptan,
|
||||||
|
grocery_pois.lazy(),
|
||||||
|
transform_gias_schools(gias_path, ofsted_path),
|
||||||
|
]
|
||||||
|
|
||||||
return pl.concat(frames, how="diagonal_relaxed")
|
return pl.concat(frames, how="diagonal_relaxed")
|
||||||
|
|
||||||
|
|
@ -1453,6 +1565,12 @@ def main():
|
||||||
required=True,
|
required=True,
|
||||||
help="GIAS schools register parquet (replaces OSM schools)",
|
help="GIAS schools register parquet (replaces OSM schools)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ofsted",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="Ofsted latest-inspections parquet (provides per-URN ratings)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
|
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
|
||||||
)
|
)
|
||||||
|
|
@ -1464,6 +1582,7 @@ def main():
|
||||||
args.boundary,
|
args.boundary,
|
||||||
args.grocery_retail_points,
|
args.grocery_retail_points,
|
||||||
args.gias,
|
args.gias,
|
||||||
|
args.ofsted,
|
||||||
).collect(engine="streaming")
|
).collect(engine="streaming")
|
||||||
|
|
||||||
df.write_parquet(args.output)
|
df.write_parquet(args.output)
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,8 @@ def build_tree_overlay_tiles(
|
||||||
str(max_zoom),
|
str(max_zoom),
|
||||||
"--drop-smallest-as-needed",
|
"--drop-smallest-as-needed",
|
||||||
"--extend-zooms-if-still-dropping",
|
"--extend-zooms-if-still-dropping",
|
||||||
|
"--temporary-directory",
|
||||||
|
tmp,
|
||||||
str(ndjson_path),
|
str(ndjson_path),
|
||||||
],
|
],
|
||||||
check=True,
|
check=True,
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ set -euo pipefail
|
||||||
# - places_ref.parquet: place order reference
|
# - places_ref.parquet: place order reference
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./r5-java/run.sh [--demo]
|
# ./r5-java/run.sh [--demo] [--cache-warmers]
|
||||||
# --demo only compute Bank + TCR, transit only (quick test)
|
# --demo only compute Bank + TCR, transit only (quick test)
|
||||||
|
# --cache-warmers only compute Bank + Tower Gateway DLR, all modes (LinkageCache warmup);
|
||||||
|
# skips origins whose output parquet already exists
|
||||||
|
|
||||||
# --- Defaults ---
|
# --- Defaults ---
|
||||||
THREADS=12
|
THREADS=12
|
||||||
|
|
@ -32,16 +34,18 @@ NETWORK_DIR=property-data/r5-network
|
||||||
OUTPUT_BASE=property-data/travel-times
|
OUTPUT_BASE=property-data/travel-times
|
||||||
R5_DIR=r5-java
|
R5_DIR=r5-java
|
||||||
DEMO_FLAG=""
|
DEMO_FLAG=""
|
||||||
|
CACHE_WARMERS_FLAG=""
|
||||||
|
|
||||||
# --- Parse args ---
|
# --- Parse args ---
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--threads) THREADS="$2"; shift 2 ;;
|
--threads) THREADS="$2"; shift 2 ;;
|
||||||
--heap) HEAP="$2"; shift 2 ;;
|
--heap) HEAP="$2"; shift 2 ;;
|
||||||
--network-dir) NETWORK_DIR="$2"; shift 2 ;;
|
--network-dir) NETWORK_DIR="$2"; shift 2 ;;
|
||||||
--output-dir) OUTPUT_BASE="$2"; shift 2 ;;
|
--output-dir) OUTPUT_BASE="$2"; shift 2 ;;
|
||||||
--demo) DEMO_FLAG="--demo"; shift ;;
|
--demo) DEMO_FLAG="--demo"; shift ;;
|
||||||
--demo-cars=*) DEMO_FLAG="--demo-cars ${1#--demo-cars=}"; shift ;;
|
--demo-cars=*) DEMO_FLAG="--demo-cars ${1#--demo-cars=}"; shift ;;
|
||||||
|
--cache-warmers) CACHE_WARMERS_FLAG="--cache-warmers"; shift ;;
|
||||||
*) echo "Unknown: $1"; exit 1 ;;
|
*) echo "Unknown: $1"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
@ -165,7 +169,7 @@ java -Xmx"$HEAP" \
|
||||||
--places property-data/places.parquet \
|
--places property-data/places.parquet \
|
||||||
--output-dir "$OUTPUT_BASE" \
|
--output-dir "$OUTPUT_BASE" \
|
||||||
--threads "$THREADS" \
|
--threads "$THREADS" \
|
||||||
$DEMO_FLAG
|
$DEMO_FLAG $CACHE_WARMERS_FLAG
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Complete ==="
|
echo "=== Complete ==="
|
||||||
|
|
|
||||||
|
|
@ -63,15 +63,12 @@ public class App {
|
||||||
private static final Set<String> DEMO_PLACES = Set.of(
|
private static final Set<String> DEMO_PLACES = Set.of(
|
||||||
"Bank tube station", "Tottenham Court Road tube station");
|
"Bank tube station", "Tottenham Court Road tube station");
|
||||||
/**
|
/**
|
||||||
* Always-first origins (per-mode). The cache-warmest London core: these origins
|
* Origins computed by the {@code --cache-warmers} flag. The cache-warmest
|
||||||
* each touch ~100 unique global tiles, so running them up front builds the
|
* London core: these origins each touch ~100 unique global tiles, so running
|
||||||
* LinkageCache that every subsequent London-ish origin reuses.
|
* them populates the LinkageCache that every later London-ish origin reuses.
|
||||||
* Order within this list is preserved in submission order.
|
|
||||||
*/
|
*/
|
||||||
private static final List<String> PRIORITY_PLACES = List.of(
|
private static final Set<String> CACHE_WARMER_PLACES = Set.of(
|
||||||
"Bank tube station",
|
"Bank tube station", "Tower Gateway DLR station");
|
||||||
"Tower Gateway DLR station",
|
|
||||||
"Tottenham Court Road tube station");
|
|
||||||
private static final int MAX_RETRIES = 2;
|
private static final int MAX_RETRIES = 2;
|
||||||
|
|
||||||
/** Writer pool size. Holds one DuckDB connection per thread. */
|
/** Writer pool size. Holds one DuckDB connection per thread. */
|
||||||
|
|
@ -122,6 +119,7 @@ public class App {
|
||||||
int threads = Integer.parseInt(optionalArg(args, "--threads", "4"));
|
int threads = Integer.parseInt(optionalArg(args, "--threads", "4"));
|
||||||
boolean enablePaths = true;
|
boolean enablePaths = true;
|
||||||
boolean demo = hasFlag(args, "--demo");
|
boolean demo = hasFlag(args, "--demo");
|
||||||
|
boolean cacheWarmersOnly = hasFlag(args, "--cache-warmers");
|
||||||
|
|
||||||
Path outDir = Paths.get(outputDirStr);
|
Path outDir = Paths.get(outputDirStr);
|
||||||
Files.createDirectories(outDir);
|
Files.createDirectories(outDir);
|
||||||
|
|
@ -190,6 +188,15 @@ public class App {
|
||||||
modes = DEMO_MODES;
|
modes = DEMO_MODES;
|
||||||
System.err.printf("DEMO MODE: %d places (transit only)%n", originIndices.length);
|
System.err.printf("DEMO MODE: %d places (transit only)%n", originIndices.length);
|
||||||
for (int i : originIndices) System.err.printf(" - %s%n", originNames[i]);
|
for (int i : originIndices) System.err.printf(" - %s%n", originNames[i]);
|
||||||
|
} else if (cacheWarmersOnly) {
|
||||||
|
List<Integer> warmerIdx = new ArrayList<>();
|
||||||
|
for (int i = 0; i < nOrigins; i++) {
|
||||||
|
if (CACHE_WARMER_PLACES.contains(originNames[i])) warmerIdx.add(i);
|
||||||
|
}
|
||||||
|
originIndices = warmerIdx.stream().mapToInt(Integer::intValue).toArray();
|
||||||
|
modes = MODES;
|
||||||
|
System.err.printf("CACHE-WARMERS MODE: %d places (all modes)%n", originIndices.length);
|
||||||
|
for (int i : originIndices) System.err.printf(" - %s%n", originNames[i]);
|
||||||
} else {
|
} else {
|
||||||
// Normal mode: use all travel-eligible England places
|
// Normal mode: use all travel-eligible England places
|
||||||
originIndices = englandIndices.stream().sorted()
|
originIndices = englandIndices.stream().sorted()
|
||||||
|
|
@ -327,21 +334,12 @@ public class App {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordering policy:
|
// Ordering policy: LPT (longest-processing-time-first). Dense urban origins
|
||||||
// 1. PRIORITY_PLACES first, in the literal order they're listed (Bank, Tower
|
// do far more work than rural ones; submitting them first prevents a long
|
||||||
// Gateway DLR, TCR). These dense London origins are the best LinkageCache
|
// tail where a few London origins finish after everything else drains.
|
||||||
// warmers — every later origin in the SE benefits.
|
|
||||||
// 2. Then LPT (longest-processing-time-first): dense urban origins do far
|
|
||||||
// more work than rural ones. Submitting them first prevents a long tail
|
|
||||||
// where a few London origins finish after everything else drains.
|
|
||||||
double modeRadius = Router.maxRadiusKm(mode);
|
double modeRadius = Router.maxRadiusKm(mode);
|
||||||
remaining.sort(Comparator.<Integer, Integer>comparing(
|
remaining.sort(Comparator.comparingInt((Integer idx) ->
|
||||||
idx -> {
|
Router.estimateWorkload(postcodeIndex, originLats[idx], originLons[idx], modeRadius)).reversed());
|
||||||
int prio = PRIORITY_PLACES.indexOf(originNames[idx]);
|
|
||||||
return prio < 0 ? Integer.MAX_VALUE : prio;
|
|
||||||
})
|
|
||||||
.thenComparing(Comparator.comparingInt((Integer idx) ->
|
|
||||||
Router.estimateWorkload(postcodeIndex, originLats[idx], originLons[idx], modeRadius)).reversed()));
|
|
||||||
|
|
||||||
long startMs = System.currentTimeMillis();
|
long startMs = System.currentTimeMillis();
|
||||||
int total = remaining.size();
|
int total = remaining.size();
|
||||||
|
|
@ -527,7 +525,7 @@ public class App {
|
||||||
if (args[i].equals(name)) return args[i + 1];
|
if (args[i].equals(name)) return args[i + 1];
|
||||||
}
|
}
|
||||||
System.err.println("Missing required argument: " + name);
|
System.err.println("Missing required argument: " + name);
|
||||||
System.err.println("Usage: App --postcodes FILE --places FILE --output-dir DIR [--threads N] [--demo]");
|
System.err.println("Usage: App --postcodes FILE --places FILE --output-dir DIR [--threads N] [--demo] [--cache-warmers]");
|
||||||
System.exit(1);
|
System.exit(1);
|
||||||
return null; // unreachable
|
return null; // unreachable
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ pub struct ActualListingData {
|
||||||
/// overlaid where available. This lets the listings endpoint use the same filter
|
/// overlaid where available. This lets the listings endpoint use the same filter
|
||||||
/// execution path as the property endpoints.
|
/// execution path as the property endpoints.
|
||||||
pub filter_feature_data: Vec<u16>,
|
pub filter_feature_data: Vec<u16>,
|
||||||
|
/// Row-major dynamic postcode POI metrics aligned with
|
||||||
|
/// PropertyData::poi_metrics.feature_names.
|
||||||
|
pub poi_filter_feature_data: Vec<u16>,
|
||||||
pub grid: GridIndex,
|
pub grid: GridIndex,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,16 +112,16 @@ impl ActualListingData {
|
||||||
let listing_status = InternedColumn::build(&opt_to_string(&listing_status_raw));
|
let listing_status = InternedColumn::build(&opt_to_string(&listing_status_raw));
|
||||||
|
|
||||||
let filter_feature_data = build_filter_feature_data(
|
let filter_feature_data = build_filter_feature_data(
|
||||||
|
&df,
|
||||||
property_data,
|
property_data,
|
||||||
&postcode,
|
|
||||||
&address,
|
|
||||||
&property_type_raw,
|
&property_type_raw,
|
||||||
&leasehold_freehold_raw,
|
&leasehold_freehold_raw,
|
||||||
&rooms_total,
|
&rooms_total,
|
||||||
&floor_area_sqm,
|
&floor_area_sqm,
|
||||||
&asking_price,
|
&asking_price,
|
||||||
&asking_price_per_sqm,
|
&asking_price_per_sqm,
|
||||||
);
|
)?;
|
||||||
|
let poi_filter_feature_data = build_poi_filter_feature_data(&df, property_data)?;
|
||||||
|
|
||||||
let grid = GridIndex::build(&lat, &lon, GRID_CELL_SIZE);
|
let grid = GridIndex::build(&lat, &lon, GRID_CELL_SIZE);
|
||||||
|
|
||||||
|
|
@ -144,6 +147,7 @@ impl ActualListingData {
|
||||||
listing_date_iso,
|
listing_date_iso,
|
||||||
features,
|
features,
|
||||||
filter_feature_data,
|
filter_feature_data,
|
||||||
|
poi_filter_feature_data,
|
||||||
grid,
|
grid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -174,49 +178,37 @@ impl ActualListingData {
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_filter_feature_data(
|
fn build_filter_feature_data(
|
||||||
|
df: &DataFrame,
|
||||||
property_data: Option<&PropertyData>,
|
property_data: Option<&PropertyData>,
|
||||||
postcode: &[String],
|
|
||||||
address: &[Option<String>],
|
|
||||||
property_type: &[Option<String>],
|
property_type: &[Option<String>],
|
||||||
leasehold_freehold: &[Option<String>],
|
leasehold_freehold: &[Option<String>],
|
||||||
rooms_total: &[Option<i32>],
|
rooms_total: &[Option<i32>],
|
||||||
floor_area_sqm: &[Option<f32>],
|
floor_area_sqm: &[Option<f32>],
|
||||||
asking_price: &[Option<i64>],
|
asking_price: &[Option<i64>],
|
||||||
asking_price_per_sqm: &[Option<f32>],
|
asking_price_per_sqm: &[Option<f32>],
|
||||||
) -> Vec<u16> {
|
) -> Result<Vec<u16>> {
|
||||||
let Some(property_data) = property_data else {
|
let Some(property_data) = property_data else {
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
|
|
||||||
let num_features = property_data.num_features;
|
let num_features = property_data.num_features;
|
||||||
let mut feature_data = vec![NAN_U16; postcode.len() * num_features];
|
let row_count = df.height();
|
||||||
let mut joined_rows = 0usize;
|
let mut feature_data = vec![NAN_U16; row_count * num_features];
|
||||||
|
let quant = property_data.quant_ref();
|
||||||
|
let mut encoded_columns = 0usize;
|
||||||
|
|
||||||
for (row, postcode_value) in postcode.iter().enumerate() {
|
for (feat_idx, name) in property_data.feature_names.iter().enumerate() {
|
||||||
let Some(address_value) = address[row]
|
if feat_idx < property_data.num_numeric {
|
||||||
.as_deref()
|
if let Some(values) = extract_optional_feature_f32(df, name)? {
|
||||||
.map(str::trim)
|
encode_numeric_feature(&mut feature_data, property_data, &quant, feat_idx, values);
|
||||||
.filter(|v| !v.is_empty())
|
encoded_columns += 1;
|
||||||
else {
|
}
|
||||||
continue;
|
} else if let Some(values) = extract_optional_feature_str(df, name)? {
|
||||||
};
|
encode_enum_feature(&mut feature_data, property_data, feat_idx, values);
|
||||||
|
encoded_columns += 1;
|
||||||
let query = format!("{address_value} {postcode_value}");
|
|
||||||
let Some(&property_row) = property_data.search_addresses(&query, 1).first() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if property_data.postcode(property_row) != postcode_value {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let dst = row * num_features;
|
|
||||||
let src = property_row * num_features;
|
|
||||||
feature_data[dst..dst + num_features]
|
|
||||||
.copy_from_slice(&property_data.feature_data[src..src + num_features]);
|
|
||||||
joined_rows += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let quant = property_data.quant_ref();
|
|
||||||
overlay_numeric_feature(
|
overlay_numeric_feature(
|
||||||
&mut feature_data,
|
&mut feature_data,
|
||||||
property_data,
|
property_data,
|
||||||
|
|
@ -281,11 +273,50 @@ fn build_filter_feature_data(
|
||||||
);
|
);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
rows = postcode.len(),
|
rows = row_count,
|
||||||
joined_rows, "Actual listings joined to property feature matrix"
|
encoded_columns, "Actual listings feature matrix read from enriched parquet"
|
||||||
);
|
);
|
||||||
|
|
||||||
feature_data
|
Ok(feature_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_poi_filter_feature_data(
|
||||||
|
df: &DataFrame,
|
||||||
|
property_data: Option<&PropertyData>,
|
||||||
|
) -> Result<Vec<u16>> {
|
||||||
|
let Some(property_data) = property_data else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let poi_metrics = &property_data.poi_metrics;
|
||||||
|
let num_features = poi_metrics.num_features();
|
||||||
|
if num_features == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let row_count = df.height();
|
||||||
|
let mut feature_data = vec![NAN_U16; row_count * num_features];
|
||||||
|
let quant = poi_metrics.quant_ref();
|
||||||
|
let mut encoded_columns = 0usize;
|
||||||
|
|
||||||
|
for (metric_idx, name) in poi_metrics.feature_names.iter().enumerate() {
|
||||||
|
let Some(values) = extract_optional_feature_f32(df, name)? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (row, value) in values.into_iter().enumerate() {
|
||||||
|
let dst = row * num_features + metric_idx;
|
||||||
|
feature_data[dst] = value
|
||||||
|
.map(|value| encode_numeric_value(&quant, metric_idx, value))
|
||||||
|
.unwrap_or(NAN_U16);
|
||||||
|
}
|
||||||
|
encoded_columns += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
rows = row_count,
|
||||||
|
encoded_columns, "Actual listings POI metrics read from enriched parquet"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(feature_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn feature_index(property_data: &PropertyData, name: &str) -> Option<usize> {
|
fn feature_index(property_data: &PropertyData, name: &str) -> Option<usize> {
|
||||||
|
|
@ -323,6 +354,53 @@ fn overlay_numeric_feature<I>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encode_numeric_feature<I>(
|
||||||
|
feature_data: &mut [u16],
|
||||||
|
property_data: &PropertyData,
|
||||||
|
quant: &QuantRef<'_>,
|
||||||
|
feat_idx: usize,
|
||||||
|
values: I,
|
||||||
|
) where
|
||||||
|
I: IntoIterator<Item = Option<f32>>,
|
||||||
|
{
|
||||||
|
let num_features = property_data.num_features;
|
||||||
|
for (row, value) in values.into_iter().enumerate() {
|
||||||
|
let dst = row * num_features + feat_idx;
|
||||||
|
feature_data[dst] = value
|
||||||
|
.map(|value| encode_numeric_value(quant, feat_idx, value))
|
||||||
|
.unwrap_or(NAN_U16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_optional_feature_f32(df: &DataFrame, name: &str) -> Result<Option<Vec<Option<f32>>>> {
|
||||||
|
let Ok(column) = df.column(name) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(column.dtype(), DataType::Datetime(_, _) | DataType::Date) {
|
||||||
|
let projected = df
|
||||||
|
.clone()
|
||||||
|
.lazy()
|
||||||
|
.select([(col(name).dt().year().cast(DataType::Float32)
|
||||||
|
+ (col(name).dt().month().cast(DataType::Float32) - lit(1.0f32)) / lit(12.0f32))
|
||||||
|
.alias("__feature")])
|
||||||
|
.collect()
|
||||||
|
.with_context(|| format!("Failed to convert datetime feature '{name}'"))?;
|
||||||
|
return Ok(Some(extract_opt_f32(&projected, "__feature")?));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cast = column
|
||||||
|
.cast(&DataType::Float32)
|
||||||
|
.with_context(|| format!("Failed to cast feature '{name}' to Float32"))?;
|
||||||
|
let values = cast
|
||||||
|
.f32()
|
||||||
|
.with_context(|| format!("Feature '{name}' is not Float32"))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.filter(|v| v.is_finite()))
|
||||||
|
.collect();
|
||||||
|
Ok(Some(values))
|
||||||
|
}
|
||||||
|
|
||||||
fn overlay_enum_feature<'a, I>(
|
fn overlay_enum_feature<'a, I>(
|
||||||
feature_data: &mut [u16],
|
feature_data: &mut [u16],
|
||||||
property_data: &PropertyData,
|
property_data: &PropertyData,
|
||||||
|
|
@ -355,6 +433,46 @@ fn overlay_enum_feature<'a, I>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encode_enum_feature(
|
||||||
|
feature_data: &mut [u16],
|
||||||
|
property_data: &PropertyData,
|
||||||
|
feat_idx: usize,
|
||||||
|
values: Vec<Option<String>>,
|
||||||
|
) {
|
||||||
|
let Some(enum_values) = property_data.enum_values.get(&feat_idx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let num_features = property_data.num_features;
|
||||||
|
for (row, value) in values.into_iter().enumerate() {
|
||||||
|
let dst = row * num_features + feat_idx;
|
||||||
|
feature_data[dst] = value
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.and_then(|text| enum_values.iter().position(|candidate| candidate == text))
|
||||||
|
.map(|position| position as u16)
|
||||||
|
.unwrap_or(NAN_U16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_optional_feature_str(df: &DataFrame, name: &str) -> Result<Option<Vec<Option<String>>>> {
|
||||||
|
let Ok(column) = df.column(name) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let cast = column
|
||||||
|
.cast(&DataType::String)
|
||||||
|
.with_context(|| format!("Failed to cast feature '{name}' to String"))?;
|
||||||
|
let strings = cast
|
||||||
|
.str()
|
||||||
|
.with_context(|| format!("Feature '{name}' is not a string column"))?;
|
||||||
|
Ok(Some(
|
||||||
|
strings
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.and_then(|text| (!text.trim().is_empty()).then(|| text.to_string())))
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn encode_numeric_value(quant: &QuantRef<'_>, feat_idx: usize, value: f32) -> u16 {
|
fn encode_numeric_value(quant: &QuantRef<'_>, feat_idx: usize, value: f32) -> u16 {
|
||||||
if !value.is_finite() {
|
if !value.is_finite() {
|
||||||
return NAN_U16;
|
return NAN_U16;
|
||||||
|
|
@ -517,8 +635,13 @@ mod tests {
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn sample_path() -> Option<PathBuf> {
|
fn sample_path() -> Option<PathBuf> {
|
||||||
let path = PathBuf::from("../finder/data/online_listings_buy.parquet");
|
[
|
||||||
path.exists().then_some(path)
|
"../finder/data/online_listings_buy_enriched.parquet",
|
||||||
|
"../finder/data/online_listings_buy.parquet",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.find(|path| path.exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,20 @@ const DASHBOARD_POI_GROUPS: &[(&str, &[&str])] = &[
|
||||||
("Groceries", GROCERY_DASHBOARD_CATEGORIES),
|
("Groceries", GROCERY_DASHBOARD_CATEGORIES),
|
||||||
("Food & Drink", &["Café", "Restaurant", "Pub", "Fast Food"]),
|
("Food & Drink", &["Café", "Restaurant", "Pub", "Fast Food"]),
|
||||||
("Green Space", &["Park", "Playground"]),
|
("Green Space", &["Park", "Playground"]),
|
||||||
("Education", &["School"]),
|
(
|
||||||
|
"Education",
|
||||||
|
&[
|
||||||
|
"Nursery school",
|
||||||
|
"Primary school",
|
||||||
|
"Secondary school",
|
||||||
|
"All-through school",
|
||||||
|
"Sixth form",
|
||||||
|
"Further education college",
|
||||||
|
"University",
|
||||||
|
"Special school",
|
||||||
|
"School",
|
||||||
|
],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Health",
|
"Health",
|
||||||
&["GP Surgery", "Pharmacy", "Dentist", "Hospital & Clinic"],
|
&["GP Surgery", "Pharmacy", "Dentist", "Hospital & Clinic"],
|
||||||
|
|
@ -119,6 +132,21 @@ fn canonical_poi_category(category: &str) -> &str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Categories the pipeline emits for the GIAS-derived school POIs. A bare
|
||||||
|
/// `poi=School` URL (predating the per-phase split) is expanded to all of these
|
||||||
|
/// so bookmarked links keep showing schools.
|
||||||
|
const SCHOOL_CATEGORY_ALIASES: &[&str] = &[
|
||||||
|
"Nursery school",
|
||||||
|
"Primary school",
|
||||||
|
"Secondary school",
|
||||||
|
"All-through school",
|
||||||
|
"Sixth form",
|
||||||
|
"Further education college",
|
||||||
|
"University",
|
||||||
|
"Special school",
|
||||||
|
"School",
|
||||||
|
];
|
||||||
|
|
||||||
pub fn resolve_poi_category_filter(category_values: &[String], categories: &str) -> FxHashSet<u16> {
|
pub fn resolve_poi_category_filter(category_values: &[String], categories: &str) -> FxHashSet<u16> {
|
||||||
let mut selected = FxHashSet::default();
|
let mut selected = FxHashSet::default();
|
||||||
for part in categories.split(',') {
|
for part in categories.split(',') {
|
||||||
|
|
@ -126,6 +154,12 @@ pub fn resolve_poi_category_filter(category_values: &[String], categories: &str)
|
||||||
if category.is_empty() {
|
if category.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if category == "School" {
|
||||||
|
for alias in SCHOOL_CATEGORY_ALIASES {
|
||||||
|
add_category_filter_index(category_values, alias, &mut selected);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
add_category_filter_index(category_values, category, &mut selected);
|
add_category_filter_index(category_values, category, &mut selected);
|
||||||
}
|
}
|
||||||
selected
|
selected
|
||||||
|
|
@ -174,6 +208,8 @@ pub struct SchoolMetadata {
|
||||||
pub telephone: Option<String>,
|
pub telephone: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub head_name: Option<String>,
|
pub head_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ofsted_rating: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct POIData {
|
pub struct POIData {
|
||||||
|
|
@ -350,6 +386,8 @@ fn build_school_meta(
|
||||||
let website = extract_optional_str_col(df, "school_website")?.unwrap_or_default();
|
let website = extract_optional_str_col(df, "school_website")?.unwrap_or_default();
|
||||||
let telephone = extract_optional_str_col(df, "school_telephone")?.unwrap_or_default();
|
let telephone = extract_optional_str_col(df, "school_telephone")?.unwrap_or_default();
|
||||||
let head_name = extract_optional_str_col(df, "school_head_name")?.unwrap_or_default();
|
let head_name = extract_optional_str_col(df, "school_head_name")?.unwrap_or_default();
|
||||||
|
let ofsted_rating =
|
||||||
|
extract_optional_str_col(df, "school_ofsted_rating")?.unwrap_or_default();
|
||||||
|
|
||||||
let fetch_str = |col: &Vec<Option<String>>, row: usize| -> Option<String> {
|
let fetch_str = |col: &Vec<Option<String>>, row: usize| -> Option<String> {
|
||||||
col.get(row).cloned().flatten()
|
col.get(row).cloned().flatten()
|
||||||
|
|
@ -390,6 +428,7 @@ fn build_school_meta(
|
||||||
website: fetch_str(&website, row),
|
website: fetch_str(&website, row),
|
||||||
telephone: fetch_str(&telephone, row),
|
telephone: fetch_str(&telephone, row),
|
||||||
head_name: fetch_str(&head_name, row),
|
head_name: fetch_str(&head_name, row),
|
||||||
|
ofsted_rating: fetch_str(&ofsted_rating, row),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok((idx, meta))
|
Ok((idx, meta))
|
||||||
|
|
@ -578,6 +617,26 @@ mod tests {
|
||||||
assert!(selected.is_empty());
|
assert!(selected.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_school_filter_expands_to_all_school_categories() {
|
||||||
|
// Bookmarked URLs from before the per-phase split sent `poi=School`;
|
||||||
|
// they should still match every school category that's loaded.
|
||||||
|
let values = vec![
|
||||||
|
"Primary school".to_string(),
|
||||||
|
"Secondary school".to_string(),
|
||||||
|
"University".to_string(),
|
||||||
|
"Tesco".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let selected = resolve_poi_category_filter(&values, "School");
|
||||||
|
|
||||||
|
assert!(selected.contains(&0));
|
||||||
|
assert!(selected.contains(&1));
|
||||||
|
assert!(selected.contains(&2));
|
||||||
|
assert!(!selected.contains(&3));
|
||||||
|
assert_eq!(selected.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn coop_category_aliases_resolve_to_single_category() {
|
fn coop_category_aliases_resolve_to_single_category() {
|
||||||
let values = vec!["Co-op".to_string(), "Tesco".to_string()];
|
let values = vec!["Co-op".to_string(), "Tesco".to_string()];
|
||||||
|
|
|
||||||
|
|
@ -891,6 +891,15 @@ impl PropertyData {
|
||||||
(&self.postcode_interner, &self.postcode_keys)
|
(&self.postcode_interner, &self.postcode_keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property rows for a given postcode string, or empty if unknown.
|
||||||
|
pub fn rows_for_postcode(&self, postcode: &str) -> &[u32] {
|
||||||
|
self.postcode_interner
|
||||||
|
.get(postcode)
|
||||||
|
.and_then(|key| self.postcode_row_index.get(&key))
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
fn row_address_search_tokens(&self, row: usize) -> &[lasso::Spur] {
|
fn row_address_search_tokens(&self, row: usize) -> &[lasso::Spur] {
|
||||||
let offset = self.address_search_token_offsets[row] as usize;
|
let offset = self.address_search_token_offsets[row] as usize;
|
||||||
let length = self.address_search_token_lengths[row] as usize;
|
let length = self.address_search_token_lengths[row] as usize;
|
||||||
|
|
|
||||||
|
|
@ -426,21 +426,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
raw: false,
|
raw: false,
|
||||||
absolute: false,
|
absolute: false,
|
||||||
}),
|
}),
|
||||||
Feature::Numeric(FeatureConfig {
|
|
||||||
name: "Education, Skills and Training Score",
|
|
||||||
bounds: Bounds::Fixed {
|
|
||||||
min: 0.0,
|
|
||||||
max: 100.0,
|
|
||||||
},
|
|
||||||
step: 1.0,
|
|
||||||
description: "Education and skills deprivation percentile (higher = less deprived)",
|
|
||||||
detail: "From the English Indices of Deprivation, converted to a national percentile where 0% is most deprived and 100% is least deprived. Covers school attainment, entry to higher education, adult qualifications, and English language proficiency.",
|
|
||||||
source: "iod",
|
|
||||||
prefix: "",
|
|
||||||
suffix: "%",
|
|
||||||
raw: true,
|
|
||||||
absolute: true,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
FeatureGroup {
|
FeatureGroup {
|
||||||
|
|
@ -476,6 +461,21 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
raw: true,
|
raw: true,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
}),
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "Education, Skills and Training Score",
|
||||||
|
bounds: Bounds::Fixed {
|
||||||
|
min: 0.0,
|
||||||
|
max: 100.0,
|
||||||
|
},
|
||||||
|
step: 1.0,
|
||||||
|
description: "Education and skills deprivation percentile (higher = less deprived)",
|
||||||
|
detail: "From the English Indices of Deprivation, converted to a national percentile where 0% is most deprived and 100% is least deprived. Covers school attainment, entry to higher education, adult qualifications, and English language proficiency.",
|
||||||
|
source: "iod",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: true,
|
||||||
|
absolute: true,
|
||||||
|
}),
|
||||||
Feature::Numeric(FeatureConfig {
|
Feature::Numeric(FeatureConfig {
|
||||||
name: "Health Deprivation and Disability Score",
|
name: "Health Deprivation and Disability Score",
|
||||||
bounds: Bounds::Fixed {
|
bounds: Bounds::Fixed {
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,11 @@ use crate::consts::NAN_U16;
|
||||||
use crate::data::ActualListing;
|
use crate::data::ActualListing;
|
||||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||||
use crate::parsing::{
|
use crate::parsing::{
|
||||||
parse_filters_with_poi, require_bounds, row_passes_filters, row_passes_poi_filters,
|
parse_filters_with_poi, require_bounds, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter,
|
||||||
ParsedEnumFilter, ParsedFilter,
|
|
||||||
};
|
};
|
||||||
use crate::state::{AppState, SharedState};
|
use crate::state::{AppState, SharedState};
|
||||||
|
|
||||||
use super::travel_time::{parse_optional_travel, row_passes_travel_filters, TravelEntry};
|
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ActualListingsParams {
|
pub struct ActualListingsParams {
|
||||||
|
|
@ -41,17 +40,6 @@ pub struct ActualListingsResponse {
|
||||||
pub truncated: bool,
|
pub truncated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
const LISTING_LEVEL_FILTER_FEATURES: &[&str] = &[
|
|
||||||
"Property type",
|
|
||||||
"Leasehold/Freehold",
|
|
||||||
"Total floor area (sqm)",
|
|
||||||
"Number of bedrooms & living rooms",
|
|
||||||
"Estimated current price",
|
|
||||||
"Last known price",
|
|
||||||
"Est. price per sqm",
|
|
||||||
"Price per sqm",
|
|
||||||
];
|
|
||||||
|
|
||||||
const KEEP_UNKNOWN_LISTING_FILTER_FEATURES: &[&str] = &["Total floor area (sqm)"];
|
const KEEP_UNKNOWN_LISTING_FILTER_FEATURES: &[&str] = &["Total floor area (sqm)"];
|
||||||
|
|
||||||
pub async fn get_actual_listings(
|
pub async fn get_actual_listings(
|
||||||
|
|
@ -90,38 +78,23 @@ pub async fn get_actual_listings(
|
||||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||||
.map_err(|err| ApiError::BadRequest(err).into_response())?;
|
.map_err(|err| ApiError::BadRequest(err).into_response())?;
|
||||||
|
|
||||||
let listing_level_feature_idxs = listing_level_filter_feature_idxs(&state);
|
|
||||||
let keep_unknown_listing_filter_idxs = keep_unknown_listing_filter_feature_idxs(&state);
|
let keep_unknown_listing_filter_idxs = keep_unknown_listing_filter_feature_idxs(&state);
|
||||||
let (listing_filters, postcode_filters) =
|
let listing_filters = parsed_filters;
|
||||||
split_numeric_filters(parsed_filters, &listing_level_feature_idxs);
|
let listing_enum_filters = parsed_enum_filters;
|
||||||
let (listing_enum_filters, postcode_enum_filters) =
|
|
||||||
split_enum_filters(parsed_enum_filters, &listing_level_feature_idxs);
|
|
||||||
|
|
||||||
let has_postcode_filters = !postcode_filters.is_empty()
|
|
||||||
|| !postcode_enum_filters.is_empty()
|
|
||||||
|| !parsed_poi_filters.is_empty()
|
|
||||||
|| !travel_entries.is_empty();
|
|
||||||
let has_listing_filters = !listing_filters.is_empty() || !listing_enum_filters.is_empty();
|
let has_listing_filters = !listing_filters.is_empty() || !listing_enum_filters.is_empty();
|
||||||
|
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
let response =
|
let response =
|
||||||
tokio::task::spawn_blocking(move || -> Result<ActualListingsResponse, String> {
|
tokio::task::spawn_blocking(move || -> Result<ActualListingsResponse, String> {
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
|
let has_poi_filters = !parsed_poi_filters.is_empty();
|
||||||
let passing_postcodes = if has_postcode_filters {
|
let has_travel_filters = !travel_entries.is_empty();
|
||||||
Some(compute_passing_postcodes(
|
let poi_num_features = state_clone.data.poi_metrics.num_features();
|
||||||
&state_clone,
|
let travel_data = if has_travel_filters {
|
||||||
south,
|
load_travel_data(&state_clone.travel_time_store, &travel_entries)?
|
||||||
west,
|
|
||||||
north,
|
|
||||||
east,
|
|
||||||
&postcode_filters,
|
|
||||||
&postcode_enum_filters,
|
|
||||||
&parsed_poi_filters,
|
|
||||||
&travel_entries,
|
|
||||||
)?)
|
|
||||||
} else {
|
} else {
|
||||||
None
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let row_indices = actual_listings.grid.query(south, west, north, east);
|
let row_indices = actual_listings.grid.query(south, west, north, east);
|
||||||
|
|
@ -133,11 +106,6 @@ pub async fn get_actual_listings(
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&row_idx| {
|
.filter_map(|&row_idx| {
|
||||||
let row = row_idx as usize;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if has_listing_filters
|
if has_listing_filters
|
||||||
&& !row_passes_listing_filters(
|
&& !row_passes_listing_filters(
|
||||||
row,
|
row,
|
||||||
|
|
@ -150,6 +118,25 @@ pub async fn get_actual_listings(
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if has_poi_filters
|
||||||
|
&& !row_passes_listing_poi_filters(
|
||||||
|
row,
|
||||||
|
&parsed_poi_filters,
|
||||||
|
&actual_listings.poi_filter_feature_data,
|
||||||
|
poi_num_features,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if has_travel_filters
|
||||||
|
&& !row_passes_travel_filters(
|
||||||
|
actual_listings.postcode[row].as_str(),
|
||||||
|
&travel_entries,
|
||||||
|
&travel_data,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
Some(row)
|
Some(row)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -176,8 +163,9 @@ pub async fn get_actual_listings(
|
||||||
total = total_matching,
|
total = total_matching,
|
||||||
total_in_bounds,
|
total_in_bounds,
|
||||||
offset,
|
offset,
|
||||||
postcode_filtered = passing_postcodes.is_some(),
|
|
||||||
listing_filtered = has_listing_filters,
|
listing_filtered = has_listing_filters,
|
||||||
|
poi_filtered = has_poi_filters,
|
||||||
|
travel_filtered = has_travel_filters,
|
||||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||||
"GET /api/actual-listings"
|
"GET /api/actual-listings"
|
||||||
);
|
);
|
||||||
|
|
@ -196,10 +184,6 @@ pub async fn get_actual_listings(
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn listing_level_filter_feature_idxs(state: &AppState) -> FxHashSet<usize> {
|
|
||||||
feature_idxs(state, LISTING_LEVEL_FILTER_FEATURES)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn keep_unknown_listing_filter_feature_idxs(state: &AppState) -> FxHashSet<usize> {
|
fn keep_unknown_listing_filter_feature_idxs(state: &AppState) -> FxHashSet<usize> {
|
||||||
feature_idxs(state, KEEP_UNKNOWN_LISTING_FILTER_FEATURES)
|
feature_idxs(state, KEEP_UNKNOWN_LISTING_FILTER_FEATURES)
|
||||||
}
|
}
|
||||||
|
|
@ -211,38 +195,6 @@ fn feature_idxs(state: &AppState, names: &[&str]) -> FxHashSet<usize> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn split_numeric_filters(
|
|
||||||
filters: Vec<ParsedFilter>,
|
|
||||||
listing_level_feature_idxs: &FxHashSet<usize>,
|
|
||||||
) -> (Vec<ParsedFilter>, Vec<ParsedFilter>) {
|
|
||||||
let mut listing_filters = Vec::new();
|
|
||||||
let mut postcode_filters = Vec::new();
|
|
||||||
for filter in filters {
|
|
||||||
if listing_level_feature_idxs.contains(&filter.feat_idx) {
|
|
||||||
listing_filters.push(filter);
|
|
||||||
} else {
|
|
||||||
postcode_filters.push(filter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(listing_filters, postcode_filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_enum_filters(
|
|
||||||
filters: Vec<ParsedEnumFilter>,
|
|
||||||
listing_level_feature_idxs: &FxHashSet<usize>,
|
|
||||||
) -> (Vec<ParsedEnumFilter>, Vec<ParsedEnumFilter>) {
|
|
||||||
let mut listing_filters = Vec::new();
|
|
||||||
let mut postcode_filters = Vec::new();
|
|
||||||
for filter in filters {
|
|
||||||
if listing_level_feature_idxs.contains(&filter.feat_idx) {
|
|
||||||
listing_filters.push(filter);
|
|
||||||
} else {
|
|
||||||
postcode_filters.push(filter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(listing_filters, postcode_filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_passes_listing_filters(
|
fn row_passes_listing_filters(
|
||||||
row: usize,
|
row: usize,
|
||||||
filters: &[ParsedFilter],
|
filters: &[ParsedFilter],
|
||||||
|
|
@ -266,132 +218,33 @@ fn row_passes_listing_filters(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
fn row_passes_listing_poi_filters(
|
||||||
fn compute_passing_postcodes(
|
row: usize,
|
||||||
state: &AppState,
|
filters: &[ParsedPoiFilter],
|
||||||
south: f64,
|
feature_data: &[u16],
|
||||||
west: f64,
|
num_features: usize,
|
||||||
north: f64,
|
) -> bool {
|
||||||
east: f64,
|
if filters.is_empty() {
|
||||||
parsed_filters: &[crate::parsing::ParsedFilter],
|
return true;
|
||||||
parsed_enum_filters: &[crate::parsing::ParsedEnumFilter],
|
}
|
||||||
parsed_poi_filters: &[crate::parsing::ParsedPoiFilter],
|
if num_features == 0 || feature_data.is_empty() {
|
||||||
travel_entries: &[TravelEntry],
|
return false;
|
||||||
) -> 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() {
|
let base = row * num_features;
|
||||||
Vec::new()
|
filters.iter().all(|filter| {
|
||||||
} else {
|
let raw = feature_data
|
||||||
let store = &state.travel_time_store;
|
.get(base + filter.metric_idx)
|
||||||
travel_entries
|
.copied()
|
||||||
.iter()
|
.unwrap_or(NAN_U16);
|
||||||
.map(|entry| {
|
raw != NAN_U16 && raw >= filter.min_u16 && raw <= filter.max_u16
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn numeric_filter(feat_idx: usize) -> ParsedFilter {
|
|
||||||
ParsedFilter {
|
|
||||||
feat_idx,
|
|
||||||
min_u16: 0,
|
|
||||||
max_u16: 100,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enum_filter(feat_idx: usize) -> ParsedEnumFilter {
|
|
||||||
ParsedEnumFilter {
|
|
||||||
feat_idx,
|
|
||||||
allowed: [0u16].into_iter().collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn splits_actual_listing_filters_by_listing_native_features() {
|
|
||||||
let listing_level_feature_idxs: FxHashSet<usize> = [1usize, 3].into_iter().collect();
|
|
||||||
|
|
||||||
let (listing_filters, postcode_filters) = split_numeric_filters(
|
|
||||||
vec![numeric_filter(0), numeric_filter(1), numeric_filter(3)],
|
|
||||||
&listing_level_feature_idxs,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
listing_filters
|
|
||||||
.iter()
|
|
||||||
.map(|filter| filter.feat_idx)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![1, 3]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
postcode_filters
|
|
||||||
.iter()
|
|
||||||
.map(|filter| filter.feat_idx)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![0]
|
|
||||||
);
|
|
||||||
|
|
||||||
let (listing_enum_filters, postcode_enum_filters) = split_enum_filters(
|
|
||||||
vec![enum_filter(2), enum_filter(3)],
|
|
||||||
&listing_level_feature_idxs,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
listing_enum_filters
|
|
||||||
.iter()
|
|
||||||
.map(|filter| filter.feat_idx)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![3]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
postcode_enum_filters
|
|
||||||
.iter()
|
|
||||||
.map(|filter| filter.feat_idx)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![2]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn listing_floor_area_filter_keeps_unknown_values() {
|
fn listing_floor_area_filter_keeps_unknown_values() {
|
||||||
let floor_area_filter = ParsedFilter {
|
let floor_area_filter = ParsedFilter {
|
||||||
|
|
@ -436,4 +289,30 @@ mod tests {
|
||||||
&keep_unknown_filter_idxs
|
&keep_unknown_filter_idxs
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listing_poi_filter_uses_listing_metric_matrix() {
|
||||||
|
let filter = ParsedPoiFilter {
|
||||||
|
metric_idx: 1,
|
||||||
|
min_u16: 10,
|
||||||
|
max_u16: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(row_passes_listing_poi_filters(
|
||||||
|
0,
|
||||||
|
&[filter],
|
||||||
|
&[NAN_U16, 15],
|
||||||
|
2
|
||||||
|
));
|
||||||
|
assert!(!row_passes_listing_poi_filters(
|
||||||
|
0,
|
||||||
|
&[ParsedPoiFilter {
|
||||||
|
metric_idx: 1,
|
||||||
|
min_u16: 10,
|
||||||
|
max_u16: 20,
|
||||||
|
}],
|
||||||
|
&[NAN_U16, NAN_U16],
|
||||||
|
2
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,15 @@ use crate::data::{PostcodePoiMetrics, QuantRef};
|
||||||
use crate::features;
|
use crate::features;
|
||||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||||
use crate::parsing::{
|
use crate::parsing::{
|
||||||
parse_field_indices_with_poi, parse_filters_with_poi, require_bounds, row_passes_filters,
|
parse_bounds, parse_field_indices_with_poi, parse_filters_with_poi, row_passes_filters,
|
||||||
row_passes_poi_filters,
|
row_passes_poi_filters, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter,
|
||||||
};
|
};
|
||||||
use crate::routes::travel_time::{
|
use crate::routes::travel_time::{
|
||||||
load_travel_data, parse_optional_travel, row_passes_travel_filters,
|
load_travel_data, parse_optional_travel, row_passes_travel_filters,
|
||||||
};
|
};
|
||||||
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
|
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
|
use crate::utils::normalize_postcode;
|
||||||
|
|
||||||
const MAX_EXPORT_POSTCODES: usize = 250;
|
const MAX_EXPORT_POSTCODES: usize = 250;
|
||||||
const EXPORT_SCREENSHOT_TIMEOUT_SECS: u64 = 12;
|
const EXPORT_SCREENSHOT_TIMEOUT_SECS: u64 = 12;
|
||||||
|
|
@ -46,6 +47,9 @@ pub struct ExportParams {
|
||||||
travel: Option<String>,
|
travel: Option<String>,
|
||||||
fields: Option<String>,
|
fields: Option<String>,
|
||||||
share: Option<String>,
|
share: Option<String>,
|
||||||
|
/// Comma-separated list of postcodes for list-mode export. When supplied,
|
||||||
|
/// the bounds / filters / travel parameters are ignored.
|
||||||
|
postcodes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-postcode accumulator for export aggregation (mean for numeric, mode for enum).
|
/// Per-postcode accumulator for export aggregation (mean for numeric, mode for enum).
|
||||||
|
|
@ -193,6 +197,94 @@ fn collect_overlay_state_params(query: Option<&str>) -> Vec<String> {
|
||||||
collect_repeated_state_params(query, "overlay")
|
collect_repeated_state_params(query, "overlay")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A parsed, deduplicated, validated list of postcodes to export.
|
||||||
|
struct ParsedPostcodeList {
|
||||||
|
/// Resolved (postcode index, normalized postcode) pairs, preserving input order.
|
||||||
|
entries: Vec<(usize, String)>,
|
||||||
|
/// Postcodes the user supplied that were not found in the dataset.
|
||||||
|
unknown: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_postcode_list(
|
||||||
|
raw: &str,
|
||||||
|
state: &crate::state::AppState,
|
||||||
|
) -> Result<ParsedPostcodeList, axum::response::Response> {
|
||||||
|
let mut entries: Vec<(usize, String)> = Vec::new();
|
||||||
|
let mut unknown: Vec<String> = Vec::new();
|
||||||
|
let mut seen: FxHashSet<usize> = FxHashSet::default();
|
||||||
|
|
||||||
|
for raw_pc in raw.split([',', '\n', ';']) {
|
||||||
|
let trimmed = raw_pc.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let normalized = normalize_postcode(trimmed);
|
||||||
|
if normalized.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if entries.len() >= MAX_EXPORT_POSTCODES {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!(
|
||||||
|
"Too many postcodes; at most {} are supported per export",
|
||||||
|
MAX_EXPORT_POSTCODES
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
match state.postcode_data.postcode_to_idx.get(&normalized) {
|
||||||
|
Some(&pc_idx) if seen.insert(pc_idx) => {
|
||||||
|
entries.push((pc_idx, normalized));
|
||||||
|
}
|
||||||
|
Some(_) => {} // duplicate — skip silently
|
||||||
|
None => unknown.push(normalized),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"No valid postcodes supplied".to_string(),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedPostcodeList { entries, unknown })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tight bounding box around a set of postcode centroids (used for license checks).
|
||||||
|
fn bounds_for_postcode_indices(
|
||||||
|
indices: &[usize],
|
||||||
|
centroids: &[(f32, f32)],
|
||||||
|
) -> (f64, f64, f64, f64) {
|
||||||
|
let mut south = f64::INFINITY;
|
||||||
|
let mut west = f64::INFINITY;
|
||||||
|
let mut north = f64::NEG_INFINITY;
|
||||||
|
let mut east = f64::NEG_INFINITY;
|
||||||
|
for &idx in indices {
|
||||||
|
if let Some(&(lat, lon)) = centroids.get(idx) {
|
||||||
|
let lat = lat as f64;
|
||||||
|
let lon = lon as f64;
|
||||||
|
if lat < south {
|
||||||
|
south = lat;
|
||||||
|
}
|
||||||
|
if lat > north {
|
||||||
|
north = lat;
|
||||||
|
}
|
||||||
|
if lon < west {
|
||||||
|
west = lon;
|
||||||
|
}
|
||||||
|
if lon > east {
|
||||||
|
east = lon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !south.is_finite() {
|
||||||
|
return (0.0, 0.0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
(south, west, north, east)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_export(
|
pub async fn get_export(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|
@ -201,16 +293,42 @@ pub async fn get_export(
|
||||||
Query(params): Query<ExportParams>,
|
Query(params): Query<ExportParams>,
|
||||||
) -> Result<impl IntoResponse, axum::response::Response> {
|
) -> Result<impl IntoResponse, axum::response::Response> {
|
||||||
let state = shared.load_state();
|
let state = shared.load_state();
|
||||||
let (south, west, north, east) =
|
|
||||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
|
||||||
|
|
||||||
let area_deg2 = (north - south).max(0.0) * (east - west).max(0.0);
|
// Two modes: bounds-based (default) and explicit postcode list.
|
||||||
if area_deg2 > MAX_EXPORT_BBOX_AREA_DEG2 {
|
let postcode_list = match params.postcodes.as_deref() {
|
||||||
return Err((
|
Some(raw) if !raw.trim().is_empty() => Some(parse_postcode_list(raw, &state)?),
|
||||||
StatusCode::BAD_REQUEST,
|
_ => None,
|
||||||
"Export area is too large; zoom in further before exporting",
|
};
|
||||||
)
|
let is_postcode_mode = postcode_list.is_some();
|
||||||
.into_response());
|
if let Some(list) = postcode_list.as_ref() {
|
||||||
|
if !list.unknown.is_empty() {
|
||||||
|
warn!(unknown = ?list.unknown, "Export: unknown postcodes ignored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (south, west, north, east) = if let Some(list) = postcode_list.as_ref() {
|
||||||
|
let idxs: Vec<usize> = list.entries.iter().map(|(i, _)| *i).collect();
|
||||||
|
bounds_for_postcode_indices(&idxs, &state.postcode_data.centroids)
|
||||||
|
} else {
|
||||||
|
let raw = params.bounds.clone().ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"bounds or postcodes parameter is required",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
})?;
|
||||||
|
parse_bounds(&raw).map_err(IntoResponse::into_response)?
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_postcode_mode {
|
||||||
|
let area_deg2 = (north - south).max(0.0) * (east - west).max(0.0);
|
||||||
|
if area_deg2 > MAX_EXPORT_BBOX_AREA_DEG2 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Export area is too large; zoom in further before exporting",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||||
|
|
@ -218,24 +336,44 @@ pub async fn get_export(
|
||||||
|
|
||||||
let quant = state.data.quant_ref();
|
let quant = state.data.quant_ref();
|
||||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||||
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
|
let (parsed_filters, parsed_enum_filters, parsed_poi_filters): (
|
||||||
params.filters.as_deref(),
|
Vec<ParsedFilter>,
|
||||||
&state.feature_name_to_index,
|
Vec<ParsedEnumFilter>,
|
||||||
&state.data.enum_values,
|
Vec<ParsedPoiFilter>,
|
||||||
&quant,
|
) = if is_postcode_mode {
|
||||||
&state.data.poi_metrics.name_to_index,
|
(Vec::new(), Vec::new(), Vec::new())
|
||||||
&poi_quant,
|
} else {
|
||||||
)
|
parse_filters_with_poi(
|
||||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
params.filters.as_deref(),
|
||||||
|
&state.feature_name_to_index,
|
||||||
|
&state.data.enum_values,
|
||||||
|
&quant,
|
||||||
|
&state.data.poi_metrics.name_to_index,
|
||||||
|
&poi_quant,
|
||||||
|
)
|
||||||
|
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
|
||||||
|
};
|
||||||
let has_poi_filters = !parsed_poi_filters.is_empty();
|
let has_poi_filters = !parsed_poi_filters.is_empty();
|
||||||
let filters_str = params.filters;
|
let filters_str = if is_postcode_mode { None } else { params.filters };
|
||||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
let travel_entries = if is_postcode_mode {
|
||||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
parse_optional_travel(params.travel.as_deref())
|
||||||
|
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
|
||||||
|
};
|
||||||
let has_travel_filters = travel_entries
|
let has_travel_filters = travel_entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
|
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
|
||||||
let travel_state_params = collect_travel_state_params(uri.query());
|
let travel_state_params = if is_postcode_mode {
|
||||||
let overlay_state_params = collect_overlay_state_params(uri.query());
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
collect_travel_state_params(uri.query())
|
||||||
|
};
|
||||||
|
let overlay_state_params = if is_postcode_mode {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
collect_overlay_state_params(uri.query())
|
||||||
|
};
|
||||||
let fields_str = params.fields;
|
let fields_str = params.fields;
|
||||||
let share_code = params.share;
|
let share_code = params.share;
|
||||||
|
|
||||||
|
|
@ -260,29 +398,34 @@ pub async fn get_export(
|
||||||
share_code.as_deref(),
|
share_code.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch screenshot (async, before spawn_blocking)
|
// Screenshot only makes sense for the spatial / filter mode. In list mode the
|
||||||
let auth_header = headers.get(header::AUTHORIZATION);
|
// map view is unrelated to the selected postcodes, so we skip it.
|
||||||
let screenshot_fetch = fetch_screenshot_bytes(&state, &frontend_params, auth_header);
|
let screenshot_bytes = if is_postcode_mode {
|
||||||
let screenshot_bytes = match tokio::time::timeout(
|
None
|
||||||
Duration::from_secs(EXPORT_SCREENSHOT_TIMEOUT_SECS),
|
} else {
|
||||||
screenshot_fetch,
|
let auth_header = headers.get(header::AUTHORIZATION);
|
||||||
)
|
let screenshot_fetch = fetch_screenshot_bytes(&state, &frontend_params, auth_header);
|
||||||
.await
|
match tokio::time::timeout(
|
||||||
{
|
Duration::from_secs(EXPORT_SCREENSHOT_TIMEOUT_SECS),
|
||||||
Ok(Ok(bytes)) => {
|
screenshot_fetch,
|
||||||
info!(bytes = bytes.len(), "Fetched screenshot for export");
|
)
|
||||||
Some(bytes)
|
.await
|
||||||
}
|
{
|
||||||
Ok(Err(err)) => {
|
Ok(Ok(bytes)) => {
|
||||||
warn!("Screenshot failed for export: {err}");
|
info!(bytes = bytes.len(), "Fetched screenshot for export");
|
||||||
None
|
Some(bytes)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Ok(Err(err)) => {
|
||||||
warn!(
|
warn!("Screenshot failed for export: {err}");
|
||||||
timeout_secs = EXPORT_SCREENSHOT_TIMEOUT_SECS,
|
None
|
||||||
"Screenshot timed out for export"
|
}
|
||||||
);
|
Err(_) => {
|
||||||
None
|
warn!(
|
||||||
|
timeout_secs = EXPORT_SCREENSHOT_TIMEOUT_SECS,
|
||||||
|
"Screenshot timed out for export"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -302,6 +445,9 @@ pub async fn get_export(
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let postcode_list_entries: Option<Vec<(usize, String)>> =
|
||||||
|
postcode_list.map(|list| list.entries);
|
||||||
|
|
||||||
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
|
|
@ -319,75 +465,102 @@ pub async fn get_export(
|
||||||
// Build set of enum feature indices for quick lookup
|
// Build set of enum feature indices for quick lookup
|
||||||
let enum_indices: FxHashMap<usize, ()> = enum_values.keys().map(|&idx| (idx, ())).collect();
|
let enum_indices: FxHashMap<usize, ()> = enum_values.keys().map(|&idx| (idx, ())).collect();
|
||||||
|
|
||||||
// Aggregate directly by postcode so large requests don't retain every
|
let (postcode_aggs, was_sampled): (Vec<(usize, PostcodeExportAgg)>, bool) =
|
||||||
// matching property row before sampling the exported postcodes.
|
if let Some(entries) = postcode_list_entries.as_ref() {
|
||||||
let mut postcode_aggs: FxHashMap<usize, PostcodeExportAgg> = FxHashMap::default();
|
// List mode: iterate property rows for each requested postcode and
|
||||||
state
|
// produce results in the order the user supplied them.
|
||||||
.grid
|
let mut out: Vec<(usize, PostcodeExportAgg)> = Vec::with_capacity(entries.len());
|
||||||
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
for (pc_idx, _normalized) in entries {
|
||||||
let row = row_idx as usize;
|
let mut agg = PostcodeExportAgg::new(total_export_features);
|
||||||
if !row_passes_filters(
|
for &row_idx in state.data.rows_for_postcode(
|
||||||
row,
|
&postcode_data.postcodes[*pc_idx],
|
||||||
&parsed_filters,
|
) {
|
||||||
&parsed_enum_filters,
|
agg.add_row(
|
||||||
feature_data,
|
|
||||||
num_features,
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if has_poi_filters && !row_passes_poi_filters(row, &parsed_poi_filters, poi_metrics)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
|
||||||
if has_travel_filters
|
|
||||||
&& !row_passes_travel_filters(postcode, &travel_entries, &travel_data)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(&pc_idx) = postcode_data.postcode_to_idx.get(postcode) {
|
|
||||||
postcode_aggs
|
|
||||||
.entry(pc_idx)
|
|
||||||
.or_insert_with(|| PostcodeExportAgg::new(total_export_features))
|
|
||||||
.add_row(
|
|
||||||
feature_data,
|
feature_data,
|
||||||
row,
|
row_idx as usize,
|
||||||
num_features,
|
num_features,
|
||||||
&enum_indices,
|
&enum_indices,
|
||||||
&quant,
|
&quant,
|
||||||
poi_metrics,
|
poi_metrics,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if agg.count > 0 {
|
||||||
|
out.push((*pc_idx, agg));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
(out, false)
|
||||||
|
} else {
|
||||||
|
// Bounds mode: aggregate directly by postcode so large requests
|
||||||
|
// don't retain every matching property row before sampling.
|
||||||
|
let mut by_pc: FxHashMap<usize, PostcodeExportAgg> = FxHashMap::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 = pc_interner.resolve(&pc_keys[row]);
|
||||||
|
if has_travel_filters
|
||||||
|
&& !row_passes_travel_filters(postcode, &travel_entries, &travel_data)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(&pc_idx) = postcode_data.postcode_to_idx.get(postcode) {
|
||||||
|
by_pc.entry(pc_idx)
|
||||||
|
.or_insert_with(|| PostcodeExportAgg::new(total_export_features))
|
||||||
|
.add_row(
|
||||||
|
feature_data,
|
||||||
|
row,
|
||||||
|
num_features,
|
||||||
|
&enum_indices,
|
||||||
|
&quant,
|
||||||
|
poi_metrics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let mut postcode_aggs: Vec<(usize, PostcodeExportAgg)> = postcode_aggs
|
let mut aggs: Vec<(usize, PostcodeExportAgg)> = by_pc
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, agg)| agg.count > 0)
|
.filter(|(_, agg)| agg.count > 0)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by property count descending
|
// Sort by property count descending
|
||||||
postcode_aggs.sort_unstable_by_key(|agg| std::cmp::Reverse(agg.1.count));
|
aggs.sort_unstable_by_key(|agg| std::cmp::Reverse(agg.1.count));
|
||||||
|
|
||||||
// Sample if too many postcodes
|
let was_sampled = aggs.len() > MAX_EXPORT_POSTCODES;
|
||||||
let was_sampled = postcode_aggs.len() > MAX_EXPORT_POSTCODES;
|
if was_sampled {
|
||||||
if was_sampled {
|
let mut hasher = DefaultHasher::new();
|
||||||
let mut hasher = DefaultHasher::new();
|
south.to_bits().hash(&mut hasher);
|
||||||
south.to_bits().hash(&mut hasher);
|
west.to_bits().hash(&mut hasher);
|
||||||
west.to_bits().hash(&mut hasher);
|
north.to_bits().hash(&mut hasher);
|
||||||
north.to_bits().hash(&mut hasher);
|
east.to_bits().hash(&mut hasher);
|
||||||
east.to_bits().hash(&mut hasher);
|
let seed = hasher.finish();
|
||||||
let seed = hasher.finish();
|
|
||||||
|
|
||||||
let len = postcode_aggs.len();
|
let len = aggs.len();
|
||||||
for pick in 0..MAX_EXPORT_POSTCODES {
|
for pick in 0..MAX_EXPORT_POSTCODES {
|
||||||
let swap_idx = pick
|
let swap_idx = pick
|
||||||
+ ((seed.wrapping_mul(pick as u64 + 1).wrapping_add(pick as u64)) as usize
|
+ ((seed.wrapping_mul(pick as u64 + 1).wrapping_add(pick as u64))
|
||||||
% (len - pick));
|
as usize
|
||||||
postcode_aggs.swap(pick, swap_idx);
|
% (len - pick));
|
||||||
}
|
aggs.swap(pick, swap_idx);
|
||||||
postcode_aggs.truncate(MAX_EXPORT_POSTCODES);
|
}
|
||||||
postcode_aggs.sort_unstable_by_key(|agg| std::cmp::Reverse(agg.1.count));
|
aggs.truncate(MAX_EXPORT_POSTCODES);
|
||||||
}
|
aggs.sort_unstable_by_key(|agg| std::cmp::Reverse(agg.1.count));
|
||||||
|
}
|
||||||
|
(aggs, was_sampled)
|
||||||
|
};
|
||||||
|
|
||||||
// Determine column order: filter features first, then remaining
|
// Determine column order: filter features first, then remaining
|
||||||
let filter_feature_names = extract_filter_feature_names(filters_str.as_deref());
|
let filter_feature_names = extract_filter_feature_names(filters_str.as_deref());
|
||||||
|
|
@ -545,12 +718,18 @@ pub async fn get_export(
|
||||||
frontend_params
|
frontend_params
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sheet 1: "Selected" (filter features only) with link + screenshot
|
// Bounds mode: two sheets — "Selected" (filter features with link + screenshot)
|
||||||
// Sheet 2: "All Data" (all features)
|
// and "All Data" (all features).
|
||||||
let sheet_configs: [(&str, &[usize], bool); 2] = [
|
// List mode: single sheet "Postcodes" with all data, no link or screenshot
|
||||||
("Selected", &filter_feature_indices, true),
|
// (the supplied list isn't tied to a map view).
|
||||||
("All Data", &all_feature_indices, false),
|
let sheet_configs: Vec<(&str, &[usize], bool)> = if postcode_list_entries.is_some() {
|
||||||
];
|
vec![("Postcodes", &all_feature_indices, false)]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
("Selected", &filter_feature_indices, true),
|
||||||
|
("All Data", &all_feature_indices, false),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
for (sheet_name, feat_indices, include_header) in &sheet_configs {
|
for (sheet_name, feat_indices, include_header) in &sheet_configs {
|
||||||
let sheet = workbook.add_worksheet();
|
let sheet = workbook.add_worksheet();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"setup-auth": "tsc && node dist/auth.js",
|
"setup-auth": "tsc && node dist/auth.js",
|
||||||
"record": "tsc && node dist/record.js",
|
"record": "tsc && node dist/record.js",
|
||||||
"verify-output": "tsc && node dist/verify.js",
|
"verify-output": "tsc && node dist/verify.js",
|
||||||
|
"review": "./review.sh",
|
||||||
"render": "./render.sh"
|
"render": "./render.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@
|
||||||
# bootstrap step; you supply real account credentials.
|
# bootstrap step; you supply real account credentials.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./render.sh # local stack
|
# ./render.sh # local stack, English homepage landscape + portrait
|
||||||
# ./render.sh --prod # prod (requires LOGIN_EMAIL/LOGIN_PASSWORD)
|
# ./render.sh --prod # prod, English homepage landscape + portrait
|
||||||
# ./render.sh --target prod # same as --prod
|
# ./render.sh --target prod # same as --prod
|
||||||
|
# VIDEO_STORYBOARD_SET=ads ./render.sh --prod # render social ads instead
|
||||||
|
# VIDEO_STORYBOARD_SET=demo ./render.sh --prod # render every homepage locale
|
||||||
# ./render.sh --fresh-auth # force re-auth even if cache is fresh
|
# ./render.sh --fresh-auth # force re-auth even if cache is fresh
|
||||||
# ./render.sh --resume # preserve completed recordings and continue
|
# ./render.sh --resume # preserve completed recordings and continue
|
||||||
# ./render.sh --no-encode # stop at WebM, skip MP4 encode
|
# ./render.sh --no-encode # stop at WebM, skip MP4 encode
|
||||||
|
|
@ -312,6 +314,20 @@ if [ "$DO_AUDIO" = "1" ]; then
|
||||||
say "Synchronising tts/ Python deps"
|
say "Synchronising tts/ Python deps"
|
||||||
uv sync --project tts ${uv_sync_extras[@]+"${uv_sync_extras[@]}"} || fail "uv sync failed in video/tts"
|
uv sync --project tts ${uv_sync_extras[@]+"${uv_sync_extras[@]}"} || fail "uv sync failed in video/tts"
|
||||||
|
|
||||||
|
if [ -z "${TTS_DEVICE:-}" ]; then
|
||||||
|
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
|
||||||
|
gpu_free_mb="$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -d ' ')"
|
||||||
|
if [ "${gpu_free_mb:-0}" -ge 8000 ]; then
|
||||||
|
export TTS_DEVICE="cuda:0"
|
||||||
|
else
|
||||||
|
export TTS_DEVICE="cpu"
|
||||||
|
say "GPU has ${gpu_free_mb:-0}MiB free; using CPU for TTS to avoid CUDA OOM"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
export TTS_DEVICE="cpu"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Voice consistency: every ad in this set declares the same AD_VOICE
|
# Voice consistency: every ad in this set declares the same AD_VOICE
|
||||||
# (instruct/seed/temperature/topP/referenceText). Even with seed-locked
|
# (instruct/seed/temperature/topP/referenceText). Even with seed-locked
|
||||||
# VoiceDesign, independent invocations across processes can produce
|
# VoiceDesign, independent invocations across processes can produce
|
||||||
|
|
@ -325,10 +341,17 @@ if [ "$DO_AUDIO" = "1" ]; then
|
||||||
# which it always does, because every ad shares AD_VOICE.
|
# which it always does, because every ad shares AD_VOICE.
|
||||||
shared_ref_wav=""
|
shared_ref_wav=""
|
||||||
shared_ref_meta=""
|
shared_ref_meta=""
|
||||||
|
shared_audio_dir=""
|
||||||
for sb in "${STORYBOARDS[@]}"; do
|
for sb in "${STORYBOARDS[@]}"; do
|
||||||
if [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then
|
if [ -n "$shared_audio_dir" ] && [ -d "$shared_audio_dir" ]; then
|
||||||
mkdir -p "output/$sb/audio"
|
mkdir -p "output/$sb/audio"
|
||||||
cp -f "$shared_ref_wav" "output/$sb/audio/_reference.wav"
|
for cached_audio_file in "$shared_audio_dir"/*.wav "$shared_audio_dir"/*.json; do
|
||||||
|
[ -f "$cached_audio_file" ] || continue
|
||||||
|
cp -f "$cached_audio_file" "output/$sb/audio/$(basename "$cached_audio_file")"
|
||||||
|
done
|
||||||
|
elif [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then
|
||||||
|
mkdir -p "output/$sb/audio"
|
||||||
|
cp -f "$shared_ref_wav" "output/$sb/audio/_reference.wav"
|
||||||
cp -f "$shared_ref_meta" "output/$sb/audio/_reference.meta.json"
|
cp -f "$shared_ref_meta" "output/$sb/audio/_reference.meta.json"
|
||||||
fi
|
fi
|
||||||
say "Synthesising narration for [$sb]"
|
say "Synthesising narration for [$sb]"
|
||||||
|
|
@ -342,6 +365,9 @@ if [ "$DO_AUDIO" = "1" ]; then
|
||||||
shared_ref_meta="output/$sb/audio/_reference.meta.json"
|
shared_ref_meta="output/$sb/audio/_reference.meta.json"
|
||||||
say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set"
|
say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set"
|
||||||
fi
|
fi
|
||||||
|
if [ -z "$shared_audio_dir" ] && [ -s "output/$sb/audio/index.json" ]; then
|
||||||
|
shared_audio_dir="output/$sb/audio"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
117
video/review.sh
Executable file
|
|
@ -0,0 +1,117 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Extract visual and audio snippets from rendered homepage videos.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./review.sh # recording + recording-mobile
|
||||||
|
# ./review.sh recording ad-01-foo # explicit storyboard slugs
|
||||||
|
#
|
||||||
|
# Outputs land under output/review/current by default. Override REVIEW_DIR
|
||||||
|
# if you want to keep multiple passes side by side.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
REVIEW_DIR="${REVIEW_DIR:-output/review/current}"
|
||||||
|
mkdir -p "$REVIEW_DIR"
|
||||||
|
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
STORYBOARDS=("$@")
|
||||||
|
else
|
||||||
|
STORYBOARDS=(recording recording-mobile)
|
||||||
|
fi
|
||||||
|
|
||||||
|
for sb in "${STORYBOARDS[@]}"; do
|
||||||
|
src="output/$sb/recording.mp4"
|
||||||
|
if [ ! -s "$src" ]; then
|
||||||
|
echo "[review] missing rendered video: $src" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
width="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=noprint_wrappers=1:nokey=1 "$src")"
|
||||||
|
height="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=noprint_wrappers=1:nokey=1 "$src")"
|
||||||
|
scale=360
|
||||||
|
poster_t=16
|
||||||
|
if [ "$height" -gt "$width" ]; then
|
||||||
|
scale=240
|
||||||
|
poster_t=12
|
||||||
|
fi
|
||||||
|
|
||||||
|
ffprobe -v error \
|
||||||
|
-select_streams v:0 \
|
||||||
|
-show_entries stream=codec_name,width,height,avg_frame_rate \
|
||||||
|
-show_entries format=duration,size \
|
||||||
|
-of default=noprint_wrappers=1 \
|
||||||
|
"$src" > "$REVIEW_DIR/$sb-ffprobe.txt"
|
||||||
|
|
||||||
|
ffmpeg -nostdin -y -loglevel warning -i "$src" \
|
||||||
|
-vf "fps=1/4,scale=${scale}:-1:flags=lanczos,tile=5x3:padding=12:margin=12:color=white" \
|
||||||
|
-frames:v 1 -update 1 -q:v 2 "$REVIEW_DIR/$sb-contact.jpg"
|
||||||
|
|
||||||
|
ffmpeg -nostdin -y -loglevel warning -i "$src" -ss "$poster_t" \
|
||||||
|
-frames:v 1 -update 1 -q:v 2 "$REVIEW_DIR/$sb-postercheck-t${poster_t}.jpg"
|
||||||
|
|
||||||
|
ffmpeg -nostdin -y -loglevel warning -i "$src" -t 12 \
|
||||||
|
-vn -ac 1 -ar 24000 "$REVIEW_DIR/$sb-audio-first12.wav"
|
||||||
|
done
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r sb idx clip_start clip_dur midpoint; do
|
||||||
|
src="output/$sb/recording.mp4"
|
||||||
|
cue="$(printf '%02d' "$idx")"
|
||||||
|
|
||||||
|
ffmpeg -nostdin -y -loglevel warning -i "$src" -ss "$midpoint" \
|
||||||
|
-frames:v 1 -update 1 -q:v 2 "$REVIEW_DIR/$sb-cue-$cue-mid.jpg"
|
||||||
|
|
||||||
|
ffmpeg -nostdin -y -loglevel warning -ss "$clip_start" -i "$src" -t "$clip_dur" \
|
||||||
|
-c:v libx264 -pix_fmt yuv420p -crf 18 -preset veryfast \
|
||||||
|
-c:a aac -b:a 128k -movflags +faststart \
|
||||||
|
"$REVIEW_DIR/$sb-cue-$cue.mp4"
|
||||||
|
|
||||||
|
ffmpeg -nostdin -y -loglevel warning -ss "$clip_start" -i "$src" -t "$clip_dur" \
|
||||||
|
-vn -ac 1 -ar 24000 "$REVIEW_DIR/$sb-cue-$cue.wav"
|
||||||
|
done < <(node - "${STORYBOARDS[@]}" <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
const storyboards = process.argv.slice(2);
|
||||||
|
const review = process.env.REVIEW_DIR || 'output/review/current';
|
||||||
|
|
||||||
|
for (const sb of storyboards) {
|
||||||
|
const narration = JSON.parse(fs.readFileSync(`output/${sb}/narration.json`, 'utf8'));
|
||||||
|
const audioPath = `output/${sb}/audio/index.json`;
|
||||||
|
const audio = fs.existsSync(audioPath)
|
||||||
|
? JSON.parse(fs.readFileSync(audioPath, 'utf8'))
|
||||||
|
: { items: [] };
|
||||||
|
const byCue = new Map((audio.items || []).map((item) => [Number(item.cueIndex), item]));
|
||||||
|
const rows = ['cueIndex\tstartS\tendS\tdurationS\tgapBeforeMs\twav\ttext'];
|
||||||
|
|
||||||
|
narration.cues.forEach((cue, i) => {
|
||||||
|
const item = byCue.get(i) || {};
|
||||||
|
const startMs = Number(cue.videoTimeMs);
|
||||||
|
const durationMs = Number(item.durationMs || cue.durationMs);
|
||||||
|
const endMs = startMs + durationMs;
|
||||||
|
|
||||||
|
rows.push([
|
||||||
|
i,
|
||||||
|
(startMs / 1000).toFixed(3),
|
||||||
|
(endMs / 1000).toFixed(3),
|
||||||
|
(durationMs / 1000).toFixed(3),
|
||||||
|
item.gapBeforeMs ?? '',
|
||||||
|
item.wav ?? '',
|
||||||
|
cue.text,
|
||||||
|
].join('\t'));
|
||||||
|
|
||||||
|
console.log([
|
||||||
|
sb,
|
||||||
|
i,
|
||||||
|
(Math.max(0, startMs - 250) / 1000).toFixed(3),
|
||||||
|
((durationMs + 500) / 1000).toFixed(3),
|
||||||
|
((startMs + durationMs / 2) / 1000).toFixed(3),
|
||||||
|
].join('\t'));
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(`${review}/${sb}-timing.tsv`, rows.join('\n') + '\n');
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[review] wrote snippets to $REVIEW_DIR"
|
||||||
|
|
@ -46,6 +46,9 @@ export interface HexagonClickTarget {
|
||||||
|
|
||||||
type ApiKind = 'hexagons' | 'postcodes' | 'selection-stats' | 'tracked-api';
|
type ApiKind = 'hexagons' | 'postcodes' | 'selection-stats' | 'tracked-api';
|
||||||
|
|
||||||
|
const SELECTION_PANE_SELECTOR =
|
||||||
|
'[data-tutorial="right-pane"], .fixed.inset-0.z-50:has(button[aria-label="Close drawer"])';
|
||||||
|
|
||||||
const TRACKED_API_PATHS = new Set([
|
const TRACKED_API_PATHS = new Set([
|
||||||
'/api/ai-filters',
|
'/api/ai-filters',
|
||||||
'/api/export',
|
'/api/export',
|
||||||
|
|
@ -89,7 +92,8 @@ export class DashboardRecorder {
|
||||||
|
|
||||||
async waitForSelectionReady(afterSelectionVersion: number, timeoutMs = 12000): Promise<void> {
|
async waitForSelectionReady(afterSelectionVersion: number, timeoutMs = 12000): Promise<void> {
|
||||||
await this.page
|
await this.page
|
||||||
.locator('[data-tutorial="right-pane"]')
|
.locator(SELECTION_PANE_SELECTOR)
|
||||||
|
.first()
|
||||||
.waitFor({ state: 'visible', timeout: timeoutMs });
|
.waitFor({ state: 'visible', timeout: timeoutMs });
|
||||||
await this.waitForStable({ afterSelectionVersion, timeoutMs });
|
await this.waitForStable({ afterSelectionVersion, timeoutMs });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,18 @@ export async function installCursor(page: Page): Promise<void> {
|
||||||
body.__demo-aspect-horizontal #__demo-caption {
|
body.__demo-aspect-horizontal #__demo-caption {
|
||||||
bottom: 7%;
|
bottom: 7%;
|
||||||
}
|
}
|
||||||
|
body.__demo-aspect-horizontal #__demo-caption.placement-side {
|
||||||
|
left: auto;
|
||||||
|
right: 3.4%;
|
||||||
|
bottom: 10%;
|
||||||
|
transform: translate(28px, 0);
|
||||||
|
max-width: min(560px, 30vw);
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.18;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
/* Vertical default: upper-third. Kept compact so the map remains the
|
/* Vertical default: upper-third. Kept compact so the map remains the
|
||||||
primary visual in the social ad cuts. */
|
primary visual in the social ad cuts. */
|
||||||
body.__demo-aspect-vertical #__demo-caption {
|
body.__demo-aspect-vertical #__demo-caption {
|
||||||
|
|
@ -130,6 +142,9 @@ export async function installCursor(page: Page): Promise<void> {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
}
|
}
|
||||||
|
body.__demo-aspect-horizontal #__demo-caption.placement-side.visible {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#__demo-outro {
|
#__demo-outro {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
|
|
@ -565,13 +580,19 @@ export async function setAspectClass(
|
||||||
}, aspect);
|
}, aspect);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showCaption(page: Page, text: string): Promise<void> {
|
export async function showCaption(
|
||||||
await page.evaluate((t) => {
|
page: Page,
|
||||||
|
text: string,
|
||||||
|
placement?: 'side'
|
||||||
|
): Promise<void> {
|
||||||
|
await page.evaluate(({ t, placement }) => {
|
||||||
const el = document.getElementById('__demo-caption');
|
const el = document.getElementById('__demo-caption');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = t;
|
el.textContent = t;
|
||||||
|
el.classList.remove('placement-side');
|
||||||
|
if (placement) el.classList.add(`placement-${placement}`);
|
||||||
el.classList.add('visible');
|
el.classList.add('visible');
|
||||||
}, text);
|
}, { t: text, placement });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -74,11 +74,10 @@ export async function smoothMove(
|
||||||
* "Fake" type: progressively set the textarea value, dispatching
|
* "Fake" type: progressively set the textarea value, dispatching
|
||||||
* React-compatible input events.
|
* React-compatible input events.
|
||||||
*
|
*
|
||||||
* Cadence is generated as a per-char weight ratio (so spaces and punctuation
|
* Do not do one Playwright round-trip per character here. Long prompts can
|
||||||
* read as natural pauses), then **rescaled** so that the sum of delays equals
|
* turn a 4s typing budget into 9s of wall-clock time on a busy recorder.
|
||||||
* `totalDurationMs` exactly. The runner depends on this: it budgets a
|
* Instead, animate through paced chunks. It still reads as typing on video,
|
||||||
* specific number of ms for the type step, and any divergence would cascade
|
* but the runner can keep narration and visuals aligned.
|
||||||
* into narration drift.
|
|
||||||
*/
|
*/
|
||||||
export async function fakeType(
|
export async function fakeType(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
|
@ -86,17 +85,19 @@ export async function fakeType(
|
||||||
text: string,
|
text: string,
|
||||||
totalDurationMs: number
|
totalDurationMs: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const steps = text.length;
|
if (text.length === 0) {
|
||||||
if (steps === 0) {
|
|
||||||
if (totalDurationMs > 0) await sleep(totalDurationMs);
|
if (totalDurationMs > 0) await sleep(totalDurationMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const weights = computeTypingWeights(text);
|
const steps = Math.min(
|
||||||
const weightSum = weights.reduce((a, b) => a + b, 0);
|
text.length,
|
||||||
const msPerWeight = totalDurationMs / weightSum;
|
Math.max(1, Math.min(48, Math.round(totalDurationMs / 95)))
|
||||||
|
);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
for (let i = 1; i <= steps; i++) {
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
const charCount = Math.max(1, Math.round((i / steps) * text.length));
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ selector, value }) => {
|
({ selector, value }) => {
|
||||||
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
|
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
|
||||||
|
|
@ -110,27 +111,16 @@ export async function fakeType(
|
||||||
setValue.call(ta, value);
|
setValue.call(ta, value);
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
},
|
},
|
||||||
{ selector, value: text.slice(0, i) }
|
{ selector, value: text.slice(0, charCount) }
|
||||||
);
|
);
|
||||||
if (i < steps) {
|
if (i < steps) {
|
||||||
const ms = Math.max(0, Math.round(weights[i - 1] * msPerWeight));
|
const targetElapsed = (totalDurationMs * i) / steps;
|
||||||
if (ms > 0) await sleep(ms);
|
const waitMs = startedAt + targetElapsed - Date.now();
|
||||||
|
if (waitMs > 0) await sleep(waitMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeTypingWeights(text: string): number[] {
|
|
||||||
const cadence = [0.82, 1.08, 0.94, 1.22, 0.88, 1.14, 0.98, 1.28];
|
|
||||||
return Array.from(text, (char, index) => {
|
|
||||||
let weight = cadence[index % cadence.length];
|
|
||||||
if (char === ' ') weight += 0.9;
|
|
||||||
if (/[,.!?;:]/.test(char)) weight += 1.8;
|
|
||||||
const next = text[index + 1];
|
|
||||||
if (next === ' ' && index % 4 === 0) weight += 0.55;
|
|
||||||
return weight;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drag the right-hand thumb of a Radix slider to a target track fraction.
|
* Drag the right-hand thumb of a Radix slider to a target track fraction.
|
||||||
* Returns the final cursor position so callers can chain a smoothMove afterwards.
|
* Returns the final cursor position so callers can chain a smoothMove afterwards.
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ async function runCue(
|
||||||
videoTimeMs: cursor.ms + leadInMs,
|
videoTimeMs: cursor.ms + leadInMs,
|
||||||
durationMs: measuredAudioMs,
|
durationMs: measuredAudioMs,
|
||||||
});
|
});
|
||||||
await showCaption(ctx.page, cue.text);
|
await showCaption(ctx.page, cue.text, cue.captionPlacement);
|
||||||
|
|
||||||
const during = cue.during ?? [];
|
const during = cue.during ?? [];
|
||||||
const declaredSum = during.reduce((s, a) => s + a.durationMs, 0);
|
const declaredSum = during.reduce((s, a) => s + a.durationMs, 0);
|
||||||
|
|
@ -184,7 +184,36 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'click': {
|
case 'click': {
|
||||||
const to = await resolveTarget(ctx, step.target);
|
const selectionVersion = ctx.dashboard.getSelectionStatsVersion();
|
||||||
|
const candidates =
|
||||||
|
step.target.kind === 'hexagon' && step.waitForSelectionReady
|
||||||
|
? await ctx.dashboard.visibleHexagonTargets(4)
|
||||||
|
: [await resolveTarget(ctx, step.target)];
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
|
const to = candidates[i];
|
||||||
|
const moveMs = Math.max(120, Math.round(step.durationMs * 0.7));
|
||||||
|
await smoothMove(ctx.page, ctx.cursor, to, { durationMs: moveMs });
|
||||||
|
ctx.cursor = to;
|
||||||
|
await ctx.page.mouse.click(to.x, to.y);
|
||||||
|
if (!step.waitForSelectionReady) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.dashboard.waitForSelectionReady(
|
||||||
|
selectionVersion,
|
||||||
|
Math.min(step.timeoutMs ?? 12000, i === candidates.length - 1 ? 12000 : 4000)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error('Click did not open the selection pane');
|
||||||
|
}
|
||||||
|
case 'clickIfVisible': {
|
||||||
|
const to = await tryResolveTarget(ctx, step.target, step.timeoutMs ?? 700);
|
||||||
|
if (!to) return;
|
||||||
const moveMs = Math.max(120, Math.round(step.durationMs * 0.7));
|
const moveMs = Math.max(120, Math.round(step.durationMs * 0.7));
|
||||||
await smoothMove(ctx.page, ctx.cursor, to, { durationMs: moveMs });
|
await smoothMove(ctx.page, ctx.cursor, to, { durationMs: moveMs });
|
||||||
ctx.cursor = to;
|
ctx.cursor = to;
|
||||||
|
|
@ -196,16 +225,109 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
||||||
return;
|
return;
|
||||||
case 'mapZoom': {
|
case 'mapZoom': {
|
||||||
const point = await resolveTarget(ctx, step.target);
|
const point = await resolveTarget(ctx, step.target);
|
||||||
await ctx.page.mouse.move(point.x, point.y);
|
const mapVersion = ctx.dashboard.getMapDataVersion();
|
||||||
const perStepMs = Math.floor(step.durationMs / Math.max(1, step.steps));
|
|
||||||
const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA;
|
const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA;
|
||||||
for (let i = 0; i < step.steps; i++) {
|
const handled = await ctx.page.evaluate(
|
||||||
await ctx.page.mouse.wheel(0, delta);
|
async ({ x, y, steps, durationMs, direction }) => {
|
||||||
if (perStepMs > 0) await sleep(perStepMs);
|
const root = document.querySelector('.maplibregl-map') as HTMLElement | null;
|
||||||
|
const fiberKey = root
|
||||||
|
? Object.getOwnPropertyNames(root).find((key) => key.startsWith('__reactFiber$'))
|
||||||
|
: undefined;
|
||||||
|
let fiber = fiberKey ? (root as unknown as Record<string, unknown>)[fiberKey] : null;
|
||||||
|
let mapRef: unknown = null;
|
||||||
|
while (fiber && typeof fiber === 'object') {
|
||||||
|
const maybeFiber = fiber as {
|
||||||
|
ref?: { current?: unknown };
|
||||||
|
return?: unknown;
|
||||||
|
};
|
||||||
|
const current = maybeFiber.ref?.current;
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
typeof current === 'object' &&
|
||||||
|
typeof (current as { getMap?: unknown }).getMap === 'function'
|
||||||
|
) {
|
||||||
|
mapRef = current;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fiber = maybeFiber.return ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = (mapRef as { getMap?: () => unknown } | null)?.getMap?.();
|
||||||
|
if (!map || typeof map !== 'object') return false;
|
||||||
|
const mapApi = map as {
|
||||||
|
getCanvas: () => HTMLCanvasElement;
|
||||||
|
getZoom: () => number;
|
||||||
|
getMinZoom?: () => number;
|
||||||
|
getMaxZoom?: () => number;
|
||||||
|
unproject: (point: [number, number]) => unknown;
|
||||||
|
zoomTo: (
|
||||||
|
zoom: number,
|
||||||
|
options: { around?: unknown; duration?: number; essential?: boolean }
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof mapApi.getCanvas !== 'function' ||
|
||||||
|
typeof mapApi.getZoom !== 'function' ||
|
||||||
|
typeof mapApi.unproject !== 'function' ||
|
||||||
|
typeof mapApi.zoomTo !== 'function'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = mapApi.getCanvas().getBoundingClientRect();
|
||||||
|
const around = mapApi.unproject([x - rect.left, y - rect.top]);
|
||||||
|
const sign = direction === 'out' ? -1 : 1;
|
||||||
|
const zoomDelta = Math.max(0.25, Math.min(5.2, steps * 0.28)) * sign;
|
||||||
|
const minZoom = mapApi.getMinZoom?.() ?? 0;
|
||||||
|
const maxZoom = mapApi.getMaxZoom?.() ?? 22;
|
||||||
|
const targetZoom = Math.max(minZoom, Math.min(maxZoom, mapApi.getZoom() + zoomDelta));
|
||||||
|
mapApi.zoomTo(targetZoom, { around, duration: durationMs, essential: true });
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, durationMs));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
steps: step.steps,
|
||||||
|
durationMs: step.durationMs,
|
||||||
|
direction: step.direction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!handled) {
|
||||||
|
const perStepMs = Math.floor(step.durationMs / Math.max(1, step.steps));
|
||||||
|
await ctx.page.evaluate(
|
||||||
|
async ({ x, y, steps, durationMs, delta }) => {
|
||||||
|
const wait = (ms: number) =>
|
||||||
|
new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
const perStep = Math.floor(durationMs / Math.max(1, steps));
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const target = document.elementFromPoint(x, y) ?? document.querySelector('canvas');
|
||||||
|
target?.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
deltaY: delta,
|
||||||
|
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||||
|
view: window,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (perStep > 0) await wait(perStep);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ x: point.x, y: point.y, steps: step.steps, durationMs: step.durationMs, delta }
|
||||||
|
);
|
||||||
|
if (perStepMs > 0) await sleep(0);
|
||||||
|
}
|
||||||
|
if (step.waitForMapSettled) {
|
||||||
|
await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'dragSlider':
|
case 'dragSlider': {
|
||||||
|
const mapVersion = ctx.dashboard.getMapDataVersion();
|
||||||
ctx.cursor = await smoothDragSliderThumb(
|
ctx.cursor = await smoothDragSliderThumb(
|
||||||
ctx.page,
|
ctx.page,
|
||||||
step.thumbSelector,
|
step.thumbSelector,
|
||||||
|
|
@ -214,12 +336,21 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
||||||
step.toFraction,
|
step.toFraction,
|
||||||
step.durationMs
|
step.durationMs
|
||||||
);
|
);
|
||||||
|
if (step.waitForMapSettled) {
|
||||||
|
await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case 'submitForm':
|
}
|
||||||
|
case 'submitForm': {
|
||||||
|
const mapVersion = ctx.dashboard.getMapDataVersion();
|
||||||
await ctx.page.evaluate((selector) => {
|
await ctx.page.evaluate((selector) => {
|
||||||
document.querySelector<HTMLFormElement>(selector)?.requestSubmit();
|
document.querySelector<HTMLFormElement>(selector)?.requestSubmit();
|
||||||
}, step.formSelector);
|
}, step.formSelector);
|
||||||
|
if (step.waitForMapSettled) {
|
||||||
|
await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
case 'showOutro':
|
case 'showOutro':
|
||||||
await showOutro(ctx.page, step.brand, step.tagline, step.url);
|
await showOutro(ctx.page, step.brand, step.tagline, step.url);
|
||||||
return;
|
return;
|
||||||
|
|
@ -269,6 +400,30 @@ async function resolveTarget(
|
||||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryResolveTarget(
|
||||||
|
ctx: ScriptCtx,
|
||||||
|
target: Target,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<{ x: number; y: number } | null> {
|
||||||
|
if (target.kind !== 'element') {
|
||||||
|
try {
|
||||||
|
return await resolveTarget(ctx, target);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const locator = ctx.page.locator(target.selector).first();
|
||||||
|
try {
|
||||||
|
await locator.waitFor({ state: 'visible', timeout: timeoutMs });
|
||||||
|
const box = await locator.boundingBox({ timeout: timeoutMs });
|
||||||
|
if (!box) return null;
|
||||||
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load synth's measured cue durations. Falls back to a worst-case estimate
|
* Load synth's measured cue durations. Falls back to a worst-case estimate
|
||||||
* if the manifest is missing — that path is only used for ``--no-audio``
|
* if the manifest is missing — that path is only used for ``--no-audio``
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,15 @@ export type Activity =
|
||||||
/** Slide the cursor from its current position to `target`. */
|
/** Slide the cursor from its current position to `target`. */
|
||||||
| { kind: 'moveCursor'; target: Target; durationMs: number }
|
| { kind: 'moveCursor'; target: Target; durationMs: number }
|
||||||
/** Move + click + ripple. `durationMs` is the whole gesture, including settle. */
|
/** Move + click + ripple. `durationMs` is the whole gesture, including settle. */
|
||||||
| { kind: 'click'; target: Target; durationMs: number }
|
| {
|
||||||
|
kind: 'click';
|
||||||
|
target: Target;
|
||||||
|
durationMs: number;
|
||||||
|
waitForSelectionReady?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
/** Move + click when the target is visible; skip without failing otherwise. */
|
||||||
|
| { kind: 'clickIfVisible'; target: Target; durationMs: number; timeoutMs?: number }
|
||||||
/** Type into a textarea/input over exactly `durationMs`. */
|
/** Type into a textarea/input over exactly `durationMs`. */
|
||||||
| { kind: 'type'; selector: string; text: string; durationMs: number }
|
| { kind: 'type'; selector: string; text: string; durationMs: number }
|
||||||
/** Grow or shrink the visible cursor (CSS scale). */
|
/** Grow or shrink the visible cursor (CSS scale). */
|
||||||
|
|
@ -135,6 +143,8 @@ export type Activity =
|
||||||
steps: number;
|
steps: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
direction?: 'in' | 'out';
|
direction?: 'in' | 'out';
|
||||||
|
waitForMapSettled?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
/** Drag the right thumb of a Radix slider to a fraction in [0,1]. */
|
/** Drag the right thumb of a Radix slider to a fraction in [0,1]. */
|
||||||
| {
|
| {
|
||||||
|
|
@ -143,9 +153,17 @@ export type Activity =
|
||||||
trackSelector: string;
|
trackSelector: string;
|
||||||
toFraction: number;
|
toFraction: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
waitForMapSettled?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
/** Submit a form found by selector and wait `durationMs`. */
|
/** Submit a form found by selector and wait `durationMs`. */
|
||||||
| { kind: 'submitForm'; formSelector: string; durationMs: number }
|
| {
|
||||||
|
kind: 'submitForm';
|
||||||
|
formSelector: string;
|
||||||
|
durationMs: number;
|
||||||
|
waitForMapSettled?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
/** Reveal the closing brand card. */
|
/** Reveal the closing brand card. */
|
||||||
| { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number }
|
| { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number }
|
||||||
/** Reveal a full-screen ad-style overlay over the live map. */
|
/** Reveal a full-screen ad-style overlay over the live map. */
|
||||||
|
|
@ -182,6 +200,8 @@ export type Activity =
|
||||||
*/
|
*/
|
||||||
export interface Cue {
|
export interface Cue {
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Optional cue-specific caption layout for shots where the default lower-third hides the product. */
|
||||||
|
captionPlacement?: 'side';
|
||||||
gapBeforeMs: number;
|
gapBeforeMs: number;
|
||||||
during?: Activity[];
|
during?: Activity[];
|
||||||
tail?: Activity[];
|
tail?: Activity[];
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ type FormFactor = 'desktop' | 'mobile';
|
||||||
// most prominent thing on screen (it sits at the top of the bottom
|
// most prominent thing on screen (it sits at the top of the bottom
|
||||||
// sheet which covers ~44% of the viewport), so we skip the wrapper zoom
|
// sheet which covers ~44% of the viewport), so we skip the wrapper zoom
|
||||||
// entirely — see buildPre().
|
// entirely — see buildPre().
|
||||||
const AI_ZOOM_SCALE_DESKTOP = 2.4;
|
const AI_ZOOM_SCALE_DESKTOP = 2.05;
|
||||||
|
|
||||||
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
|
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
|
||||||
const TT_SLIDER_MAX = 120;
|
const TT_SLIDER_MAX = 120;
|
||||||
|
|
@ -54,14 +54,16 @@ const TT_DRAG_TO_MIN = 20;
|
||||||
// sheet, not the map).
|
// sheet, not the map).
|
||||||
const MAP_FOCUS_DESKTOP = vfrac(1140 / 1920, 605 / 1080);
|
const MAP_FOCUS_DESKTOP = vfrac(1140 / 1920, 605 / 1080);
|
||||||
const MAP_FOCUS_MOBILE = vfrac(0.5, 0.3);
|
const MAP_FOCUS_MOBILE = vfrac(0.5, 0.3);
|
||||||
|
const HOMEPAGE_RIGHT_PANE_SELECTOR =
|
||||||
|
'[data-tutorial="right-pane"], .fixed.inset-0.z-50:has(button[aria-label="Close drawer"])';
|
||||||
|
|
||||||
// Mobile mapZoom intensity. 6 wheel-steps from the initial zoom (12)
|
// Mobile mapZoom intensity. Keep mobile below the old 18-step drill that
|
||||||
// lands around zoom 14.5 — postcode polygons clearly visible, individual
|
// overshot into featureless street-level tiles, but make the homepage pass
|
||||||
// streets named, hex aggregation broken open. The previous 18-step
|
// visibly break from city blobs into postcode/street scale.
|
||||||
// drill ended past zoom 20 (street-level vector tiles only), so the
|
const MOBILE_MAP_ZOOM_STEPS = 9;
|
||||||
// click landed on featureless terrain.
|
const MOBILE_MAP_ZOOM_MS = 2200;
|
||||||
const MOBILE_MAP_ZOOM_STEPS = 6;
|
const DESKTOP_MAP_ZOOM_STEPS = 18;
|
||||||
const MOBILE_MAP_ZOOM_MS = 1400;
|
const DESKTOP_MAP_ZOOM_MS = 4300;
|
||||||
|
|
||||||
type RecordingLocale = 'en' | 'de' | 'zh' | 'hi';
|
type RecordingLocale = 'en' | 'de' | 'zh' | 'hi';
|
||||||
|
|
||||||
|
|
@ -74,6 +76,7 @@ interface RecordingLocalization {
|
||||||
promptText: string;
|
promptText: string;
|
||||||
travelTimeLabel: string;
|
travelTimeLabel: string;
|
||||||
exportButtonTitle: string;
|
exportButtonTitle: string;
|
||||||
|
colourMapTitle: string;
|
||||||
brand: {
|
brand: {
|
||||||
name: string;
|
name: string;
|
||||||
tagline: string;
|
tagline: string;
|
||||||
|
|
@ -84,6 +87,8 @@ interface RecordingLocalization {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
dashboard: string;
|
dashboard: string;
|
||||||
filters: string;
|
filters: string;
|
||||||
|
zoom: string;
|
||||||
|
open: string;
|
||||||
details: string;
|
details: string;
|
||||||
shortlist: string;
|
shortlist: string;
|
||||||
};
|
};
|
||||||
|
|
@ -105,22 +110,29 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
'strong Manchester accent.',
|
'strong Manchester accent.',
|
||||||
voiceReferenceText:
|
voiceReferenceText:
|
||||||
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
|
||||||
promptText: 'Flat under £300k, 35 min to Manchester, good schools, low crime, quiet streets',
|
promptText:
|
||||||
|
'First home under £315k, 35 min to Manchester, good schools, check crime, road noise, tree cover, fast broadband',
|
||||||
travelTimeLabel: 'Manchester city centre',
|
travelTimeLabel: 'Manchester city centre',
|
||||||
exportButtonTitle: 'Export to Excel',
|
exportButtonTitle: 'Export to Excel',
|
||||||
|
colourMapTitle: 'Colour map',
|
||||||
brand: {
|
brand: {
|
||||||
name: 'Perfect Postcode',
|
name: 'Perfect Postcode',
|
||||||
tagline: 'Know where to look before listings take over.',
|
tagline: 'Find the area before the house.',
|
||||||
url: BRAND_URL,
|
url: BRAND_URL,
|
||||||
},
|
},
|
||||||
cues: {
|
cues: {
|
||||||
describe: "Don't pick a home by scrolling listings.",
|
describe: 'A Manchester first-time buyer wants to stop wasting Saturdays on the wrong streets.',
|
||||||
prompt:
|
prompt:
|
||||||
'Describe what you want. Budget, commute, schools, whatever matters.',
|
'They type the whole brief: under £315k, thirty-five minutes to town, good schools, low crime, quieter roads, trees, and fast broadband.',
|
||||||
dashboard: 'The map lights up with every postcode in England that fits.',
|
dashboard:
|
||||||
filters: 'Move one slider. The map answers instantly.',
|
'The map keeps only the postcodes that match. The rest of the country drops away.',
|
||||||
details: 'Open any postcode. Sold prices. Schools. Crime. Noise. All on one screen.',
|
filters:
|
||||||
shortlist: 'Take your shortlist to the listings. Now you know where to search.',
|
'Now tweak it: cut the commute to twenty minutes and colour the map by travel time.',
|
||||||
|
zoom: 'Zoom in until the blobs become streets, parks, and postcode blocks.',
|
||||||
|
open: 'Open one block that still passes the filters.',
|
||||||
|
details:
|
||||||
|
'On the right, you can see why it passed: journey time, listing links, Street View, sold prices, schools, crime, the noise number, and the tree score.',
|
||||||
|
shortlist: 'Export those postcodes and only search there.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
|
|
@ -131,26 +143,29 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
'Calm and cheerful German male narrator with clear standard German pronunciation ' +
|
'Calm and cheerful German male narrator with clear standard German pronunciation ' +
|
||||||
'and a friendly, practical delivery.',
|
'and a friendly, practical delivery.',
|
||||||
voiceReferenceText:
|
voiceReferenceText:
|
||||||
'Willkommen zur Demonstration. Diese Sprecherstimme hören Sie im gesamten Video.',
|
'Willkommen zur Demonstration. Diese Sprecherstimme hörst du im gesamten Video.',
|
||||||
promptText:
|
promptText:
|
||||||
'Wohnung unter £300k, 35 Min. nach Manchester, gute Schulen, niedrige Kriminalität, ruhige Straßen',
|
'Wohnung unter £300k, 35 Min. nach Manchester, gute Schulen, niedrige Kriminalität, ruhige Straßen',
|
||||||
travelTimeLabel: 'Stadtzentrum Manchester',
|
travelTimeLabel: 'Stadtzentrum Manchester',
|
||||||
exportButtonTitle: 'Als Excel exportieren',
|
exportButtonTitle: 'Als Excel exportieren',
|
||||||
|
colourMapTitle: 'Karte einfärben',
|
||||||
brand: {
|
brand: {
|
||||||
name: 'Perfect Postcode',
|
name: 'Perfect Postcode',
|
||||||
tagline: 'Wissen, wo Sie suchen sollten, bevor Inserate Ihre Suche bestimmen.',
|
tagline: 'Wissen, wo du suchen solltest, bevor Inserate deine Suche bestimmen.',
|
||||||
url: BRAND_URL,
|
url: BRAND_URL,
|
||||||
},
|
},
|
||||||
cues: {
|
cues: {
|
||||||
describe: 'Wählen Sie kein Zuhause durch endloses Scrollen.',
|
describe: 'Wähle kein Zuhause durch endloses Scrollen.',
|
||||||
prompt:
|
prompt:
|
||||||
'Beschreiben Sie, was Ihnen wichtig ist. Budget, Pendelzeit, Schulen, alles.',
|
'Beschreibe, was dir wichtig ist. Budget, Pendelzeit, Schulen, alles.',
|
||||||
dashboard: 'Die Karte zeigt jede passende Postleitzahl in ganz England.',
|
dashboard: 'Die Karte zeigt jede passende Postleitzahl in ganz England.',
|
||||||
filters: 'Ein Regler bewegt sich. Die Karte antwortet sofort.',
|
filters: 'Ein Regler bewegt sich. Die Karte antwortet sofort.',
|
||||||
|
zoom: 'Jetzt von der Stadtansicht bis zu echten Straßen zoomen.',
|
||||||
|
open: 'Öffne einen Treffer und sieh, warum er übrig bleibt.',
|
||||||
details:
|
details:
|
||||||
'Öffnen Sie eine Postleitzahl. Preise. Schulen. Kriminalität. Lärm. Alles auf einer Karte.',
|
'Öffne eine Postleitzahl. Preise. Schulen. Kriminalität. Lärm. Alles auf einer Karte.',
|
||||||
shortlist:
|
shortlist:
|
||||||
'Mit dieser Auswahl zu den Inseraten. Sie wissen jetzt, wo Sie suchen sollen.',
|
'Mit dieser Auswahl zu den Inseraten. Du weißt jetzt, wo du suchen sollst.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
|
|
@ -164,6 +179,7 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
promptText: '30万英镑以内的公寓,35分钟到曼彻斯特,学校好,犯罪率低,街道安静',
|
promptText: '30万英镑以内的公寓,35分钟到曼彻斯特,学校好,犯罪率低,街道安静',
|
||||||
travelTimeLabel: '曼彻斯特市中心',
|
travelTimeLabel: '曼彻斯特市中心',
|
||||||
exportButtonTitle: '导出为 Excel',
|
exportButtonTitle: '导出为 Excel',
|
||||||
|
colourMapTitle: '为地图着色',
|
||||||
brand: {
|
brand: {
|
||||||
name: 'Perfect Postcode',
|
name: 'Perfect Postcode',
|
||||||
tagline: '先知道该看哪里,再让房源牵着你走。',
|
tagline: '先知道该看哪里,再让房源牵着你走。',
|
||||||
|
|
@ -174,6 +190,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
prompt: '用日常话告诉地图你想要的家。预算、通勤、学校,什么都行。',
|
prompt: '用日常话告诉地图你想要的家。预算、通勤、学校,什么都行。',
|
||||||
dashboard: '地图点亮每一个符合条件的英格兰邮编。',
|
dashboard: '地图点亮每一个符合条件的英格兰邮编。',
|
||||||
filters: '动一个滑块,地图立刻给答案。',
|
filters: '动一个滑块,地图立刻给答案。',
|
||||||
|
zoom: '现在从城市范围放大到真实街道。',
|
||||||
|
open: '打开一个匹配项,看看它为什么留下来。',
|
||||||
details: '打开任意邮编。成交价、学校、犯罪率、噪音,一目了然。',
|
details: '打开任意邮编。成交价、学校、犯罪率、噪音,一目了然。',
|
||||||
shortlist: '带着这份清单去房源网站。现在你知道该在哪儿找了。',
|
shortlist: '带着这份清单去房源网站。现在你知道该在哪儿找了。',
|
||||||
},
|
},
|
||||||
|
|
@ -190,6 +208,7 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
promptText: 'Flat under £300k, 35 min to Manchester, good schools, low crime, quiet streets',
|
promptText: 'Flat under £300k, 35 min to Manchester, good schools, low crime, quiet streets',
|
||||||
travelTimeLabel: 'Manchester city centre',
|
travelTimeLabel: 'Manchester city centre',
|
||||||
exportButtonTitle: 'Excel में निर्यात करें',
|
exportButtonTitle: 'Excel में निर्यात करें',
|
||||||
|
colourMapTitle: 'नक्शे को रंगें',
|
||||||
brand: {
|
brand: {
|
||||||
name: 'Perfect Postcode',
|
name: 'Perfect Postcode',
|
||||||
tagline: 'Know where to look before listings take over.',
|
tagline: 'Know where to look before listings take over.',
|
||||||
|
|
@ -201,6 +220,8 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
||||||
'Describe what you want. Budget, commute, schools, whatever matters.',
|
'Describe what you want. Budget, commute, schools, whatever matters.',
|
||||||
dashboard: 'The map lights up with every postcode in England that fits.',
|
dashboard: 'The map lights up with every postcode in England that fits.',
|
||||||
filters: 'Move one slider. The map answers instantly.',
|
filters: 'Move one slider. The map answers instantly.',
|
||||||
|
zoom: 'Now zoom in from the city pattern to actual streets.',
|
||||||
|
open: 'Open one match and see why it made the cut.',
|
||||||
details: 'Open any postcode. Sold prices. Schools. Crime. Noise. All on one screen.',
|
details: 'Open any postcode. Sold prices. Schools. Crime. Noise. All on one screen.',
|
||||||
shortlist: 'Take your shortlist to the listings. Now you know where to search.',
|
shortlist: 'Take your shortlist to the listings. Now you know where to search.',
|
||||||
},
|
},
|
||||||
|
|
@ -211,25 +232,23 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
||||||
const copy = RECORDING_LOCALIZATIONS[locale];
|
const copy = RECORDING_LOCALIZATIONS[locale];
|
||||||
const isMobile = formFactor === 'mobile';
|
const isMobile = formFactor === 'mobile';
|
||||||
const mapFocus = isMobile ? MAP_FOCUS_MOBILE : MAP_FOCUS_DESKTOP;
|
const mapFocus = isMobile ? MAP_FOCUS_MOBILE : MAP_FOCUS_DESKTOP;
|
||||||
const mapZoomSteps = isMobile ? MOBILE_MAP_ZOOM_STEPS : 18;
|
const mapZoomSteps = isMobile ? MOBILE_MAP_ZOOM_STEPS : DESKTOP_MAP_ZOOM_STEPS;
|
||||||
const mapZoomMs = isMobile ? MOBILE_MAP_ZOOM_MS : 1500;
|
const mapZoomMs = isMobile ? MOBILE_MAP_ZOOM_MS : DESKTOP_MAP_ZOOM_MS;
|
||||||
// Click target stays at the mapZoom focus point. On mobile we kept the
|
const colourTravelTime = el(`${TT_CARD_SELECTOR} button[title="${copy.colourMapTitle}"]`);
|
||||||
// zoom shallow (6 wheel-steps → ~zoom 14.5) specifically so the centre
|
const postcodeDemoTarget = isMobile
|
||||||
// of the visible map area lands on a real postcode polygon at that
|
? vfrac(320 / 540, 255 / 960)
|
||||||
// depth; using a vfrac target is deterministic and avoids needing a
|
: vfrac(1087 / 1920, 520 / 1080);
|
||||||
// `[data-tutorial="map"]` anchor in the MobileMapPage DOM (it has
|
const openPostcodeTarget = postcodeDemoTarget;
|
||||||
// none — that attribute lives only on DesktopMapPage).
|
const zoomPostcodeTarget = postcodeDemoTarget;
|
||||||
const clickTarget = mapFocus;
|
const cursorParkTarget = isMobile ? vfrac(0.12, 0.61) : vfrac(0.12, 0.18);
|
||||||
|
const definingCharacteristicsSelector =
|
||||||
|
'[data-tutorial="right-pane"] button:has-text("Defining characteristics"), ' +
|
||||||
|
'.fixed.inset-0.z-50:has(button[aria-label="Close drawer"]) button:has-text("Defining characteristics")';
|
||||||
|
|
||||||
// Cue 5 (shortlist) on mobile: the Export button lives inside the
|
|
||||||
// hidden hamburger menu, not in the header — opening it cleanly would
|
|
||||||
// need a localised aria-label lookup. Instead we pull the map back
|
|
||||||
// out to the filtered overview so the cut ends on a satisfying wide
|
|
||||||
// shot of the matching postcodes rather than the post-click zoom.
|
|
||||||
const shortlistActivities: Storyboard['cues'][number]['during'] =
|
const shortlistActivities: Storyboard['cues'][number]['during'] =
|
||||||
formFactor === 'desktop'
|
formFactor === 'desktop'
|
||||||
? [
|
? [
|
||||||
{ kind: 'zoomReset', durationMs: 900 },
|
{ kind: 'zoomReset', durationMs: 800 },
|
||||||
{
|
{
|
||||||
kind: 'click',
|
kind: 'click',
|
||||||
target: el(`button[title="${copy.exportButtonTitle}"]`),
|
target: el(`button[title="${copy.exportButtonTitle}"]`),
|
||||||
|
|
@ -237,14 +256,19 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
// Reverse the cue-4 zoom-in exactly so we land back on the
|
{
|
||||||
// initial filtered dashboard view (hexagons visible).
|
kind: 'click',
|
||||||
|
target: el('button[aria-label="Close drawer"]'),
|
||||||
|
durationMs: 650,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
kind: 'mapZoom',
|
kind: 'mapZoom',
|
||||||
target: mapFocus,
|
target: mapFocus,
|
||||||
steps: MOBILE_MAP_ZOOM_STEPS,
|
steps: MOBILE_MAP_ZOOM_STEPS,
|
||||||
durationMs: MOBILE_MAP_ZOOM_MS,
|
durationMs: MOBILE_MAP_ZOOM_MS,
|
||||||
direction: 'out',
|
direction: 'out',
|
||||||
|
waitForMapSettled: true,
|
||||||
|
timeoutMs: 12000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -252,7 +276,17 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
||||||
{
|
{
|
||||||
text: copy.cues.describe,
|
text: copy.cues.describe,
|
||||||
gapBeforeMs: 0,
|
gapBeforeMs: 0,
|
||||||
tail: [{ kind: 'wait', durationMs: 250 }],
|
during: isMobile
|
||||||
|
? [{ kind: 'wait', durationMs: 700 }]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
kind: 'zoomTo',
|
||||||
|
target: el('[data-tutorial="ai-filters"]'),
|
||||||
|
scale: AI_ZOOM_SCALE_DESKTOP,
|
||||||
|
durationMs: 900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tail: [{ kind: 'wait', durationMs: 150 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: copy.cues.prompt,
|
text: copy.cues.prompt,
|
||||||
|
|
@ -262,17 +296,25 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
||||||
kind: 'type',
|
kind: 'type',
|
||||||
selector: '[data-tutorial="ai-filters"] textarea',
|
selector: '[data-tutorial="ai-filters"] textarea',
|
||||||
text: copy.promptText,
|
text: copy.promptText,
|
||||||
durationMs: 3000,
|
durationMs: 4300,
|
||||||
},
|
},
|
||||||
{ kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1200 },
|
|
||||||
],
|
],
|
||||||
tail: [{ kind: 'wait', durationMs: 500 }],
|
tail: [{ kind: 'wait', durationMs: 120 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: copy.cues.dashboard,
|
text: copy.cues.dashboard,
|
||||||
gapBeforeMs: 300,
|
gapBeforeMs: 300,
|
||||||
during: [{ kind: 'zoomReset', durationMs: 1400 }],
|
during: [
|
||||||
tail: [{ kind: 'wait', durationMs: 500 }],
|
{
|
||||||
|
kind: 'submitForm',
|
||||||
|
formSelector: '[data-tutorial="ai-filters"] form',
|
||||||
|
durationMs: 2200,
|
||||||
|
waitForMapSettled: true,
|
||||||
|
timeoutMs: 15000,
|
||||||
|
},
|
||||||
|
{ kind: 'zoomReset', durationMs: 900 },
|
||||||
|
],
|
||||||
|
tail: [{ kind: 'wait', durationMs: 300 }],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -284,45 +326,106 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
||||||
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
|
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
|
||||||
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
|
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
|
||||||
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
|
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
|
||||||
durationMs: 1000,
|
durationMs: 1800,
|
||||||
},
|
},
|
||||||
|
{ kind: 'click', target: colourTravelTime, durationMs: 750 },
|
||||||
],
|
],
|
||||||
tail: [{ kind: 'wait', durationMs: 400 }],
|
tail: [{ kind: 'wait', durationMs: 350 }],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
text: copy.cues.details,
|
text: copy.cues.zoom,
|
||||||
gapBeforeMs: 500,
|
gapBeforeMs: 500,
|
||||||
during: [
|
during: [
|
||||||
{ kind: 'cursorScale', scale: 1.4, durationMs: 200 },
|
{ kind: 'cursorScale', scale: 1.4, durationMs: 200 },
|
||||||
{
|
{
|
||||||
kind: 'mapZoom',
|
kind: 'mapZoom',
|
||||||
target: mapFocus,
|
target: zoomPostcodeTarget,
|
||||||
steps: mapZoomSteps,
|
steps: mapZoomSteps,
|
||||||
durationMs: mapZoomMs,
|
durationMs: mapZoomMs,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tail: [
|
tail: [
|
||||||
// Wait for the post-zoom /api/postcodes response and a redraw
|
{ kind: 'moveCursor', target: cursorParkTarget, durationMs: 250 },
|
||||||
// before the click — otherwise the click can fire on a stale
|
{ kind: 'wait', durationMs: 120 },
|
||||||
// frame and miss the polygon.
|
],
|
||||||
{ kind: 'wait', durationMs: 500 },
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
text: copy.cues.open,
|
||||||
|
gapBeforeMs: 200,
|
||||||
|
during: [
|
||||||
{
|
{
|
||||||
kind: 'click',
|
kind: 'click',
|
||||||
target: clickTarget,
|
target: openPostcodeTarget,
|
||||||
durationMs: 700,
|
durationMs: 1200,
|
||||||
|
waitForSelectionReady: true,
|
||||||
|
timeoutMs: 6000,
|
||||||
},
|
},
|
||||||
{ kind: 'cursorScale', scale: 1, durationMs: 280 },
|
{ kind: 'cursorScale', scale: 1, durationMs: 250 },
|
||||||
// Linger so the climax cue lands on the right-pane reveal.
|
|
||||||
{ kind: 'wait', durationMs: 1500 },
|
|
||||||
],
|
],
|
||||||
|
tail: [{ kind: 'wait', durationMs: 300 }],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
text: copy.cues.details,
|
||||||
|
captionPlacement: isMobile ? undefined : 'side',
|
||||||
|
gapBeforeMs: 250,
|
||||||
|
during: isMobile
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
kind: 'scrollPane',
|
||||||
|
selector: HOMEPAGE_RIGHT_PANE_SELECTOR,
|
||||||
|
top: 430,
|
||||||
|
durationMs: 900,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'clickIfVisible',
|
||||||
|
target: el(definingCharacteristicsSelector),
|
||||||
|
durationMs: 650,
|
||||||
|
timeoutMs: 700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'scrollPane',
|
||||||
|
selector: HOMEPAGE_RIGHT_PANE_SELECTOR,
|
||||||
|
top: 700,
|
||||||
|
durationMs: 850,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
kind: 'zoomTo',
|
||||||
|
target: el(HOMEPAGE_RIGHT_PANE_SELECTOR),
|
||||||
|
scale: 1.35,
|
||||||
|
durationMs: 950,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'scrollPane',
|
||||||
|
selector: HOMEPAGE_RIGHT_PANE_SELECTOR,
|
||||||
|
top: 360,
|
||||||
|
durationMs: 850,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'clickIfVisible',
|
||||||
|
target: el(definingCharacteristicsSelector),
|
||||||
|
durationMs: 650,
|
||||||
|
timeoutMs: 700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'scrollPane',
|
||||||
|
selector: HOMEPAGE_RIGHT_PANE_SELECTOR,
|
||||||
|
top: 920,
|
||||||
|
durationMs: 850,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tail: [{ kind: 'wait', durationMs: 700 }],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
text: copy.cues.shortlist,
|
text: copy.cues.shortlist,
|
||||||
gapBeforeMs: 500,
|
gapBeforeMs: 500,
|
||||||
during: shortlistActivities,
|
during: shortlistActivities,
|
||||||
tail: [{ kind: 'wait', durationMs: 800 }],
|
tail: [{ kind: 'wait', durationMs: 650 }],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -344,26 +447,14 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
||||||
|
|
||||||
function buildPre(formFactor: FormFactor): Storyboard['pre'] {
|
function buildPre(formFactor: FormFactor): Storyboard['pre'] {
|
||||||
if (formFactor === 'mobile') {
|
if (formFactor === 'mobile') {
|
||||||
// Mobile skips the wrapper-zoom into the AI card. On a 540-wide
|
|
||||||
// viewport the bottom sheet already occupies ~44% of the screen
|
|
||||||
// and the AI card sits at the top of it — leaning further in would
|
|
||||||
// overflow the card width and crop the placeholder. We just clear
|
|
||||||
// the vignette and let the typing draw the eye.
|
|
||||||
return [
|
return [
|
||||||
{ kind: 'clearVignette', durationMs: 0 },
|
{ kind: 'clearVignette', durationMs: 0 },
|
||||||
{ kind: 'wait', durationMs: 400 },
|
{ kind: 'wait', durationMs: 120 },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ kind: 'clearVignette', durationMs: 0 },
|
{ kind: 'clearVignette', durationMs: 0 },
|
||||||
{ kind: 'wait', durationMs: 200 },
|
{ kind: 'wait', durationMs: 120 },
|
||||||
{
|
|
||||||
kind: 'zoomTo',
|
|
||||||
target: el('[data-tutorial="ai-filters"]'),
|
|
||||||
scale: AI_ZOOM_SCALE_DESKTOP,
|
|
||||||
durationMs: 1300,
|
|
||||||
},
|
|
||||||
{ kind: 'wait', durationMs: 140 },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,10 +543,13 @@ function createRecordingStoryboard(
|
||||||
// Filters returned by the AI stub. Keys MUST match real feature names
|
// Filters returned by the AI stub. Keys MUST match real feature names
|
||||||
// from /api/features (verified against the running server's schema).
|
// from /api/features (verified against the running server's schema).
|
||||||
stubbedFilters: {
|
stubbedFilters: {
|
||||||
'Property type': ['Flats/Maisonettes'],
|
'Property type': ['Flats/Maisonettes', 'Semi-Detached'],
|
||||||
'Estimated current price': [0, 300000],
|
'Estimated current price': [0, 315000],
|
||||||
'Serious crime per 1k residents (avg/yr)': [0, 55],
|
'Serious crime per 1k residents (avg/yr)': [0, 70],
|
||||||
'Outstanding primary schools within 2km': [1, 10],
|
'Good+ primary schools within 2km': [1, 10],
|
||||||
|
'Noise (dB)': [50, 70],
|
||||||
|
'Street tree density percentile': [25, 100],
|
||||||
|
'Max available download speed (Mbps)': ['100', '300', '1000'],
|
||||||
},
|
},
|
||||||
// Travel-time filters returned by the AI stub. Slug matches the real
|
// Travel-time filters returned by the AI stub. Slug matches the real
|
||||||
// /api/travel-destinations?mode=transit response.
|
// /api/travel-destinations?mode=transit response.
|
||||||
|
|
@ -481,6 +575,10 @@ function createRecordingStoryboard(
|
||||||
const RECORDING_LOCALES: readonly RecordingLocale[] = ['en', 'de', 'zh', 'hi'];
|
const RECORDING_LOCALES: readonly RecordingLocale[] = ['en', 'de', 'zh', 'hi'];
|
||||||
const RECORDING_FORM_FACTORS: readonly FormFactor[] = ['desktop', 'mobile'];
|
const RECORDING_FORM_FACTORS: readonly FormFactor[] = ['desktop', 'mobile'];
|
||||||
|
|
||||||
|
const ENGLISH_HOMEPAGE_STORYBOARDS: Storyboard[] = RECORDING_FORM_FACTORS.map((formFactor) =>
|
||||||
|
createRecordingStoryboard('en', formFactor)
|
||||||
|
);
|
||||||
|
|
||||||
const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) =>
|
const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) =>
|
||||||
RECORDING_FORM_FACTORS.map((formFactor) => createRecordingStoryboard(locale, formFactor))
|
RECORDING_FORM_FACTORS.map((formFactor) => createRecordingStoryboard(locale, formFactor))
|
||||||
);
|
);
|
||||||
|
|
@ -1271,14 +1369,21 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
||||||
|
|
||||||
const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard);
|
const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard);
|
||||||
|
|
||||||
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'ads';
|
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';
|
||||||
|
|
||||||
export const storyboards: Storyboard[] =
|
export const storyboards: Storyboard[] = (() => {
|
||||||
STORYBOARD_SET === 'demo'
|
switch (STORYBOARD_SET) {
|
||||||
? DEMO_STORYBOARDS
|
case 'homepage-en':
|
||||||
: STORYBOARD_SET === 'all'
|
return ENGLISH_HOMEPAGE_STORYBOARDS;
|
||||||
? [...AD_STORYBOARDS, ...DEMO_STORYBOARDS]
|
case 'demo':
|
||||||
: AD_STORYBOARDS;
|
return DEMO_STORYBOARDS;
|
||||||
|
case 'all':
|
||||||
|
return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS];
|
||||||
|
case 'ads':
|
||||||
|
default:
|
||||||
|
return AD_STORYBOARDS;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
export function getStoryboard(name: string): Storyboard {
|
export function getStoryboard(name: string): Storyboard {
|
||||||
const sb = storyboards.find((s) => s.name === name);
|
const sb = storyboards.find((s) => s.name === name);
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,6 @@ def main() -> int:
|
||||||
"aac",
|
"aac",
|
||||||
"-b:a",
|
"-b:a",
|
||||||
"192k",
|
"192k",
|
||||||
"-shortest",
|
|
||||||
"-movflags",
|
"-movflags",
|
||||||
"+faststart",
|
"+faststart",
|
||||||
str(out_path),
|
str(out_path),
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,61 @@ def cached_index_matches(
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def load_reusable_items(
|
||||||
|
index_path: Path,
|
||||||
|
cues: list[dict],
|
||||||
|
instruct: str,
|
||||||
|
language: str,
|
||||||
|
reference_text: str,
|
||||||
|
design_model: str,
|
||||||
|
clone_model: str,
|
||||||
|
reference_audio: str,
|
||||||
|
seed: int,
|
||||||
|
temperature: float,
|
||||||
|
top_p: float,
|
||||||
|
) -> dict[int, dict]:
|
||||||
|
"""Return cue-indexed cached items that match the current synth settings.
|
||||||
|
|
||||||
|
Unlike ``cached_index_matches`` this accepts a partial index, so a long
|
||||||
|
CPU synthesis run can be resumed cue-by-cue after an interruption.
|
||||||
|
"""
|
||||||
|
if not index_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
cached = json.loads(index_path.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
if cached.get("instruct") != instruct or cached.get("language") != language:
|
||||||
|
return {}
|
||||||
|
if cached.get("referenceText") != reference_text:
|
||||||
|
return {}
|
||||||
|
if cached.get("designModel") != design_model or cached.get("cloneModel") != clone_model:
|
||||||
|
return {}
|
||||||
|
if cached.get("referenceAudio", "") != reference_audio:
|
||||||
|
return {}
|
||||||
|
if int(cached.get("seed", -1)) != seed:
|
||||||
|
return {}
|
||||||
|
if float(cached.get("temperature", -1)) != temperature:
|
||||||
|
return {}
|
||||||
|
if float(cached.get("topP", -1)) != top_p:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
cue_by_index = {int(c["cueIndex"]): c for c in cues}
|
||||||
|
reusable: dict[int, dict] = {}
|
||||||
|
for item in cached.get("items", []):
|
||||||
|
cue_index = int(item.get("cueIndex", -1))
|
||||||
|
cue = cue_by_index.get(cue_index)
|
||||||
|
wav = item.get("wav")
|
||||||
|
if cue is None or not wav or not (index_path.parent / wav).exists():
|
||||||
|
continue
|
||||||
|
if cue["text"].strip() != str(item.get("text", "")).strip():
|
||||||
|
continue
|
||||||
|
if int(cue.get("gapBeforeMs", 0)) != int(item.get("gapBeforeMs", -1)):
|
||||||
|
continue
|
||||||
|
reusable[cue_index] = item
|
||||||
|
return reusable
|
||||||
|
|
||||||
|
|
||||||
def seed_everything(seed: int) -> None:
|
def seed_everything(seed: int) -> None:
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
np.random.seed(seed)
|
np.random.seed(seed)
|
||||||
|
|
@ -333,34 +388,74 @@ def main() -> int:
|
||||||
)
|
)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"[synth] cloning {len(texts)} cues from reference (x_vector_only) — one batched call",
|
f"[synth] cloning {len(texts)} cues from reference (x_vector_only)",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
for i, t in enumerate(texts):
|
for i, t in enumerate(texts):
|
||||||
print(f"[synth] {i:2d}: {t}", flush=True)
|
print(f"[synth] {i:2d}: {t}", flush=True)
|
||||||
|
|
||||||
clone_model = load_model(args.clone_model, args.device)
|
clone_model = load_model(args.clone_model, args.device)
|
||||||
seed_everything(seed)
|
out_index_base = {
|
||||||
wavs, sr = clone_model.generate_voice_clone(
|
"storyboard": args.storyboard,
|
||||||
text=texts,
|
"instruct": instruct,
|
||||||
language=language,
|
"language": language,
|
||||||
ref_audio=str(ref_wav_path),
|
"designModel": args.design_model,
|
||||||
ref_text=ref_text,
|
"cloneModel": args.clone_model,
|
||||||
x_vector_only_mode=True,
|
"referenceAudio": reference_audio_cache_key,
|
||||||
non_streaming_mode=True,
|
"referenceText": ref_text,
|
||||||
do_sample=True,
|
"seed": seed,
|
||||||
temperature=temperature,
|
"temperature": temperature,
|
||||||
top_p=top_p,
|
"topP": top_p,
|
||||||
|
}
|
||||||
|
index_path = audio_dir / "index.json"
|
||||||
|
reusable = load_reusable_items(
|
||||||
|
index_path,
|
||||||
|
cues,
|
||||||
|
instruct,
|
||||||
|
language,
|
||||||
|
reference_text,
|
||||||
|
args.design_model,
|
||||||
|
args.clone_model,
|
||||||
|
reference_audio_cache_key,
|
||||||
|
seed,
|
||||||
|
temperature,
|
||||||
|
top_p,
|
||||||
)
|
)
|
||||||
if len(wavs) != len(texts):
|
|
||||||
print(
|
def write_index(items: list[dict]) -> None:
|
||||||
f"[synth] model returned {len(wavs)} wavs for {len(texts)} cues",
|
index_path.write_text(json.dumps({**out_index_base, "items": items}, indent=2))
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for cue, audio in zip(cues, wavs):
|
for cue_index, cue in enumerate(cues):
|
||||||
|
cached_item = reusable.get(int(cue["cueIndex"]))
|
||||||
|
if cached_item:
|
||||||
|
items.append(cached_item)
|
||||||
|
write_index(items)
|
||||||
|
print(
|
||||||
|
f"[synth] reusing {cached_item['wav']} {int(cached_item['durationMs']):>5d}ms «{cue['text']}»",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
seed_everything(seed + cue_index)
|
||||||
|
wavs, sr = clone_model.generate_voice_clone(
|
||||||
|
text=[texts[cue_index]],
|
||||||
|
language=language,
|
||||||
|
ref_audio=str(ref_wav_path),
|
||||||
|
ref_text=ref_text,
|
||||||
|
x_vector_only_mode=True,
|
||||||
|
non_streaming_mode=True,
|
||||||
|
do_sample=True,
|
||||||
|
temperature=temperature,
|
||||||
|
top_p=top_p,
|
||||||
|
)
|
||||||
|
if len(wavs) != 1:
|
||||||
|
print(
|
||||||
|
f"[synth] model returned {len(wavs)} wavs for cue {cue_index}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
audio = wavs[0]
|
||||||
if hasattr(audio, "cpu"):
|
if hasattr(audio, "cpu"):
|
||||||
audio = audio.cpu().float().numpy()
|
audio = audio.cpu().float().numpy()
|
||||||
wav_name = f"cue_{cue['cueIndex']:03d}.wav"
|
wav_name = f"cue_{cue['cueIndex']:03d}.wav"
|
||||||
|
|
@ -377,25 +472,13 @@ def main() -> int:
|
||||||
"durationMs": duration_ms,
|
"durationMs": duration_ms,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
write_index(items)
|
||||||
print(
|
print(
|
||||||
f"[synth] wrote {wav_name} {duration_ms:>5d}ms «{cue['text']}»",
|
f"[synth] wrote {wav_name} {duration_ms:>5d}ms «{cue['text']}»",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
out_index = {
|
write_index(items)
|
||||||
"storyboard": args.storyboard,
|
|
||||||
"instruct": instruct,
|
|
||||||
"language": language,
|
|
||||||
"designModel": args.design_model,
|
|
||||||
"cloneModel": args.clone_model,
|
|
||||||
"referenceAudio": reference_audio_cache_key,
|
|
||||||
"referenceText": ref_text,
|
|
||||||
"seed": seed,
|
|
||||||
"temperature": temperature,
|
|
||||||
"topP": top_p,
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
(audio_dir / "index.json").write_text(json.dumps(out_index, indent=2))
|
|
||||||
total_ms = sum(it["gapBeforeMs"] + it["durationMs"] for it in items)
|
total_ms = sum(it["gapBeforeMs"] + it["durationMs"] for it in items)
|
||||||
print(
|
print(
|
||||||
f"[synth] [{args.storyboard}] {len(items)} cues, {total_ms}ms of audio (incl. gaps) -> {audio_dir}",
|
f"[synth] [{args.storyboard}] {len(items)} cues, {total_ms}ms of audio (incl. gaps) -> {audio_dir}",
|
||||||
|
|
|
||||||