diff --git a/Dockerfile b/Dockerfile
index bd543c8..7441776 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -56,4 +56,4 @@ EXPOSE 8001
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
CMD curl -f http://localhost:8001/health || exit 1
ENTRYPOINT ["./property-map-server"]
-CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/data/crime_by_postcode_by_year.parquet", "--area-crime-averages-path", "/app/data/area_crime_averages.parquet", "--population-path", "/app/data/population_by_postcode.parquet", "--developments-path", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"]
+CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/data/crime_by_postcode_by_year.parquet", "--crime-records-path", "/app/data/crime_records.parquet", "--area-crime-averages-path", "/app/data/area_crime_averages.parquet", "--population-path", "/app/data/population_by_postcode.parquet", "--developments-path", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"]
diff --git a/Makefile.data b/Makefile.data
index dbcf1c4..d576639 100644
--- a/Makefile.data
+++ b/Makefile.data
@@ -27,7 +27,10 @@ POSTCODES_RAW := $(DATA_DIR)/gb-postcodes-v5
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
MERGE_STAMP := $(DATA_DIR)/.merge_done
+PRICE_INPUTS := $(DATA_DIR)/price_inputs.parquet
+POSTCODE_CENTROIDS := $(DATA_DIR)/postcode_centroids.parquet
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
+PRICE_ESTIMATES := $(DATA_DIR)/price_estimates.parquet
PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/domestic-csv.zip
ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet
@@ -38,6 +41,8 @@ TENURE := $(DATA_DIR)/tenure_by_lsoa.parquet
CRIME_DIR := $(DATA_DIR)/crime
CRIME := $(DATA_DIR)/crime_by_postcode.parquet
CRIME_BY_YEAR := $(DATA_DIR)/crime_by_postcode_by_year.parquet
+CRIME_RECORDS := $(DATA_DIR)/crime_records.parquet
+AREA_CRIME_AVERAGES := $(DATA_DIR)/area_crime_averages.parquet
POPULATION := $(DATA_DIR)/population_by_postcode.parquet
CRIME_STAMP := $(CRIME_DIR)/.downloaded
NOISE := $(DATA_DIR)/road_noise.parquet
@@ -91,8 +96,11 @@ VALIDATE_OUTPUTS := uv run python -m pipeline.validate_outputs
POI_PROXIMITY_DEPS := pipeline/transform/poi_proximity.py pipeline/utils/poi_counts.py
MERGE_DEPS := pipeline/transform/merge.py
+AREA_CRIME_AVERAGES_DEPS := pipeline/transform/area_crime_averages.py
PRICE_INDEX_DEPS := pipeline/transform/price_estimation/index.py pipeline/transform/price_estimation/shrinkage.py pipeline/transform/price_estimation/utils.py
PRICE_ESTIMATE_DEPS := pipeline/transform/price_estimation/estimate.py pipeline/transform/price_estimation/knn.py pipeline/transform/price_estimation/utils.py
+PRICE_JOIN_DEPS := pipeline/transform/join_price_estimates.py pipeline/transform/price_estimation/utils.py
+PRICE_INPUTS_DEPS := pipeline/transform/property_base.py pipeline/utils/postcode_mapping.py
TREE_DENSITY_DEPS := pipeline/transform/tree_density.py
PC_BOUNDARIES_DEPS := pipeline/transform/postcode_boundaries/__main__.py \
pipeline/transform/postcode_boundaries/greenspace.py \
@@ -116,11 +124,11 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_
download-postcodes download-noise download-inspire download-crime \
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-nfi download-ofs-register download-places download-median-age download-population download-england-boundary download-rightmove-outcodes \
download-map-assets \
- transform-pois transform-epc-pp transform-crime transform-poi-proximity \
+ transform-pois transform-epc-pp transform-crime transform-poi-proximity transform-area-crime-averages \
transform-school-catchments transform-tree-density \
generate-postcode-boundaries generate-travel-times enrich-actual-listings download-development-sites
-prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times $(DEVELOPMENT_SITES) $(POPULATION) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX)
+prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times $(DEVELOPMENT_SITES) $(POPULATION) $(CRIME_RECORDS) $(AREA_CRIME_AVERAGES) | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX)
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX) --postcode-boundary-match "$(POSTCODES_PQ)::$(PC_BOUNDARIES)" --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX)
merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ)
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)"
@@ -178,6 +186,7 @@ transform-pois: $(POIS_FILTERED)
transform-epc-pp: $(EPC_PP)
transform-crime: $(CRIME)
transform-poi-proximity: $(POI_PROXIMITY)
+transform-area-crime-averages: $(AREA_CRIME_AVERAGES)
transform-school-catchments: $(SCHOOL_CATCH)
transform-tree-density: $(TREE_DENSITY_PC)
generate-postcode-boundaries: $(PC_BOUNDARIES_STAMP)
@@ -383,9 +392,9 @@ $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(OFSTE
$(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 $@
-$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py
+$(CRIME) $(CRIME_BY_YEAR) $(CRIME_RECORDS) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv"
- uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR)
+ uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) --output-records $(CRIME_RECORDS)
$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DEPS)
uv run python -m pipeline.transform.poi_proximity --arcgis $(ARCGIS) --pois $(POIS_FILTERED) --greenspace $(OS_GREENSPACE) --output $@
@@ -450,16 +459,42 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
$(POSTCODES_PQ) $(PROPERTIES_PQ) &: $(MERGE_STAMP)
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)"
-$(PRICE_INDEX): $(MERGE_STAMP) $(PRICE_INDEX_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ)
- uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@
+# Slim price-estimation inputs, built straight from epc_pp + arcgis (NOT the
+# merge outputs). This is what decouples the expensive index/kNN from merge:
+# adding an area or property column to merge does not change these files, so the
+# price index and estimates are reused and only the cheap join re-runs.
+$(PRICE_INPUTS) $(POSTCODE_CENTROIDS) &: $(EPC_PP) $(ARCGIS) $(PRICE_INPUTS_DEPS)
+ uv run python -m pipeline.transform.property_base --epc-pp $(EPC_PP) --arcgis $(ARCGIS) --output $(PRICE_INPUTS) --centroids $(POSTCODE_CENTROIDS)
+ $(VALIDATE_OUTPUTS) --parquet $(PRICE_INPUTS) --parquet $(POSTCODE_CENTROIDS)
+
+$(PRICE_INDEX): $(PRICE_INPUTS) $(POSTCODE_CENTROIDS) $(PRICE_INDEX_DEPS)
+ uv run python -m pipeline.transform.price_estimation.index --input $(PRICE_INPUTS) --postcodes $(POSTCODE_CENTROIDS) --output $@
$(VALIDATE_OUTPUTS) --parquet $@
-$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ)
+# Estimate prices on the slim inputs and write a standalone price_estimates.parquet
+# (natural key + the two estimate columns). Never touches properties.parquet.
+$(PRICE_ESTIMATES): $(PRICE_INPUTS) $(POSTCODE_CENTROIDS) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS)
+ uv run python -m pipeline.transform.price_estimation.estimate --input $(PRICE_INPUTS) --postcodes $(POSTCODE_CENTROIDS) --index $(PRICE_INDEX) --output $@
+ $(VALIDATE_OUTPUTS) --parquet $@
+
+# Join the estimate columns back onto properties.parquet by natural key
+# (idempotent, atomic). Re-runs whenever merge or the estimates change.
+$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_ESTIMATES) $(PRICE_JOIN_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) $(PRICE_INDEX)
@rm -f $@
- uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX)
+ uv run python -m pipeline.transform.join_price_estimates --properties $(PROPERTIES_PQ) --estimates $(PRICE_ESTIMATES)
$(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX)
@touch $@
+# ── Area crime averages (post-merge) ────────────────────
+# National / per-outcode / per-sector property-weighted mean headline crime
+# rates for the right pane. Depends on $(PRICES_STAMP) so it reads the
+# finalised properties.parquet (the price-estimate join rewrites it); only the
+# per-postcode property counts and the crime values from postcode.parquet are
+# used, both unaffected by that join.
+$(AREA_CRIME_AVERAGES): $(PRICES_STAMP) $(AREA_CRIME_AVERAGES_DEPS) | $(POSTCODES_PQ) $(PROPERTIES_PQ)
+ uv run python -m pipeline.transform.area_crime_averages --postcodes $(POSTCODES_PQ) --properties $(PROPERTIES_PQ) --output $@
+ $(VALIDATE_OUTPUTS) --parquet $@
+
$(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \
$(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
$(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \
diff --git a/docker-compose.yml b/docker-compose.yml
index 81a8413..45825c3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,7 +11,7 @@ services:
command: >
bash -c "
cargo install cargo-watch &&
- cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --population-path /app/property-data/population_by_postcode.parquet'
+ cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --crime-records-path /app/property-data/crime_records.parquet --area-crime-averages-path /app/property-data/area_crime_averages.parquet --population-path /app/property-data/population_by_postcode.parquet'
"
ports:
- "8001:8001"
diff --git a/frontend/public/assets/poi-icons/public_transport/london_dlr.svg b/frontend/public/assets/poi-icons/public_transport/london_dlr.svg
new file mode 100644
index 0000000..d1bc20e
--- /dev/null
+++ b/frontend/public/assets/poi-icons/public_transport/london_dlr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/assets/twemoji/1f688.png b/frontend/public/assets/twemoji/1f688.png
new file mode 100644
index 0000000..26aa866
Binary files /dev/null and b/frontend/public/assets/twemoji/1f688.png differ
diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs
index ec1fdcb..d0c4151 100644
--- a/frontend/scripts/check-translations.mjs
+++ b/frontend/scripts/check-translations.mjs
@@ -214,16 +214,41 @@ function readServerFeatureNames() {
return [...new Set(names)];
}
+// Names of the Enum/Numeric feature *configs* (each needs a description + detail
+// translation). We take the FIRST `name:` field after every Feature::Enum( /
+// Feature::Numeric( opening. This deliberately skips macro-generated configs
+// whose name is a `concat!(...)` expression (the crime rates — handled via
+// deriveLegacyCrimeKeys instead) and stops the lazy match from running past such
+// a config into an unrelated FeatureGroup `name:` (which previously made the
+// group name "Properties" look like a required feature).
function readServerFeatureConfigNames() {
const src = readFileSync(FEATURES_RS, 'utf8');
const names = [];
- const re = /Feature::(?:Enum|Numeric)\([^]*?name:\s*"((?:\\.|[^"\\])*)"/g;
- for (const match of src.matchAll(re)) {
- names.push(JSON.parse(`"${match[1]}"`));
+ const re = /Feature::(?:Enum|Numeric)\(/g;
+ let m;
+ while ((m = re.exec(src))) {
+ const after = src.slice(m.index + m[0].length);
+ const nm = after.match(/\bname:\s*(?:"((?:\\.|[^"\\])*)"|concat!)/);
+ if (nm && nm[1] !== undefined) names.push(JSON.parse(`"${nm[1]}"`));
}
return [...new Set(names)];
}
+// The crime-rate features are generated by the crime_rate_features! macro and
+// named " (/yr, 7y|2y)". Their description/detail translations are shared
+// across both windows under a single legacy " (avg/yr)" key. This mirror
+// of legacyCrimeFeatureKey() in descriptions.ts MUST use the same suffix regex,
+// derived straight from the server keys so the checker and the runtime lookup
+// stay in lock-step.
+function deriveLegacyCrimeKeys(serverKeys) {
+ const legacy = new Set();
+ for (const key of serverKeys) {
+ const match = key.match(/^(.*) \(\/yr, \d+y\)$/);
+ if (match) legacy.add(`${match[1]} (avg/yr)`);
+ }
+ return legacy;
+}
+
function readNamedRecord(file, varName) {
const sf = parseFile(join(I18N_DIR, file));
const init = findVarInitializer(sf, varName);
@@ -355,7 +380,7 @@ function checkLocales(supportedCodes) {
}
}
-function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys) {
+function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys, legacyCrimeKeys) {
const record = readNamedRecord(file, varName);
const expected = supportedCodes.filter((c) => c !== 'en');
const present = Object.keys(record);
@@ -397,10 +422,12 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys, required
}
}
- // Every key here must also be a translatable feature name in en.ts > server.
- // Otherwise the description is unreachable — ts() looks up server.${name}.
+ // Every key here must also be a translatable feature name in en.ts > server,
+ // or a legacy crime key that maps onto the per-window server keys (see
+ // deriveLegacyCrimeKeys / legacyCrimeFeatureKey). Otherwise the description is
+ // unreachable — ts() looks up server.${name}.
for (const key of union) {
- if (!serverKeys.has(key)) {
+ if (!serverKeys.has(key) && !legacyCrimeKeys.has(key)) {
fail(`${file}: key "${key}" has no matching entry in en.ts > server`);
}
}
@@ -478,16 +505,25 @@ function main() {
checkLocaleLoaders(supportedCodes);
const en = readLocale('en');
const serverKeys = new Set(Object.keys(en.server ?? {}));
- const sourceFeatureKeys = readServerFeatureConfigNames();
+ const legacyCrimeKeys = deriveLegacyCrimeKeys(serverKeys);
+ const requiredKeys = [...readServerFeatureConfigNames(), ...legacyCrimeKeys];
checkServerSourceCoverage(serverKeys);
checkRecordCoverage(
'descriptions.ts',
'descriptions',
supportedCodes,
serverKeys,
- sourceFeatureKeys
+ requiredKeys,
+ legacyCrimeKeys
+ );
+ checkRecordCoverage(
+ 'details.ts',
+ 'details',
+ supportedCodes,
+ serverKeys,
+ requiredKeys,
+ legacyCrimeKeys
);
- checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys, sourceFeatureKeys);
checkForbiddenVisibleStrings();
for (const w of warnings) console.warn(`warn: ${w}`);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1274f4e..5e03eb6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -12,7 +12,7 @@ import {
} from './lib/seoRoutes';
import Header, { type Page } from './components/ui/Header';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
-import { fetchWithRetry, apiUrl, logNonAbortError, readDemoChoice, setDemoCenter } from './lib/api';
+import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import pb from './lib/pocketbase';
@@ -209,34 +209,6 @@ export default function App() {
}, []);
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
const [mapUrlState, setMapUrlState] = useState(urlState);
- // Demo location: a fresh visitor (no explicit URL view) is offered the "check
- // your area" prompt (in MapPage). A choice made earlier this session is reused —
- // GPS coords re-centre the map and are sent to the server (via lib/api), while
- // 'declined' keeps Canary Wharf. Resolve it once, before children fetch, so the
- // very first data request already carries the centre.
- // Read license synchronously from the persisted session so we never apply the
- // demo location for a returning licensed user (they bypass the free zone, and we
- // don't want their location in request URLs).
- const licensedAtMount = useMemo(
- () =>
- pb.authStore.isValid &&
- (pb.authStore.record?.subscription === 'licensed' || pb.authStore.record?.is_admin === true),
- []
- );
- const initialDemoChoice = useMemo(() => {
- const choice = readDemoChoice();
- if (choice && choice !== 'declined' && !licensedAtMount) setDemoCenter(choice);
- return choice;
- }, [licensedAtMount]);
- const [demoView] = useState(() =>
- initialDemoChoice && initialDemoChoice !== 'declined' && !licensedAtMount
- ? {
- ...INITIAL_VIEW_STATE,
- latitude: initialDemoChoice.lat,
- longitude: initialDemoChoice.lon,
- }
- : null
- );
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
window.location.pathname === '/dashboard' ? window.location.search : ''
);
@@ -248,8 +220,8 @@ export default function App() {
);
const activePageRef = useRef('home');
const initialViewState = useMemo(
- () => (mapUrlState.hasExplicitView ? mapUrlState.viewState : (demoView ?? INITIAL_VIEW_STATE)),
- [mapUrlState.hasExplicitView, mapUrlState.viewState, demoView]
+ () => (mapUrlState.hasExplicitView ? mapUrlState.viewState : INITIAL_VIEW_STATE),
+ [mapUrlState.hasExplicitView, mapUrlState.viewState]
);
const isScreenshotMode = useMemo(() => {
@@ -302,16 +274,6 @@ export default function App() {
refreshAuth,
clearError,
} = useAuth();
- // Only offer the "check your area" demo prompt to non-licensed visitors on a
- // fresh visit. `user` is hydrated synchronously from the persisted PocketBase
- // session, so a returning premium user is recognised at mount (no flash).
- const offerDemoLocation =
- !urlState.hasExplicitView && initialDemoChoice == null && !hasFullAccess(user);
- // Stop transmitting the demo location once the user is licensed (in-session
- // upgrade, cross-tab sign-in, or a stale session that refreshed to licensed).
- useEffect(() => {
- if (hasFullAccess(user)) setDemoCenter(null);
- }, [user]);
const { startCheckout: startPostAuthCheckout } = useLicense();
const [showAuthModal, setShowAuthModal] = useState(false);
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
@@ -351,8 +313,8 @@ export default function App() {
if (!completed) {
setPostAuthIntent(null);
postAuthCheckoutReturnPathRef.current = null;
- // Only protected pages bounce home; the dashboard stays open in demo
- // mode (server-enforced free zone) when the modal is dismissed.
+ // Only protected pages bounce home; the dashboard stays open when the
+ // modal is dismissed.
if (isProtectedPage(activePageRef.current)) {
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
setRouteHash('');
@@ -611,9 +573,8 @@ export default function App() {
const isProtectedPageActive = isProtectedPage(activePage);
// Only protected pages (account/saved) prompt for login on entry. The
- // dashboard opens straight into demo mode (server-enforced free zone) so
- // visitors can try it without logging in; the upgrade modal still appears
- // when they pan outside the free zone.
+ // dashboard opens straight away so visitors can try it without logging in;
+ // the upgrade modal appears when they hit the free filter cap.
useEffect(() => {
if (authLoading) return;
if (isProtectedPageActive && !user) {
@@ -768,7 +729,6 @@ export default function App() {
poiCategoryGroups={poiCategoryGroups}
initialFilters={mapUrlState.filters}
initialViewState={initialViewState}
- offerDemoLocation={offerDemoLocation}
initialPOICategories={mapUrlState.poiCategories}
initialOverlays={mapUrlState.overlays}
initialCrimeTypes={mapUrlState.crimeTypes}
diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx
index 0c18e12..3e19e44 100644
--- a/frontend/src/components/account/AccountPage.tsx
+++ b/frontend/src/components/account/AccountPage.tsx
@@ -383,7 +383,9 @@ export function SavedPage({
})
.catch((err) => {
if (!cancelled) {
- setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links');
+ setShareLinksError(
+ err instanceof Error ? err.message : t('accountPage.fetchShareLinksError')
+ );
}
})
.finally(() => {
@@ -393,7 +395,7 @@ export function SavedPage({
return () => {
cancelled = true;
};
- }, []);
+ }, [t]);
const tabClass = (tab: string) =>
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
@@ -738,7 +740,7 @@ function InviteSection({ user }: { user: AuthUser }) {
setInviteUrl((prev) => ({ ...prev, [type]: data.url }));
fetchInviteHistory();
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Failed to create invite';
+ const msg = err instanceof Error ? err.message : t('invitesPage.createInviteError');
setInviteError((prev) => ({ ...prev, [type]: msg }));
} finally {
setCreatingInvite((prev) => ({ ...prev, [type]: false }));
@@ -931,7 +933,8 @@ export default function AccountPage({
assertOk(res, 'Update newsletter');
await onRefreshAuth();
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Failed to update newsletter';
+ const msg =
+ err instanceof Error ? err.message : t('accountPage.updateNewsletterError');
setNewsletterError(msg);
} finally {
setNewsletterSaving(false);
diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx
index 70cc34d..d43ba0b 100644
--- a/frontend/src/components/home/HomePage.tsx
+++ b/frontend/src/components/home/HomePage.tsx
@@ -150,7 +150,7 @@ function ProductDemoVideo() {
diff --git a/frontend/src/components/home/ProductShowcase.tsx b/frontend/src/components/home/ProductShowcase.tsx
index b4bc291..4273c72 100644
--- a/frontend/src/components/home/ProductShowcase.tsx
+++ b/frontend/src/components/home/ProductShowcase.tsx
@@ -74,7 +74,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
prefix: '£',
},
{
- name: 'Serious crime (avg/yr)',
+ name: 'Serious crime (/yr, 7y)',
type: 'numeric',
group: 'Crime',
min: 0,
@@ -763,7 +763,7 @@ function RightPaneOnlyScreen({
{t('home.showcasePoliticalVoteShare')}
- 2024 GE
+ {t('home.showcaseGe2024')}
setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
>
-
+
{!isPlaying && (
diff --git a/frontend/src/components/legal/legal-content.ts b/frontend/src/components/legal/legal-content.ts
index 7ea6a7d..edd923f 100644
--- a/frontend/src/components/legal/legal-content.ts
+++ b/frontend/src/components/legal/legal-content.ts
@@ -41,14 +41,14 @@ export const TERMS: LegalDoc = {
{
heading: '2. Accounts',
paragraphs: [
- 'You need an account to use the service beyond the free demo area. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
+ 'You can browse the map without an account. To save searches or buy lifetime access you need an account. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
'We may suspend or close accounts that breach these terms, abuse the service, or attempt to circumvent access restrictions. If we close your account without cause, we will refund the price you paid.',
],
},
{
- heading: '3. Free demo and lifetime access',
+ heading: '3. Free access and lifetime access',
paragraphs: [
- 'Free accounts can explore all features within the demo area (inner London). Lifetime access is a one-time payment that gives your account ongoing access to the paid map — every postcode, every filter — for as long as the service runs. It is not a subscription, and routine data updates are included.',
+ 'Free users can explore the full map of England with every feature, applying up to three filters at a time. Lifetime access is a one-time payment that removes the filter limit and gives your account ongoing access — every postcode, every filter, unlimited — for as long as the service runs. It is not a subscription, and routine data updates are included.',
'Lifetime access is personal and non-transferable, and is for personal, non-commercial property research. If you would like to use Perfect Postcode commercially (for example in lettings, relocation or research services), contact us first.',
],
},
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx
index 64ebd40..d990940 100644
--- a/frontend/src/components/map/AreaPane.tsx
+++ b/frontend/src/components/map/AreaPane.tsx
@@ -1,15 +1,8 @@
-import {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- type MutableRefObject,
- type ReactNode,
-} from 'react';
+import { useCallback, useMemo, useState, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type {
+ CrimeRecordsResponse,
FeatureFilters,
FeatureGroup,
FeatureMeta,
@@ -18,7 +11,11 @@ import type {
} from '../../types';
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
-import { formatStationDistance, type NearbyStation } from '../../lib/nearby-stations';
+import {
+ formatStationDistance,
+ stationDisplayName,
+ type NearbyStation,
+} from '../../lib/nearby-stations';
import {
formatValue,
formatFilterValue,
@@ -39,12 +36,14 @@ import {
} from '../../lib/consts';
import { useNearbyStations } from '../../hooks/useNearbyStations';
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
+import { useRevealOnExpand } from '../../hooks/useRevealOnExpand';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import CrimeYearChart from './CrimeYearChart';
+import CrimeGroupBody from './CrimeGroupBody';
import NumberLine, { type NumberLinePoint } from './NumberLine';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, TransitIcon } from '../ui/icons';
@@ -72,6 +71,7 @@ interface AreaPaneProps {
shareCode?: string;
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
+ onLoadCrimeRecords?: (offset: number) => Promise;
scrollTopRef?: MutableRefObject;
scrollRestoreKey?: string | null;
scrollSaveDisabled?: boolean;
@@ -215,7 +215,7 @@ function NearbyStationRow({ station }: { station: NearbyStation }) {
)}
@@ -242,6 +242,7 @@ export default function AreaPane({
shareCode,
isGroupExpanded,
onToggleGroup,
+ onLoadCrimeRecords,
scrollTopRef,
scrollRestoreKey,
scrollSaveDisabled,
@@ -297,61 +298,22 @@ export default function AreaPane({
// When the user expands a group, scroll the bottom of the newly opened content
// into view so the whole group is revealed without manual scrolling. The group
// header stays pinned at the top via its sticky positioning.
- const scrollContainerRef = useRef(null);
+ const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
const setScrollNode = useCallback(
(node: HTMLDivElement | null) => {
- scrollContainerRef.current = node;
+ setContainer(node);
scrollRef(node);
},
- [scrollRef]
+ [setContainer, scrollRef]
);
- const groupRefs = useRef