From 5e73287eafa9ba4b5d61f3141050496cd5b65c44 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 25 Jun 2026 22:29:52 +0100 Subject: [PATCH] lgtm --- Dockerfile | 2 +- Makefile.data | 51 +- docker-compose.yml | 2 +- .../src/components/account/AccountPage.tsx | 8 +- frontend/src/components/home/HomePage.tsx | 2 +- .../src/components/home/ProductShowcase.tsx | 4 +- frontend/src/components/invite/InvitePage.tsx | 4 +- frontend/src/components/learn/LearnPage.tsx | 2 +- frontend/src/components/map/AreaPane.tsx | 112 ++-- .../src/components/map/CrimeGroupBody.tsx | 204 ++++++ .../components/map/CrimeRecordsSection.tsx | 147 +++++ .../src/components/map/CrimeYearChart.tsx | 7 +- .../components/map/ExternalSearchLinks.tsx | 2 +- .../src/components/map/FeatureBrowser.tsx | 14 +- frontend/src/components/map/Filters.tsx | 207 +++++- frontend/src/components/map/HoverCard.tsx | 9 + .../map/JourneyInstructions.test.tsx | 2 + .../components/map/JourneyInstructions.tsx | 22 +- frontend/src/components/map/ListingPopups.tsx | 40 +- frontend/src/components/map/Map.tsx | 10 +- .../src/components/map/MapErrorBoundary.tsx | 9 +- frontend/src/components/map/MapPage.tsx | 3 + frontend/src/components/map/MobileDrawer.tsx | 6 +- frontend/src/components/map/OverlayPane.tsx | 48 +- frontend/src/components/map/PoiPopupCard.tsx | 43 +- .../src/components/map/PropertiesPane.tsx | 6 +- .../src/components/map/StackedBarChart.tsx | 1 + .../map/filters/ActiveFilterList.tsx | 127 +++- .../map/filters/ActiveFiltersPanel.tsx | 20 +- .../components/map/filters/AddFilterPanel.tsx | 49 +- ...meFilterCard.tsx => VariantFilterCard.tsx} | 179 ++++-- .../map/map-page/DesktopMapPage.tsx | 9 +- .../components/map/map-page/MobileMapPage.tsx | 16 +- .../components/map/map-page/derivedState.ts | 6 + .../ui/IndeterminateProgressBar.tsx | 6 +- frontend/src/hooks/useAiFilters.ts | 6 +- frontend/src/hooks/useAuth.ts | 18 +- frontend/src/hooks/useClickedListings.test.ts | 98 +++ frontend/src/hooks/useClickedListings.ts | 82 +++ frontend/src/hooks/useDeckLayers.ts | 33 +- frontend/src/hooks/useFilters.ts | 164 ++++- frontend/src/hooks/useHexagonSelection.ts | 27 +- frontend/src/hooks/useLicense.ts | 6 +- frontend/src/hooks/useListingLayers.ts | 60 +- frontend/src/hooks/useMapData.ts | 6 + frontend/src/hooks/usePoiLayers.test.ts | 12 +- frontend/src/hooks/usePoiLayers.ts | 8 +- frontend/src/hooks/useRevealOnExpand.test.tsx | 80 +++ frontend/src/hooks/useRevealOnExpand.ts | 97 +++ frontend/src/hooks/useSavedSearches.ts | 26 +- frontend/src/hooks/useTutorial.ts | 7 + frontend/src/index.tsx | 10 +- frontend/src/lib/api.test.ts | 46 +- frontend/src/lib/api.ts | 29 +- frontend/src/lib/consts.ts | 64 +- frontend/src/lib/crime-filter.test.ts | 79 +++ frontend/src/lib/crime-filter.ts | 105 +++- .../src/lib/crime-severity-filter.test.ts | 85 +++ frontend/src/lib/crime-severity-filter.ts | 193 ++++++ frontend/src/lib/crime-types.ts | 51 +- frontend/src/lib/feature-icons.tsx | 7 +- frontend/src/lib/qualification-filter.ts | 117 +++- frontend/src/lib/tenure-filter.ts | 121 ++++ frontend/src/lib/url-state.test.ts | 108 +++- frontend/src/lib/url-state.ts | 106 ++++ frontend/src/lib/variant-filter.ts | 80 +++ frontend/src/types.ts | 27 +- pipeline/transform/area_crime_averages.py | 212 +++++++ pipeline/transform/crime_spatial.py | 591 +++++++++++------- pipeline/transform/join_price_estimates.py | 149 +++++ pipeline/transform/merge.py | 214 ++----- .../transform/postcode_boundaries/README.md | 5 +- .../postcode_boundaries/greenspace.py | 44 +- .../transform/postcode_boundaries/output.py | 164 +++++ .../test_postcode_boundaries.py | 231 +++++++ .../transform/price_estimation/estimate.py | 67 +- pipeline/transform/price_estimation/utils.py | 20 + pipeline/transform/property_base.py | 217 +++++++ .../transform/test_area_crime_averages.py | 81 +++ pipeline/transform/test_crime_spatial.py | 396 ++++++------ .../transform/test_join_price_estimates.py | 136 ++++ pipeline/transform/test_merge.py | 79 ++- server-rs/src/data.rs | 2 + server-rs/src/data/area_crime_averages.rs | 254 +++++++- server-rs/src/data/crime_records.rs | 534 ++++++++++++++++ server-rs/src/data/property/mod.rs | 105 ---- server-rs/src/data/spill.rs | 233 +++++++ server-rs/src/features.rs | 338 +++------- server-rs/src/main.rs | 39 +- server-rs/src/pocketbase.rs | 43 ++ server-rs/src/routes.rs | 2 + server-rs/src/routes/ai_filters/parsing.rs | 4 +- server-rs/src/routes/ai_filters/prompt.rs | 12 +- server-rs/src/routes/crime_records.rs | 194 ++++++ server-rs/src/routes/hexagon_stats.rs | 62 +- server-rs/src/routes/postcode_stats.rs | 6 + server-rs/src/routes/shorten.rs | 21 + server-rs/src/routes/stats.rs | 50 +- server-rs/src/state.rs | 12 +- 99 files changed, 6392 insertions(+), 1462 deletions(-) create mode 100644 frontend/src/components/map/CrimeGroupBody.tsx create mode 100644 frontend/src/components/map/CrimeRecordsSection.tsx rename frontend/src/components/map/filters/{SpecificCrimeFilterCard.tsx => VariantFilterCard.tsx} (51%) create mode 100644 frontend/src/hooks/useClickedListings.test.ts create mode 100644 frontend/src/hooks/useClickedListings.ts create mode 100644 frontend/src/hooks/useRevealOnExpand.test.tsx create mode 100644 frontend/src/hooks/useRevealOnExpand.ts create mode 100644 frontend/src/lib/crime-filter.test.ts create mode 100644 frontend/src/lib/crime-severity-filter.test.ts create mode 100644 frontend/src/lib/crime-severity-filter.ts create mode 100644 frontend/src/lib/tenure-filter.ts create mode 100644 frontend/src/lib/variant-filter.ts create mode 100644 pipeline/transform/area_crime_averages.py create mode 100644 pipeline/transform/join_price_estimates.py create mode 100644 pipeline/transform/property_base.py create mode 100644 pipeline/transform/test_area_crime_averages.py create mode 100644 pipeline/transform/test_join_price_estimates.py create mode 100644 server-rs/src/data/crime_records.rs create mode 100644 server-rs/src/routes/crime_records.rs 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/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 0c18e12..a067b91 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -383,7 +383,7 @@ 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 +393,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 +738,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 +931,7 @@ 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/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 64ebd40..8b0b80d 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, @@ -39,12 +32,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 +67,7 @@ interface AreaPaneProps { shareCode?: string; isGroupExpanded: (name: string) => boolean; onToggleGroup: (name: string) => void; + onLoadCrimeRecords?: (offset: number) => Promise; scrollTopRef?: MutableRefObject; scrollRestoreKey?: string | null; scrollSaveDisabled?: boolean; @@ -242,6 +238,7 @@ export default function AreaPane({ shareCode, isGroupExpanded, onToggleGroup, + onLoadCrimeRecords, scrollTopRef, scrollRestoreKey, scrollSaveDisabled, @@ -297,61 +294,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>(new Map()); - // A fresh object each toggle so re-expanding the same group still re-triggers. - const [groupToReveal, setGroupToReveal] = useState<{ name: string } | null>(null); const handleToggleGroup = useCallback( (name: string) => { const willExpand = !isGroupExpanded(name); onToggleGroup(name); - setGroupToReveal(willExpand ? { name } : null); + revealGroupOnToggle(name, willExpand); }, - [isGroupExpanded, onToggleGroup] + [isGroupExpanded, onToggleGroup, revealGroupOnToggle] ); - useEffect(() => { - if (!groupToReveal) return; - const el = groupRefs.current.get(groupToReveal.name); - const container = scrollContainerRef.current; - if (!el || !container) return; - - // Scroll down just enough to bring the bottom of the opened group into view. - // For groups taller than the pane this scrolls past the header, but it stays - // visible via its sticky positioning. Never scroll up (group already in view). - const alignBottom = () => { - const delta = el.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom; - if (delta <= 1) return; - container.scrollTo({ top: container.scrollTop + delta }); - }; - - alignBottom(); - - // The group's charts and async data (e.g. nearby stations) can grow its - // height after this first pass, so keep the bottom pinned as it settles. - // Stop once the user scrolls or after a short grace period so we never fight - // a deliberate scroll. - let timer = 0; - const observer = new ResizeObserver(alignBottom); - const stop = () => { - observer.disconnect(); - container.removeEventListener('wheel', stop); - container.removeEventListener('touchmove', stop); - window.clearTimeout(timer); - }; - observer.observe(el); - container.addEventListener('wheel', stop, { passive: true }); - container.addEventListener('touchmove', stop, { passive: true }); - timer = window.setTimeout(stop, 1500); - - return stop; - }, [groupToReveal]); const numericByName = useMemo(() => { if (!stats) return new Map(); @@ -363,26 +321,21 @@ export default function AreaPane({ return new Map(stats.enum_features.map((feature) => [feature.name, feature])); }, [stats]); - // Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary"). - // We also index by the configured feature name (with " (avg/yr)" suffix) so the - // metric-row renderer can look it up using the feature name it already has. - const crimeByYearByFeatureName = useMemo(() => { + // Crime-by-year series, keyed by the bare crime type (e.g. "Burglary"). + const crimeByYearByType = useMemo(() => { const map = new Map[number]>(); for (const entry of stats?.crime_by_year ?? []) { map.set(entry.name, entry); - map.set(`${entry.name} (avg/yr)`, entry); } return map; }, [stats]); - // Per-crime-type outcode/sector averages, keyed by both the bare crime type - // and the " (avg/yr)" feature name (same convention as crimeByYearByFeatureName) - // so renderers can look them up by the feature name they already hold. + // Per-rate-feature outcode/sector averages, keyed by the FULL rate-feature name + // (e.g. "Burglary (/yr, 7y)") the comparison is computed against. const crimeAreaAvgByName = useMemo(() => { const map = new Map[number]>(); for (const entry of stats?.crime_area_averages ?? []) { map.set(entry.name, entry); - map.set(`${entry.name} (avg/yr)`, entry); } return map; }, [stats]); @@ -440,7 +393,11 @@ export default function AreaPane({ <>
-
+
@@ -615,13 +572,7 @@ export default function AreaPane({ ); return ( -
{ - if (el) groupRefs.current.set(group.name, el); - else groupRefs.current.delete(group.name); - }} - > +
{showNearbyStations && } + {group.name === 'Crime' ? ( + + ) : ( + <> {stackedCharts?.map((chart) => { const segments = chart.components .map((name) => ({ @@ -651,7 +615,7 @@ export default function AreaPane({ ? aggregateStats.mean : displaySegments.reduce((sum, s) => sum + s.value, 0); - // Use rateFeature (e.g. per-1k) for display if available + // Use rateFeature (e.g. a percentage) for display if available const rateStats = chart.rateFeature ? numericByName.get(chart.rateFeature) : undefined; @@ -715,7 +679,7 @@ export default function AreaPane({ if (total === 0) return null; const crimeSeries = chart.feature - ? crimeByYearByFeatureName.get(chart.feature) + ? crimeByYearByType.get(chart.feature) : undefined; return ( @@ -800,7 +764,7 @@ export default function AreaPane({ const globalMean = globalHistogram ? calculateHistogramMean(globalHistogram) : undefined; - const crimeSeries = crimeByYearByFeatureName.get(feature.name); + const crimeSeries = crimeByYearByType.get(feature.name); const crimeAreaAvg = crimeAreaAvgByName.get(feature.name); // National avg is shown for every metric here as a // tooltip; for crime metrics the outcode and sector @@ -992,6 +956,8 @@ export default function AreaPane({
); })} + + )}
)}
diff --git a/frontend/src/components/map/CrimeGroupBody.tsx b/frontend/src/components/map/CrimeGroupBody.tsx new file mode 100644 index 0000000..20e8181 --- /dev/null +++ b/frontend/src/components/map/CrimeGroupBody.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ts } from '../../i18n/server'; +import type { + CrimeAreaAverage, + CrimeRecordsResponse, + CrimeYearStats, + FeatureMeta, + HexagonStatsResponse, + NumericFeatureStats, +} from '../../types'; +import { STACKED_GROUPS, STACKED_SEGMENT_COLORS } from '../../lib/consts'; +import { formatValue, calculateHistogramMean } from '../../lib/format'; +import { FeatureLabel } from '../ui/FeatureLabel'; +import StackedBarChart from './StackedBarChart'; +import NumberLine, { type NumberLinePoint } from './NumberLine'; +import CrimeYearChart from './CrimeYearChart'; +import CrimeRecordsSection from './CrimeRecordsSection'; + +type CrimeWindow = '7y' | '2y'; + +/** Strip the " (/yr, 7y|2y)" suffix to the bare crime type. */ +function bareType(featureName: string): string { + return featureName.replace(/ \(\/yr, \dy\)$/, ''); +} +/** Re-target a crime-feature name at the chosen averaging window. */ +function toWindow(featureName: string, window: CrimeWindow): string { + return featureName.replace(/(\/yr, )\dy/, `$1${window}`); +} + +/** + * Segment colours keyed by the BARE crime type. The stacked bar then labels each + * segment with the clean crime name (e.g. "Burglary") and keeps one stable colour + * regardless of whether the 7-year or 2-year window is selected. + */ +const SEGMENT_COLOR_BY_BARE: Record = Object.fromEntries( + Object.entries(STACKED_SEGMENT_COLORS).map(([name, color]) => [bareType(name), color]) +); + +interface CrimeGroupBodyProps { + stats: HexagonStatsResponse; + numericByName: Map; + /** Keyed by the FULL crime-feature name (e.g. "Burglary (/yr, 7y)"). */ + crimeAreaAvgByName: Map; + /** Keyed by the bare crime type (e.g. "Burglary"). */ + crimeByYearByType: Map; + globalFeatureByName: Map; + onShowInfo: (feature: FeatureMeta) => void; + onLoadCrimeRecords?: (offset: number) => Promise; + selectionKey: string | null; +} + +export default function CrimeGroupBody({ + stats, + numericByName, + crimeAreaAvgByName, + crimeByYearByType, + globalFeatureByName, + onShowInfo, + onLoadCrimeRecords, + selectionKey, +}: CrimeGroupBodyProps) { + const { t } = useTranslation(); + // One selector drives every value and comparison in this group. + const [crimeWindow, setCrimeWindow] = useState('7y'); + + const fmtCount = (value?: number) => (value == null ? '—' : formatValue(value)); + + const rollupCards = STACKED_GROUPS.Crime ?? []; + + return ( + <> +
+ + {t('areaPane.crimeWindowLabel')} + +
+ {(['7y', '2y'] as const).map((w) => { + const active = crimeWindow === w; + return ( + + ); + })} +
+
+ + {rollupCards.map((card) => { + if (!card.feature) return null; + const bare = bareType(card.feature); + const windowFeature = toWindow(card.feature, crimeWindow); + const rate = numericByName.get(windowFeature)?.mean; + + const segments = card.components + .map((name) => ({ + name: bareType(name), + value: numericByName.get(toWindow(name, crimeWindow))?.mean ?? 0, + })) + .filter((s) => s.value > 0); + const total = segments.reduce((sum, s) => sum + s.value, 0); + if (rate == null && total === 0) return null; + + const displayValue = rate ?? total; + // Info popup / icon stay anchored to the 7-year feature so the metric + // definition is stable across windows; only the numbers change. + const featureMeta = globalFeatureByName.get(card.feature); + const globalMean = featureMeta?.histogram + ? calculateHistogramMean(featureMeta.histogram) + : undefined; + const crimeAreaAvg = crimeAreaAvgByName.get(windowFeature); + const nationalAvg = crimeAreaAvg?.national ?? globalMean; + const cardTitle = t('areaPane.crimeCardTitle', { name: ts(card.label) }); + + const numberLinePoints: NumberLinePoint[] = crimeAreaAvg + ? ( + [ + { kind: 'area', label: t('areaPane.thisArea'), value: displayValue }, + nationalAvg != null + ? { kind: 'national', label: t('areaPane.national'), value: nationalAvg } + : null, + crimeAreaAvg.outcode != null + ? { + kind: 'outcode', + label: stats.crime_outcode ?? t('areaPane.outcodeAvg'), + value: crimeAreaAvg.outcode, + } + : null, + crimeAreaAvg.sector != null + ? { + kind: 'sector', + label: stats.crime_sector ?? t('areaPane.sectorAvg'), + value: crimeAreaAvg.sector, + } + : null, + ] as (NumberLinePoint | null)[] + ).filter((p): p is NumberLinePoint => p !== null) + : []; + + const series = crimeByYearByType.get(bare); + + return ( +
+
+ {featureMeta ? ( + + ) : ( + + {cardTitle} + + )} +
+ + {fmtCount(displayValue)} + +
+
+ + {total > 0 && ( + + )} + {numberLinePoints.length >= 2 && ( +
+ +
+ )} + {series && series.points.length > 1 && ( +
+ +
+ )} +
+ ); + })} + + {onLoadCrimeRecords && ( + + )} + + ); +} diff --git a/frontend/src/components/map/CrimeRecordsSection.tsx b/frontend/src/components/map/CrimeRecordsSection.tsx new file mode 100644 index 0000000..8326513 --- /dev/null +++ b/frontend/src/components/map/CrimeRecordsSection.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ts } from '../../i18n/server'; +import type { CrimeIncident, CrimeRecordsResponse } from '../../types'; +import { isAbortError, logNonAbortError } from '../../lib/api'; + +function formatMonth(month: string, locale: string): string { + const [year, m] = month.split('-').map(Number); + if (!year || !m) return month; + return new Date(Date.UTC(year, m - 1, 1)).toLocaleDateString(locale, { + year: 'numeric', + month: 'short', + timeZone: 'UTC', + }); +} + +interface CrimeRecordsSectionProps { + /** Identity of the current selection; changing it resets the list. */ + selectionKey: string | null; + /** Total records for the selection (from stats); the section hides when 0. */ + total?: number; + onLoad: (offset: number) => Promise; +} + +export default function CrimeRecordsSection({ + selectionKey, + total, + onLoad, +}: CrimeRecordsSectionProps) { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const [records, setRecords] = useState([]); + const [loadedTotal, setLoadedTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + // Bumped whenever the selection changes; in-flight responses for a previous + // selection are ignored when they resolve. + const selectionIdRef = useRef(0); + + useEffect(() => { + selectionIdRef.current += 1; + setOpen(false); + setRecords([]); + setLoadedTotal(0); + setLoading(false); + setError(false); + }, [selectionKey]); + + if (!total || total <= 0) return null; + + const fetchPage = async (offset: number) => { + const myId = selectionIdRef.current; + setLoading(true); + setError(false); + try { + const data = await onLoad(offset); + if (myId !== selectionIdRef.current) return; + setRecords((prev) => (offset === 0 ? data.records : [...prev, ...data.records])); + setLoadedTotal(data.total); + } catch (err) { + if (isAbortError(err)) return; + logNonAbortError('Failed to fetch crime records', err); + if (myId === selectionIdRef.current) setError(true); + } finally { + if (myId === selectionIdRef.current) setLoading(false); + } + }; + + const handleToggle = () => { + const next = !open; + setOpen(next); + if (next && records.length === 0 && !loading) fetchPage(0); + }; + + const shownTotal = loadedTotal || total; + const canLoadMore = records.length < loadedTotal; + + return ( +
+ + + {open && ( +
+ {error ? ( +
+ {t('areaPane.crimeRecordsError')} +
+ ) : records.length === 0 && loading ? ( +
+ + {t('areaPane.crimeRecordsLoading')} +
+ ) : records.length === 0 ? ( +
+ {t('areaPane.crimeRecordsEmpty')} +
+ ) : ( + <> +
    + {records.map((record, index) => ( +
  1. +
    + + {ts(record.type)} + + + {formatMonth(record.month, i18n.language)} + +
    +
    + {record.location ?? t('areaPane.crimeNoLocation')} +
    +
    + {record.outcome ?? t('areaPane.crimeOutcomeUnknown')} +
    +
  2. + ))} +
+ {canLoadMore && ( + + )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/map/CrimeYearChart.tsx b/frontend/src/components/map/CrimeYearChart.tsx index b518c21..dc60ffe 100644 --- a/frontend/src/components/map/CrimeYearChart.tsx +++ b/frontend/src/components/map/CrimeYearChart.tsx @@ -82,7 +82,12 @@ export default function CrimeYearChart({ points, latestAvailableYear }: CrimeYea r={1.6} className="fill-rose-700 dark:fill-rose-300" > - {`${p.year}: ${p.count.toFixed(1)}/yr`} + + {t('areaPane.crimeYearPointTooltip', { + year: p.year, + rate: p.count.toFixed(1), + })} + ))} void; travelTimeEntries: TravelTimeEntry[]; onAddTravelTimeEntry: (mode: TransportMode) => void; + /** Registers a group wrapper so an expanded group can be scrolled into view. */ + registerGroup?: (name: string) => (node: HTMLDivElement | null) => void; + /** Notifies the reveal-on-expand mechanism when a group is toggled. */ + onGroupToggle?: (name: string, willExpand: boolean) => void; } export default function FeatureBrowser({ @@ -46,6 +50,8 @@ export default function FeatureBrowser({ onClearOpenInfoFeature, travelTimeEntries: _travelTimeEntries, onAddTravelTimeEntry, + registerGroup, + onGroupToggle, }: FeatureBrowserProps) { const { t } = useTranslation(); const modes = useTranslatedModes(); @@ -115,11 +121,15 @@ export default function FeatureBrowser({ {mergedGrouped.map((group) => { const isExpanded = isSearching || isGroupExpanded(group.name); return ( -
+
toggleGroup(group.name)} + onToggle={() => { + const willExpand = !isExpanded; + toggleGroup(group.name); + onGroupToggle?.(group.name, willExpand); + }} className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800" > diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 32ee222..28e8f82 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -21,6 +21,16 @@ import { isSpecificCrimeFeatureName, isSpecificCrimeFilterName, } from '../../lib/crime-filter'; +import { + CRIME_SEVERITY_FILTER_NAMES, + getCrimeSeverityFeatureName, + getCrimeSeverityFilterMeta, + getCrimeSeverityFilterName, + getDefaultCrimeSeverityFeatureName, + isCrimeSeverityFeatureName, + isCrimeSeverityFilterName, + type CrimeSeverityFilterName, +} from '../../lib/crime-severity-filter'; import { ELECTION_VOTE_SHARE_FILTER_NAME, getDefaultElectionVoteShareFeatureName, @@ -37,7 +47,22 @@ import { isEthnicityFeatureName, isEthnicityFilterName, } from '../../lib/ethnicity-filter'; -import { isQualificationFeatureName } from '../../lib/qualification-filter'; +import { + QUALIFICATIONS_FILTER_NAME, + getDefaultQualificationFeatureName, + getQualificationFeatureName, + getQualificationFilterMeta, + isQualificationFeatureName, + isQualificationFilterName, +} from '../../lib/qualification-filter'; +import { + TENURE_FILTER_NAME, + getDefaultTenureFeatureName, + getTenureFeatureName, + getTenureFilterMeta, + isTenureFeatureName, + isTenureFilterName, +} from '../../lib/tenure-filter'; import { SCHOOL_FILTER_NAME, getDefaultSchoolFeatureName, @@ -182,6 +207,33 @@ export default memo(function Filters({ [features] ); const ethnicityMeta = useMemo(() => getEthnicityFilterMeta(features), [features]); + const defaultQualificationFeatureName = useMemo( + () => getDefaultQualificationFeatureName(features), + [features] + ); + const qualificationMeta = useMemo(() => getQualificationFilterMeta(features), [features]); + const defaultTenureFeatureName = useMemo(() => getDefaultTenureFeatureName(features), [features]); + const tenureMeta = useMemo(() => getTenureFilterMeta(features), [features]); + const crimeSeverityMetas = useMemo( + () => + Object.fromEntries( + CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [ + filterName, + getCrimeSeverityFilterMeta(features, filterName), + ]) + ) as Record, + [features] + ); + const defaultCrimeSeverityFeatureNames = useMemo( + () => + Object.fromEntries( + CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [ + filterName, + getDefaultCrimeSeverityFeatureName(features, filterName), + ]) + ) as Record, + [features] + ); const defaultPoiDistanceFeatureName = useMemo( () => getDefaultPoiDistanceFeatureName(features), [features] @@ -256,6 +308,18 @@ export default memo(function Filters({ return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' }; }); }, [filters, features, specificCrimeMeta]); + const crimeSeverityFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isCrimeSeverityFilterName) + .map((name) => { + const backendName = getCrimeSeverityFeatureName(name); + const filterName = getCrimeSeverityFilterName(name) ?? CRIME_SEVERITY_FILTER_NAMES[0]; + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? crimeSeverityMetas[filterName]), name, group: 'Crime' }; + }); + }, [filters, features, crimeSeverityMetas]); const electionVoteShareFilterItems = useMemo(() => { return Object.keys(filters) .filter(isElectionVoteShareFilterName) @@ -278,6 +342,28 @@ export default memo(function Filters({ return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' }; }); }, [filters, features, ethnicityMeta]); + const qualificationFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isQualificationFilterName) + .map((name) => { + const backendName = getQualificationFeatureName(name); + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? qualificationMeta), name, group: 'Neighbours' }; + }); + }, [filters, features, qualificationMeta]); + const tenureFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isTenureFilterName) + .map((name) => { + const backendName = getTenureFeatureName(name); + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? tenureMeta), name, group: 'Neighbours' }; + }); + }, [filters, features, tenureMeta]); const poiDistanceFilterItems = useMemo(() => { return Object.keys(filters) .filter(isPoiDistanceFilterName) @@ -300,6 +386,8 @@ export default memo(function Filters({ let insertedSpecificCrimeFilter = false; let insertedElectionVoteShareFilter = false; let insertedEthnicityFilter = false; + let insertedQualificationFilter = false; + let insertedTenureFilter = false; const insertedPoiFilters = new Set(); const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => { if ( @@ -311,6 +399,17 @@ export default memo(function Filters({ insertedPoiFilters.add(filterName); } }; + const insertedCrimeSeverityFilters = new Set(); + const maybeInsertCrimeSeverityFilter = (filterName: CrimeSeverityFilterName | null) => { + if ( + filterName && + defaultCrimeSeverityFeatureNames[filterName] && + !insertedCrimeSeverityFilters.has(filterName) + ) { + result.push(crimeSeverityMetas[filterName]); + insertedCrimeSeverityFilters.add(filterName); + } + }; for (const feature of features) { if (feature.group === 'Transport') { @@ -330,6 +429,12 @@ export default memo(function Filters({ } continue; } + // "Serious crime" and "Minor crime" each fold their 7y/2y windows into one + // card with a period toggle (no variant dropdown — a single feature each). + if (isCrimeSeverityFeatureName(feature.name)) { + maybeInsertCrimeSeverityFilter(getCrimeSeverityFilterName(feature.name)); + continue; + } if (isElectionVoteShareFeatureName(feature.name)) { if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) { result.push(electionVoteShareMeta); @@ -344,14 +449,28 @@ export default memo(function Filters({ } continue; } + // The seven qualification bands fold into one "Qualifications" filter + // whose dropdown picks a band, rather than seven separate sliders. + if (isQualificationFeatureName(feature.name)) { + if (defaultQualificationFeatureName && !insertedQualificationFilter) { + result.push(qualificationMeta); + insertedQualificationFilter = true; + } + continue; + } + // The three tenure categories fold into one "Tenure" filter + // whose dropdown picks a category, rather than three separate sliders. + if (isTenureFeatureName(feature.name)) { + if (defaultTenureFeatureName && !insertedTenureFilter) { + result.push(tenureMeta); + insertedTenureFilter = true; + } + continue; + } if (isPoiFilterFeatureName(feature.name)) { maybeInsertPoiFilter(getPoiFilterName(feature.name)); continue; } - // Qualification breakdown is display-only (shown as the stacked - // "Qualifications" composition in the area pane), so keep its seven - // component features out of the filter browser. - if (isQualificationFeatureName(feature.name)) continue; if (!enabledFeatures.has(feature.name)) result.push(feature); } @@ -363,10 +482,16 @@ export default memo(function Filters({ schoolMeta, defaultSpecificCrimeFeatureName, specificCrimeMeta, + defaultCrimeSeverityFeatureNames, + crimeSeverityMetas, defaultElectionVoteShareFeatureName, electionVoteShareMeta, defaultEthnicityFeatureName, ethnicityMeta, + defaultQualificationFeatureName, + qualificationMeta, + defaultTenureFeatureName, + tenureMeta, defaultPoiFilterFeatureNames, poiFilterMetas, ]); @@ -376,6 +501,8 @@ export default memo(function Filters({ let insertedSpecificCrimeFilters = false; let insertedElectionVoteShareFilters = false; let insertedEthnicityFilters = false; + let insertedQualificationFilters = false; + let insertedTenureFilters = false; const insertedPoiFilters = new Set(); const insertPoiFilterItems = (filterName: PoiFilterName | null) => { if (!filterName || insertedPoiFilters.has(filterName)) return; @@ -384,6 +511,16 @@ export default memo(function Filters({ ); insertedPoiFilters.add(filterName); }; + const insertedCrimeSeverityFilters = new Set(); + const insertCrimeSeverityFilterItems = (filterName: CrimeSeverityFilterName | null) => { + if (!filterName || insertedCrimeSeverityFilters.has(filterName)) return; + result.push( + ...crimeSeverityFilterItems.filter( + (item) => getCrimeSeverityFilterName(item.name) === filterName + ) + ); + insertedCrimeSeverityFilters.add(filterName); + }; for (const feature of features) { if (feature.group === 'Transport') { @@ -403,6 +540,10 @@ export default memo(function Filters({ } continue; } + if (isCrimeSeverityFeatureName(feature.name)) { + insertCrimeSeverityFilterItems(getCrimeSeverityFilterName(feature.name)); + continue; + } if (isElectionVoteShareFeatureName(feature.name)) { if (!insertedElectionVoteShareFilters) { result.push(...electionVoteShareFilterItems); @@ -417,6 +558,20 @@ export default memo(function Filters({ } continue; } + if (isQualificationFeatureName(feature.name)) { + if (!insertedQualificationFilters) { + result.push(...qualificationFilterItems); + insertedQualificationFilters = true; + } + continue; + } + if (isTenureFeatureName(feature.name)) { + if (!insertedTenureFilters) { + result.push(...tenureFilterItems); + insertedTenureFilters = true; + } + continue; + } if (isPoiFilterFeatureName(feature.name)) { insertPoiFilterItems(getPoiFilterName(feature.name)); continue; @@ -430,8 +585,11 @@ export default memo(function Filters({ enabledFeatures, schoolFilterItems, specificCrimeFilterItems, + crimeSeverityFilterItems, electionVoteShareFilterItems, ethnicityFilterItems, + qualificationFilterItems, + tenureFilterItems, poiDistanceFilterItems, ]); @@ -460,10 +618,15 @@ export default memo(function Filters({ (name: string): string | null => { if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools'; if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime'; + if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) { + return crimeSeverityMetas[name as CrimeSeverityFilterName].group ?? 'Crime'; + } if (name === ELECTION_VOTE_SHARE_FILTER_NAME) { return electionVoteShareMeta.group ?? 'Neighbours'; } if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours'; + if (name === QUALIFICATIONS_FILTER_NAME) return qualificationMeta.group ?? 'Neighbours'; + if (name === TENURE_FILTER_NAME) return tenureMeta.group ?? 'Neighbours'; if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { return poiFilterMetas[name as PoiFilterName].group ?? null; } @@ -472,8 +635,11 @@ export default memo(function Filters({ [ electionVoteShareMeta.group, ethnicityMeta.group, + qualificationMeta.group, + tenureMeta.group, features, poiFilterMetas, + crimeSeverityMetas, schoolMeta.group, specificCrimeMeta.group, ] @@ -514,6 +680,28 @@ export default memo(function Filters({ onAddFilter(ETHNICITIES_FILTER_NAME); return; } + if (name === QUALIFICATIONS_FILTER_NAME) { + if (!defaultQualificationFeatureName) return; + queueActiveFilterScroll( + QUALIFICATIONS_FILTER_NAME, + getAddFilterGroupName(QUALIFICATIONS_FILTER_NAME) + ); + onAddFilter(QUALIFICATIONS_FILTER_NAME); + return; + } + if (name === TENURE_FILTER_NAME) { + if (!defaultTenureFeatureName) return; + queueActiveFilterScroll(TENURE_FILTER_NAME, getAddFilterGroupName(TENURE_FILTER_NAME)); + onAddFilter(TENURE_FILTER_NAME); + return; + } + if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) { + const severityFilterName = name as CrimeSeverityFilterName; + if (!defaultCrimeSeverityFeatureNames[severityFilterName]) return; + queueActiveFilterScroll(severityFilterName, getAddFilterGroupName(severityFilterName)); + onAddFilter(severityFilterName); + return; + } if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { const filterName = name as PoiFilterName; if (!defaultPoiFilterFeatureNames[filterName]) return; @@ -528,8 +716,11 @@ export default memo(function Filters({ [ defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, + defaultCrimeSeverityFeatureNames, defaultElectionVoteShareFeatureName, defaultEthnicityFeatureName, + defaultQualificationFeatureName, + defaultTenureFeatureName, defaultPoiFilterFeatureNames, getAddFilterGroupName, onAddFilter, @@ -686,8 +877,11 @@ export default memo(function Filters({ ...features, schoolMeta, specificCrimeMeta, + ...Object.values(crimeSeverityMetas), electionVoteShareMeta, ethnicityMeta, + qualificationMeta, + tenureMeta, poiDistanceMeta, transportDistanceMeta, poiCount2KmMeta, @@ -696,8 +890,11 @@ export default memo(function Filters({ pinnedFeature={pinnedFeature} defaultSchoolFeatureName={defaultSchoolFeatureName} defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName} + defaultCrimeSeverityFeatureNames={defaultCrimeSeverityFeatureNames} defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName} defaultEthnicityFeatureName={defaultEthnicityFeatureName} + defaultQualificationFeatureName={defaultQualificationFeatureName} + defaultTenureFeatureName={defaultTenureFeatureName} defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames} openInfoFeature={openInfoFeature} travelTimeEntries={travelTimeEntries} diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index be04ef6..d738f37 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -5,8 +5,11 @@ import { formatValue } from '../../lib/format'; import { ts } from '../../i18n/server'; import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter'; import { getSpecificCrimeFeatureName } from '../../lib/crime-filter'; +import { getCrimeSeverityFeatureName } from '../../lib/crime-severity-filter'; import { getElectionVoteShareFeatureName } from '../../lib/election-filter'; import { getEthnicityFeatureName } from '../../lib/ethnicity-filter'; +import { getQualificationFeatureName } from '../../lib/qualification-filter'; +import { getTenureFeatureName } from '../../lib/tenure-filter'; import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName, @@ -52,14 +55,20 @@ export default memo(function HoverCard({ for (const name of activeFilterNames.slice(0, 4)) { const schoolBackendName = getSchoolBackendFeatureName(name); const specificCrimeFeatureName = getSpecificCrimeFeatureName(name); + const crimeSeverityFeatureName = getCrimeSeverityFeatureName(name); const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name); const ethnicityFeatureName = getEthnicityFeatureName(name); + const qualificationFeatureName = getQualificationFeatureName(name); + const tenureFeatureName = getTenureFeatureName(name); const poiDistanceFeatureName = getPoiDistanceFeatureName(name); const backendName = schoolBackendName ?? specificCrimeFeatureName ?? + crimeSeverityFeatureName ?? electionVoteShareFeatureName ?? ethnicityFeatureName ?? + qualificationFeatureName ?? + tenureFeatureName ?? poiDistanceFeatureName ?? name; const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`]; diff --git a/frontend/src/components/map/JourneyInstructions.test.tsx b/frontend/src/components/map/JourneyInstructions.test.tsx index a8cfcd8..40dfd9f 100644 --- a/frontend/src/components/map/JourneyInstructions.test.tsx +++ b/frontend/src/components/map/JourneyInstructions.test.tsx @@ -16,6 +16,8 @@ vi.mock('react-i18next', () => ({ if (key === 'travel.noBuses') return 'No buses'; if (key === 'areaPane.walk') return 'Walk'; if (key === 'areaPane.cycle') return 'Cycle'; + if (key === 'journey.bus') return 'Bus'; + if (key === 'journey.lineSuffix') return 'line'; if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps'; if (key === 'areaPane.noJourneyData') return 'No journey data'; return key; diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx index c4667e0..fb32486 100644 --- a/frontend/src/components/map/JourneyInstructions.tsx +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import type { JourneyLeg } from '../../types'; import { resolveTransitVariant, type TravelTimeEntry } from '../../hooks/useTravelTime'; import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api'; @@ -106,15 +107,25 @@ function stripId(label: string): string { return label.replace(/\s+\([A-Za-z0-9]+\)$/, ''); } -function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } { +function getRouteDisplay( + mode: string, + t: TFunction +): { label: string; color: string; darkText: boolean } { const clean = stripId(mode); const known = ROUTE_COLORS[clean]; if (known) { - const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`; + const label = + NON_TUBE_NAMES.has(clean) || clean.includes('line') + ? clean + : `${clean} ${t('journey.lineSuffix')}`; return { label, color: known.color, darkText: !!known.darkText }; } if (/^\d+[A-Za-z]?$/.test(clean.trim())) { - return { label: `Bus ${clean}`, color: '#0d9488', darkText: false }; + return { + label: `${t('journey.bus')} ${clean}`, + color: '#0d9488', + darkText: false, + }; } return { label: clean, color: '#6b7280', darkText: false }; } @@ -203,7 +214,8 @@ function invertLegs(legs: JourneyLeg[]): JourneyLeg[] { } function RouteBadge({ mode }: { mode: string }) { - const { label, color, darkText } = getRouteDisplay(mode); + const { t } = useTranslation(); + const { label, color, darkText } = getRouteDisplay(mode, t); return (
diff --git a/frontend/src/components/map/ListingPopups.tsx b/frontend/src/components/map/ListingPopups.tsx index f04e99b..f7ffbeb 100644 --- a/frontend/src/components/map/ListingPopups.tsx +++ b/frontend/src/components/map/ListingPopups.tsx @@ -19,15 +19,21 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n export const ListingPopupSingleContent = memo(function ListingPopupSingleContent({ listing, + clickedUrls, + onOpen, }: { listing: ActualListing; + clickedUrls: Set; + onOpen: (url: string) => void; }) { const { t } = useTranslation(); + const visited = clickedUrls.has(listing.listing_url); return ( onOpen(listing.listing_url)} className="block px-3 py-2" > {listing.asking_price != null && ( @@ -57,7 +63,7 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent )} {listing.floor_area_sqm != null && (
- {Math.round(listing.floor_area_sqm)} sqm + {Math.round(listing.floor_area_sqm)} {t('common.sqm')} {listing.asking_price_per_sqm != null ? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm` : ''} @@ -72,8 +78,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent ))} )} -
- Open listing ↗ +
+ {visited && ✓ {t('listing.viewed')}} + {t('listing.openListing')} ↗
); @@ -82,9 +89,13 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent export const ListingClusterPopupContent = memo(function ListingClusterPopupContent({ count, listings, + clickedUrls, + onOpen, }: { count: number; listings: ActualListing[]; + clickedUrls: Set; + onOpen: (url: string) => void; }) { const { t } = useTranslation(); const visibleCount = listings.length; @@ -92,32 +103,41 @@ export const ListingClusterPopupContent = memo(function ListingClusterPopupConte
- {count.toLocaleString()} listings + {count.toLocaleString()} {t('listing.listings')}
{visibleCount > 0 - ? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}` - : 'Grouped near this map position'} + ? t('listing.showingOf', { visible: visibleCount, count }) + : t('listing.groupedNear')}
{visibleCount > 0 && (
{listings.map((listing, idx) => { const headline = formatListingHeadline(listing, t); + const visited = clickedUrls.has(listing.listing_url); return ( onOpen(listing.listing_url)} className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60" >
-
- {listing.asking_price != null - ? formatListingPrice(listing.asking_price) - : 'Listing'} +
+ + {listing.asking_price != null + ? formatListingPrice(listing.asking_price) + : t('listing.listing')} + + {visited && ( + + ✓ Viewed + + )}
{headline && (
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index c31a649..caee2d9 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -452,6 +452,8 @@ export default memo(function Map({ visiblePois, listingPopup, clearListingPopup, + clickedListingUrls, + markListingClicked, developmentPopup, clearDevelopmentPopup, hoverPosition, @@ -696,11 +698,17 @@ export default memo(function Map({ {listingPopup.mode === 'single' ? ( - + ) : ( )}
diff --git a/frontend/src/components/map/MapErrorBoundary.tsx b/frontend/src/components/map/MapErrorBoundary.tsx index 4b19af0..550d16e 100644 --- a/frontend/src/components/map/MapErrorBoundary.tsx +++ b/frontend/src/components/map/MapErrorBoundary.tsx @@ -1,6 +1,7 @@ import { Component, Fragment, type ReactNode } from 'react'; import * as Sentry from '@sentry/react'; +import i18n from '../../i18n'; import { MapFallback } from './map-page/Fallbacks'; /** @@ -113,17 +114,19 @@ export class MapErrorBoundary extends Component

- The map ran into a problem + {i18n.t('map.error.heading', { defaultValue: 'The map ran into a problem' })}

- This can happen when your browser's graphics context is interrupted. + {i18n.t('map.error.body', { + defaultValue: 'This can happen when your browser’s graphics context is interrupted.', + })}

diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index b951e36..039646f 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -407,6 +407,7 @@ export default function MapPage({ handlePropertiesTabClick, handleLoadMoreProperties, handleCloseSelection, + loadCrimeRecords, selectedPostcodeGeometry, handleLocationSearch, handleCurrentLocationSearch, @@ -806,6 +807,7 @@ export default function MapPage({ shareCode={shareCode} isGroupExpanded={isAreaGroupExpanded} onToggleGroup={toggleAreaGroup} + onLoadCrimeRecords={loadCrimeRecords} scrollTopRef={areaPaneScrollTopRef} scrollRestoreKey={ selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null @@ -823,6 +825,7 @@ export default function MapPage({ hexagonLocation, isAreaGroupExpanded, loadingAreaStats, + loadCrimeRecords, selectedHexagon, setAreaStatsUseFilters, shareCode, diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx index 39bdf6c..18cd292 100644 --- a/frontend/src/components/map/MobileDrawer.tsx +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -105,7 +105,11 @@ export default function MobileDrawer({ return (