Compare commits
3 commits
a02c8f7849
...
fd2860070a
| Author | SHA1 | Date | |
|---|---|---|---|
| fd2860070a | |||
| f7e0814a38 | |||
| 6c6780fc60 |
115 changed files with 4704 additions and 352 deletions
|
|
@ -56,4 +56,4 @@ EXPOSE 8001
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
||||||
CMD curl -f http://localhost:8001/health || exit 1
|
CMD curl -f http://localhost:8001/health || exit 1
|
||||||
ENTRYPOINT ["./property-map-server"]
|
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/property-data/crime_by_postcode_by_year.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/property-data/crime_by_postcode_by_year.parquet", "--population-path", "/app/property-data/population_by_postcode.parquet", "--developments", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"]
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,19 @@ EPC := $(MANUAL_DATA)/domestic-csv.zip
|
||||||
ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet
|
ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet
|
||||||
ACTUAL_LISTINGS_ENRICHED := $(FINDER_DATA)/online_listings_buy_enriched.parquet
|
ACTUAL_LISTINGS_ENRICHED := $(FINDER_DATA)/online_listings_buy_enriched.parquet
|
||||||
ETHNICITY := $(DATA_DIR)/ethnicity_by_lsoa.parquet
|
ETHNICITY := $(DATA_DIR)/ethnicity_by_lsoa.parquet
|
||||||
|
EDUCATION := $(DATA_DIR)/education_by_lsoa.parquet
|
||||||
|
TENURE := $(DATA_DIR)/tenure_by_lsoa.parquet
|
||||||
CRIME_DIR := $(DATA_DIR)/crime
|
CRIME_DIR := $(DATA_DIR)/crime
|
||||||
CRIME := $(DATA_DIR)/crime_by_postcode.parquet
|
CRIME := $(DATA_DIR)/crime_by_postcode.parquet
|
||||||
CRIME_BY_YEAR := $(DATA_DIR)/crime_by_postcode_by_year.parquet
|
CRIME_BY_YEAR := $(DATA_DIR)/crime_by_postcode_by_year.parquet
|
||||||
|
POPULATION := $(DATA_DIR)/population_by_postcode.parquet
|
||||||
CRIME_STAMP := $(CRIME_DIR)/.downloaded
|
CRIME_STAMP := $(CRIME_DIR)/.downloaded
|
||||||
NOISE := $(DATA_DIR)/road_noise.parquet
|
NOISE := $(DATA_DIR)/road_noise.parquet
|
||||||
NOISE_OVERLAY_TILES := $(DATA_DIR)/noise_lden_10m.pmtiles
|
NOISE_OVERLAY_TILES := $(DATA_DIR)/noise_lden_10m.pmtiles
|
||||||
CRIME_HOTSPOT_TILES := $(DATA_DIR)/crime_hotspots.pmtiles
|
CRIME_HOTSPOT_TILES := $(DATA_DIR)/crime_hotspots.pmtiles
|
||||||
TREE_OVERLAY_TILES := $(DATA_DIR)/trees_outside_woodlands.pmtiles
|
TREE_OVERLAY_TILES := $(DATA_DIR)/trees_outside_woodlands.pmtiles
|
||||||
PROPERTY_BORDER_TILES := $(DATA_DIR)/property_borders.pmtiles
|
PROPERTY_BORDER_TILES := $(DATA_DIR)/property_borders.pmtiles
|
||||||
|
DEVELOPMENT_SITES := $(DATA_DIR)/development_sites.parquet
|
||||||
OFSTED := $(DATA_DIR)/ofsted.parquet
|
OFSTED := $(DATA_DIR)/ofsted.parquet
|
||||||
GIAS := $(DATA_DIR)/gias.parquet
|
GIAS := $(DATA_DIR)/gias.parquet
|
||||||
NAPTAN := $(DATA_DIR)/naptan.parquet
|
NAPTAN := $(DATA_DIR)/naptan.parquet
|
||||||
|
|
@ -107,16 +111,16 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_
|
||||||
# ── Phony aliases ─────────────────────────────────────────────────────────────
|
# ── Phony aliases ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.PHONY: prepare merge tiles satellite-tiles satellite-highres-tiles overlay-tiles noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles property-border-tiles \
|
.PHONY: prepare merge tiles satellite-tiles satellite-highres-tiles overlay-tiles noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles property-border-tiles \
|
||||||
download-arcgis download-price-paid download-deprivation download-ethnicity \
|
download-arcgis download-price-paid download-deprivation download-ethnicity download-education download-tenure \
|
||||||
download-naptan download-pois download-grocery-retail-points download-ofsted download-gias download-lsoa-children download-broadband download-conservation-areas download-listed-buildings download-rental-prices \
|
download-naptan download-pois download-grocery-retail-points download-ofsted download-gias download-lsoa-children download-broadband download-conservation-areas download-listed-buildings download-rental-prices \
|
||||||
download-postcodes download-noise download-inspire download-crime \
|
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-england-boundary download-rightmove-outcodes \
|
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 \
|
download-map-assets \
|
||||||
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
||||||
transform-school-catchments transform-tree-density \
|
transform-school-catchments transform-tree-density \
|
||||||
generate-postcode-boundaries generate-travel-times enrich-actual-listings
|
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 | $(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) | $(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)
|
$(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)
|
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)"
|
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)"
|
||||||
|
|
@ -133,6 +137,8 @@ download-arcgis: $(ARCGIS)
|
||||||
download-price-paid: $(PRICE_PAID)
|
download-price-paid: $(PRICE_PAID)
|
||||||
download-deprivation: $(IOD)
|
download-deprivation: $(IOD)
|
||||||
download-ethnicity: $(ETHNICITY)
|
download-ethnicity: $(ETHNICITY)
|
||||||
|
download-education: $(EDUCATION)
|
||||||
|
download-tenure: $(TENURE)
|
||||||
download-crime: $(CRIME_STAMP)
|
download-crime: $(CRIME_STAMP)
|
||||||
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv"
|
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv"
|
||||||
download-naptan: $(NAPTAN)
|
download-naptan: $(NAPTAN)
|
||||||
|
|
@ -143,6 +149,7 @@ download-gias: $(GIAS)
|
||||||
download-lsoa-children: $(LSOA_CHILDREN)
|
download-lsoa-children: $(LSOA_CHILDREN)
|
||||||
download-broadband: $(BROADBAND)
|
download-broadband: $(BROADBAND)
|
||||||
download-conservation-areas: $(CONSERVATION_AREAS)
|
download-conservation-areas: $(CONSERVATION_AREAS)
|
||||||
|
download-development-sites: $(DEVELOPMENT_SITES)
|
||||||
download-listed-buildings: $(LISTED_BUILDINGS)
|
download-listed-buildings: $(LISTED_BUILDINGS)
|
||||||
download-postcodes: $(POSTCODES_RAW)
|
download-postcodes: $(POSTCODES_RAW)
|
||||||
download-rental-prices: $(RENTAL)
|
download-rental-prices: $(RENTAL)
|
||||||
|
|
@ -161,6 +168,7 @@ download-nfi: $(NFI)
|
||||||
download-ofs-register: $(OFS_REGISTER)
|
download-ofs-register: $(OFS_REGISTER)
|
||||||
download-places: $(PLACES)
|
download-places: $(PLACES)
|
||||||
download-median-age: $(MEDIAN_AGE)
|
download-median-age: $(MEDIAN_AGE)
|
||||||
|
download-population: $(POPULATION)
|
||||||
download-election-results: $(ELECTION)
|
download-election-results: $(ELECTION)
|
||||||
download-england-boundary: $(ENGLAND_BOUNDARY)
|
download-england-boundary: $(ENGLAND_BOUNDARY)
|
||||||
download-rightmove-outcodes: $(RM_OUTCODES)
|
download-rightmove-outcodes: $(RM_OUTCODES)
|
||||||
|
|
@ -235,6 +243,12 @@ $(IOD):
|
||||||
$(ETHNICITY): pipeline/download/ethnicity.py
|
$(ETHNICITY): pipeline/download/ethnicity.py
|
||||||
uv run python -m pipeline.download.ethnicity --output $@
|
uv run python -m pipeline.download.ethnicity --output $@
|
||||||
|
|
||||||
|
$(EDUCATION): pipeline/download/education.py
|
||||||
|
uv run python -m pipeline.download.education --output $@
|
||||||
|
|
||||||
|
$(TENURE): pipeline/download/tenure.py
|
||||||
|
uv run python -m pipeline.download.tenure --output $@
|
||||||
|
|
||||||
$(CRIME_STAMP): $(CRIME_DOWNLOAD_DEPS)
|
$(CRIME_STAMP): $(CRIME_DOWNLOAD_DEPS)
|
||||||
@rm -f $@
|
@rm -f $@
|
||||||
uv run python -m pipeline.download.crime --output $(CRIME_DIR)
|
uv run python -m pipeline.download.crime --output $(CRIME_DIR)
|
||||||
|
|
@ -285,6 +299,9 @@ $(BROADBAND):
|
||||||
$(CONSERVATION_AREAS): pipeline/download/conservation_areas.py
|
$(CONSERVATION_AREAS): pipeline/download/conservation_areas.py
|
||||||
uv run python -m pipeline.download.conservation_areas --output $@
|
uv run python -m pipeline.download.conservation_areas --output $@
|
||||||
|
|
||||||
|
$(DEVELOPMENT_SITES): pipeline/download/development_sites.py
|
||||||
|
uv run python -m pipeline.download.development_sites --output $@
|
||||||
|
|
||||||
$(LISTED_BUILDINGS): pipeline/download/listed_buildings.py
|
$(LISTED_BUILDINGS): pipeline/download/listed_buildings.py
|
||||||
uv run python -m pipeline.download.listed_buildings --output $@
|
uv run python -m pipeline.download.listed_buildings --output $@
|
||||||
|
|
||||||
|
|
@ -340,6 +357,9 @@ $(PLACES): $(PBF) $(ENGLAND_BOUNDARY) $(NAPTAN) $(OFS_REGISTER) $(ARCGIS) $(POIS
|
||||||
$(MEDIAN_AGE):
|
$(MEDIAN_AGE):
|
||||||
uv run python -m pipeline.download.median_age --output $@
|
uv run python -m pipeline.download.median_age --output $@
|
||||||
|
|
||||||
|
$(POPULATION): pipeline/download/census_population.py
|
||||||
|
uv run python -m pipeline.download.census_population --output $@
|
||||||
|
|
||||||
$(ELECTION): pipeline/download/election_results.py
|
$(ELECTION): pipeline/download/election_results.py
|
||||||
uv run python -m pipeline.download.election_results --output $@
|
uv run python -m pipeline.download.election_results --output $@
|
||||||
|
|
||||||
|
|
@ -400,7 +420,7 @@ $(PC_BOUNDARIES):
|
||||||
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
|
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
|
||||||
|
|
||||||
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
||||||
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) $(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS)
|
$(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) $(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS)
|
||||||
@rm -f $@
|
@rm -f $@
|
||||||
uv run python -m pipeline.transform.merge \
|
uv run python -m pipeline.transform.merge \
|
||||||
--epc-pp $(EPC_PP) \
|
--epc-pp $(EPC_PP) \
|
||||||
|
|
@ -408,6 +428,8 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
||||||
--iod $(IOD) \
|
--iod $(IOD) \
|
||||||
--poi-proximity $(POI_PROXIMITY) \
|
--poi-proximity $(POI_PROXIMITY) \
|
||||||
--ethnicity $(ETHNICITY) \
|
--ethnicity $(ETHNICITY) \
|
||||||
|
--education $(EDUCATION) \
|
||||||
|
--tenure $(TENURE) \
|
||||||
--crime $(CRIME) \
|
--crime $(CRIME) \
|
||||||
--noise $(NOISE) \
|
--noise $(NOISE) \
|
||||||
--school-catchments $(SCHOOL_CATCH) \
|
--school-catchments $(SCHOOL_CATCH) \
|
||||||
|
|
@ -440,7 +462,7 @@ $(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPER
|
||||||
|
|
||||||
$(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \
|
$(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \
|
||||||
$(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
$(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
||||||
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \
|
$(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \
|
||||||
$(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) \
|
$(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) \
|
||||||
$(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) \
|
$(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) \
|
||||||
$(MERGE_DEPS) pipeline/utils/fuzzy_join.py
|
$(MERGE_DEPS) pipeline/utils/fuzzy_join.py
|
||||||
|
|
@ -450,6 +472,8 @@ $(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \
|
||||||
--iod $(IOD) \
|
--iod $(IOD) \
|
||||||
--poi-proximity $(POI_PROXIMITY) \
|
--poi-proximity $(POI_PROXIMITY) \
|
||||||
--ethnicity $(ETHNICITY) \
|
--ethnicity $(ETHNICITY) \
|
||||||
|
--education $(EDUCATION) \
|
||||||
|
--tenure $(TENURE) \
|
||||||
--crime $(CRIME) \
|
--crime $(CRIME) \
|
||||||
--noise $(NOISE) \
|
--noise $(NOISE) \
|
||||||
--school-catchments $(SCHOOL_CATCH) \
|
--school-catchments $(SCHOOL_CATCH) \
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ services:
|
||||||
command: >
|
command: >
|
||||||
bash -c "
|
bash -c "
|
||||||
cargo install cargo-watch &&
|
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'
|
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'
|
||||||
"
|
"
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
|
|
@ -57,6 +57,7 @@ services:
|
||||||
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
|
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
|
||||||
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
|
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
|
||||||
ACTUAL_LISTINGS_PATH: /app/finder/data/online_listings_buy_enriched.parquet
|
ACTUAL_LISTINGS_PATH: /app/finder/data/online_listings_buy_enriched.parquet
|
||||||
|
DEVELOPMENTS_PATH: /app/property-data/development_sites.parquet
|
||||||
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
|
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
|
||||||
depends_on:
|
depends_on:
|
||||||
screenshot:
|
screenshot:
|
||||||
|
|
|
||||||
BIN
frontend/public/video/ad-01-say-it.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-01-say-it.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-01-say-it.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-01-say-it.mp4
(Stored with Git LFS)
Binary file not shown.
14
frontend/public/video/ad-01-say-it.vtt
Normal file
14
frontend/public/video/ad-01-say-it.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.201 --> 00:00:03.961
|
||||||
|
My whole house brief is one plain sentence.
|
||||||
|
|
||||||
|
00:00:04.361 --> 00:00:08.841
|
||||||
|
Every postcode in England that fits, sorted by value.
|
||||||
|
|
||||||
|
00:00:10.141 --> 00:00:14.941
|
||||||
|
Same schools, same commute, the price quietly drops nearby.
|
||||||
|
|
||||||
|
00:00:15.591 --> 00:00:19.511
|
||||||
|
The underpriced twin is on this map. Find it.
|
||||||
|
|
||||||
BIN
frontend/public/video/ad-02-twenty-minute-map.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-02-twenty-minute-map.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-02-twenty-minute-map.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-02-twenty-minute-map.mp4
(Stored with Git LFS)
Binary file not shown.
14
frontend/public/video/ad-02-twenty-minute-map.vtt
Normal file
14
frontend/public/video/ad-02-twenty-minute-map.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:02.121 --> 00:00:05.961
|
||||||
|
Every colour on this map is a commute to central London.
|
||||||
|
|
||||||
|
00:00:06.361 --> 00:00:09.801
|
||||||
|
Here's what twenty minutes actually leaves you.
|
||||||
|
|
||||||
|
00:00:11.301 --> 00:00:16.741
|
||||||
|
Same twenty minutes wherever it's lit, but not the same price.
|
||||||
|
|
||||||
|
00:00:17.391 --> 00:00:21.151
|
||||||
|
The commute is priced in. The bargain is not.
|
||||||
|
|
||||||
BIN
frontend/public/video/ad-03-postcode-files.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-03-postcode-files.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-03-postcode-files.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-03-postcode-files.mp4
(Stored with Git LFS)
Binary file not shown.
14
frontend/public/video/ad-03-postcode-files.vtt
Normal file
14
frontend/public/video/ad-03-postcode-files.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:01.021 --> 00:00:05.341
|
||||||
|
One name costs more. The block next door scores the same.
|
||||||
|
|
||||||
|
00:00:05.741 --> 00:00:11.581
|
||||||
|
Sold prices, schools, crime, even Street View, for this exact postcode.
|
||||||
|
|
||||||
|
00:00:12.031 --> 00:00:17.071
|
||||||
|
The same evidence a pricey postcode has, sitting quietly cheaper here.
|
||||||
|
|
||||||
|
00:00:17.671 --> 00:00:22.071
|
||||||
|
Every postcode, proven on the numbers, not its reputation.
|
||||||
|
|
||||||
BIN
frontend/public/video/ad-04-quiet-streets.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-04-quiet-streets.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-04-quiet-streets.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-04-quiet-streets.mp4
(Stored with Git LFS)
Binary file not shown.
14
frontend/public/video/ad-04-quiet-streets.vtt
Normal file
14
frontend/public/video/ad-04-quiet-streets.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.201 --> 00:00:04.441
|
||||||
|
Listing photos are silent. Main roads are not.
|
||||||
|
|
||||||
|
00:00:04.841 --> 00:00:07.881
|
||||||
|
This is London under fifty-five decibels.
|
||||||
|
|
||||||
|
00:00:09.181 --> 00:00:13.021
|
||||||
|
Nearby, just as quiet, and overlooked.
|
||||||
|
|
||||||
|
00:00:13.671 --> 00:00:16.071
|
||||||
|
The quiet street nobody bid up.
|
||||||
|
|
||||||
BIN
frontend/public/video/ad-05-school-run.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-05-school-run.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-05-school-run.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-05-school-run.mp4
(Stored with Git LFS)
Binary file not shown.
14
frontend/public/video/ad-05-school-run.vtt
Normal file
14
frontend/public/video/ad-05-school-run.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.201 --> 00:00:06.521
|
||||||
|
Good primary, low crime, under three hundred and fifty. The actual family brief.
|
||||||
|
|
||||||
|
00:00:06.921 --> 00:00:11.081
|
||||||
|
Everything still on the map passes all three filters.
|
||||||
|
|
||||||
|
00:00:12.381 --> 00:00:17.741
|
||||||
|
Same primary, same low crime, without the name everyone else is bidding up.
|
||||||
|
|
||||||
|
00:00:18.391 --> 00:00:21.831
|
||||||
|
The schools are real. The premium is optional.
|
||||||
|
|
||||||
BIN
frontend/public/video/ad-06-waitrose-test.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-06-waitrose-test.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-06-waitrose-test.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-06-waitrose-test.mp4
(Stored with Git LFS)
Binary file not shown.
14
frontend/public/video/ad-06-waitrose-test.vtt
Normal file
14
frontend/public/video/ad-06-waitrose-test.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.204 --> 00:00:05.724
|
||||||
|
A Waitrose, a tube stop, a park nearby. Search by what you want.
|
||||||
|
|
||||||
|
00:00:06.124 --> 00:00:10.204
|
||||||
|
London, narrowed to the postcodes that fit the way you live.
|
||||||
|
|
||||||
|
00:00:11.504 --> 00:00:14.704
|
||||||
|
Same amenities, lower price nearby.
|
||||||
|
|
||||||
|
00:00:15.354 --> 00:00:19.354
|
||||||
|
Same Waitrose. Same tube. Cheaper postcode.
|
||||||
|
|
||||||
BIN
frontend/public/video/ad-07-renters-map.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-07-renters-map.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-07-renters-map.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-07-renters-map.mp4
(Stored with Git LFS)
Binary file not shown.
14
frontend/public/video/ad-07-renters-map.vtt
Normal file
14
frontend/public/video/ad-07-renters-map.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.201 --> 00:00:05.561
|
||||||
|
Rent under sixteen hundred, half an hour to central, on a quiet street.
|
||||||
|
|
||||||
|
00:00:05.961 --> 00:00:10.201
|
||||||
|
Letting sites rank flats. This ranks the streets around them.
|
||||||
|
|
||||||
|
00:00:11.501 --> 00:00:15.821
|
||||||
|
Same commute, same quiet, lower rent nearby.
|
||||||
|
|
||||||
|
00:00:16.471 --> 00:00:19.831
|
||||||
|
The name costs more. The street does not.
|
||||||
|
|
||||||
BIN
frontend/public/video/ad-08-cheap-insurance.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-08-cheap-insurance.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-08-cheap-insurance.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-08-cheap-insurance.mp4
(Stored with Git LFS)
Binary file not shown.
14
frontend/public/video/ad-08-value.vtt
Normal file
14
frontend/public/video/ad-08-value.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.201 --> 00:00:05.641
|
||||||
|
Two streets, same schools, same commute, priced tens of thousands apart.
|
||||||
|
|
||||||
|
00:00:06.041 --> 00:00:10.201
|
||||||
|
You pay for the postcode's name; the value sits a postcode away.
|
||||||
|
|
||||||
|
00:00:11.501 --> 00:00:15.581
|
||||||
|
Pay once, find the bargain, stop overpaying for the name.
|
||||||
|
|
||||||
|
00:00:16.231 --> 00:00:19.351
|
||||||
|
Buy value, not reputation.
|
||||||
|
|
||||||
BIN
frontend/public/video/recording-de-mobile.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording-de-mobile.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording-de-mobile.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording-de-mobile.mp4
(Stored with Git LFS)
Binary file not shown.
26
frontend/public/video/recording-de-mobile.vtt
Normal file
26
frontend/public/video/recording-de-mobile.vtt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.241 --> 00:00:04.081
|
||||||
|
Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht.
|
||||||
|
|
||||||
|
00:00:04.431 --> 00:00:10.831
|
||||||
|
Also sag, was du suchst. Budget, Arbeitsweg, Schulen, sogar wie ruhig die Straße ist.
|
||||||
|
|
||||||
|
00:00:11.201 --> 00:00:16.641
|
||||||
|
Eine Vorgabe, und England schrumpft auf die Postleitzahlen, die dein Geld wert sind.
|
||||||
|
|
||||||
|
00:00:17.191 --> 00:00:21.751
|
||||||
|
Verschärf den Arbeitsweg, und in Sekunden bleiben nur die besten übrig.
|
||||||
|
|
||||||
|
00:00:22.351 --> 00:00:26.111
|
||||||
|
Auf Straßenebene stechen die besten Ecken von selbst hervor.
|
||||||
|
|
||||||
|
00:00:26.969 --> 00:00:34.649
|
||||||
|
Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.
|
||||||
|
|
||||||
|
00:00:41.505 --> 00:00:47.505
|
||||||
|
Behalte die paar mit dem besten Preis-Leistungs-Verhältnis, exportier sie und schau sie dir vor Ort an.
|
||||||
|
|
||||||
|
00:00:48.505 --> 00:00:52.105
|
||||||
|
Zahl nicht für den Namen. Finde den Wert.
|
||||||
|
|
||||||
BIN
frontend/public/video/recording-de.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording-de.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording-de.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording-de.mp4
(Stored with Git LFS)
Binary file not shown.
26
frontend/public/video/recording-de.vtt
Normal file
26
frontend/public/video/recording-de.vtt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.244 --> 00:00:04.084
|
||||||
|
Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht.
|
||||||
|
|
||||||
|
00:00:04.434 --> 00:00:10.834
|
||||||
|
Also sag, was du suchst. Budget, Arbeitsweg, Schulen, sogar wie ruhig die Straße ist.
|
||||||
|
|
||||||
|
00:00:11.204 --> 00:00:16.644
|
||||||
|
Eine Vorgabe, und England schrumpft auf die Postleitzahlen, die dein Geld wert sind.
|
||||||
|
|
||||||
|
00:00:17.194 --> 00:00:21.754
|
||||||
|
Verschärf den Arbeitsweg, und in Sekunden bleiben nur die besten übrig.
|
||||||
|
|
||||||
|
00:00:22.354 --> 00:00:26.114
|
||||||
|
Auf Straßenebene stechen die besten Ecken von selbst hervor.
|
||||||
|
|
||||||
|
00:00:27.355 --> 00:00:35.035
|
||||||
|
Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.
|
||||||
|
|
||||||
|
00:00:37.235 --> 00:00:43.235
|
||||||
|
Behalte die paar mit dem besten Preis-Leistungs-Verhältnis, exportier sie und schau sie dir vor Ort an.
|
||||||
|
|
||||||
|
00:00:44.235 --> 00:00:47.835
|
||||||
|
Zahl nicht für den Namen. Finde den Wert.
|
||||||
|
|
||||||
BIN
frontend/public/video/recording-hi-mobile.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording-hi-mobile.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording-hi-mobile.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording-hi-mobile.mp4
(Stored with Git LFS)
Binary file not shown.
26
frontend/public/video/recording-hi-mobile.vtt
Normal file
26
frontend/public/video/recording-hi-mobile.vtt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.241 --> 00:00:04.881
|
||||||
|
A postcode's reputation is priced in. Its value isn't.
|
||||||
|
|
||||||
|
00:00:05.231 --> 00:00:12.111
|
||||||
|
So start with the brief. Budget, commute, schools, even how quiet the street is.
|
||||||
|
|
||||||
|
00:00:12.481 --> 00:00:16.721
|
||||||
|
One brief, and England narrows to the postcodes worth your money.
|
||||||
|
|
||||||
|
00:00:17.271 --> 00:00:20.871
|
||||||
|
Tighten the commute, and the keepers narrow further in seconds.
|
||||||
|
|
||||||
|
00:00:21.471 --> 00:00:26.111
|
||||||
|
Down at street level, the strongest streets start to stand out.
|
||||||
|
|
||||||
|
00:00:26.561 --> 00:00:33.921
|
||||||
|
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
|
||||||
|
|
||||||
|
00:00:41.054 --> 00:00:46.494
|
||||||
|
Keep the best-value few, export them, and scout where it actually counts.
|
||||||
|
|
||||||
|
00:00:47.494 --> 00:00:50.694
|
||||||
|
Stop paying for the name. Find the value.
|
||||||
|
|
||||||
BIN
frontend/public/video/recording-hi.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording-hi.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording-hi.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording-hi.mp4
(Stored with Git LFS)
Binary file not shown.
26
frontend/public/video/recording-hi.vtt
Normal file
26
frontend/public/video/recording-hi.vtt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.245 --> 00:00:04.885
|
||||||
|
A postcode's reputation is priced in. Its value isn't.
|
||||||
|
|
||||||
|
00:00:05.235 --> 00:00:12.115
|
||||||
|
So start with the brief. Budget, commute, schools, even how quiet the street is.
|
||||||
|
|
||||||
|
00:00:12.485 --> 00:00:16.725
|
||||||
|
One brief, and England narrows to the postcodes worth your money.
|
||||||
|
|
||||||
|
00:00:17.275 --> 00:00:20.875
|
||||||
|
Tighten the commute, and the keepers narrow further in seconds.
|
||||||
|
|
||||||
|
00:00:21.475 --> 00:00:26.115
|
||||||
|
Down at street level, the strongest streets start to stand out.
|
||||||
|
|
||||||
|
00:00:26.565 --> 00:00:33.925
|
||||||
|
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
|
||||||
|
|
||||||
|
00:00:36.125 --> 00:00:41.565
|
||||||
|
Keep the best-value few, export them, and scout where it actually counts.
|
||||||
|
|
||||||
|
00:00:42.565 --> 00:00:45.765
|
||||||
|
Stop paying for the name. Find the value.
|
||||||
|
|
||||||
BIN
frontend/public/video/recording-mobile.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording-mobile.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording-mobile.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording-mobile.mp4
(Stored with Git LFS)
Binary file not shown.
26
frontend/public/video/recording-mobile.vtt
Normal file
26
frontend/public/video/recording-mobile.vtt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.240 --> 00:00:05.200
|
||||||
|
A postcode's reputation is priced in. Its value isn't.
|
||||||
|
|
||||||
|
00:00:05.550 --> 00:00:12.110
|
||||||
|
So start with the brief. Budget, commute, schools, even how quiet the street is.
|
||||||
|
|
||||||
|
00:00:12.480 --> 00:00:17.200
|
||||||
|
One brief, and England narrows to the postcodes worth your money.
|
||||||
|
|
||||||
|
00:00:17.750 --> 00:00:22.630
|
||||||
|
Tighten the commute, and the keepers narrow further in seconds.
|
||||||
|
|
||||||
|
00:00:23.230 --> 00:00:29.150
|
||||||
|
Down at street level, the strongest streets start to stand out.
|
||||||
|
|
||||||
|
00:00:29.600 --> 00:00:37.920
|
||||||
|
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
|
||||||
|
|
||||||
|
00:00:44.031 --> 00:00:49.871
|
||||||
|
Keep the best-value few, export them, and scout where it actually counts.
|
||||||
|
|
||||||
|
00:00:50.871 --> 00:00:53.991
|
||||||
|
Stop paying for the name. Find the value.
|
||||||
|
|
||||||
BIN
frontend/public/video/recording-zh-mobile.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording-zh-mobile.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording-zh-mobile.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording-zh-mobile.mp4
(Stored with Git LFS)
Binary file not shown.
26
frontend/public/video/recording-zh-mobile.vtt
Normal file
26
frontend/public/video/recording-zh-mobile.vtt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.242 --> 00:00:06.322
|
||||||
|
一个邮区的名气,早就算进了房价里。它真正的价值,却没有。
|
||||||
|
|
||||||
|
00:00:06.672 --> 00:00:15.152
|
||||||
|
所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。
|
||||||
|
|
||||||
|
00:00:15.522 --> 00:00:20.162
|
||||||
|
一份需求,整个英格兰就缩小到那些真正值这个价的邮区。
|
||||||
|
|
||||||
|
00:00:20.712 --> 00:00:25.672
|
||||||
|
把通勤再收紧一点,几秒钟,留下的好选择又少了一批。
|
||||||
|
|
||||||
|
00:00:26.272 --> 00:00:29.952
|
||||||
|
放大到街道层面,好街区自己浮现出来。
|
||||||
|
|
||||||
|
00:00:30.960 --> 00:00:38.080
|
||||||
|
点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。
|
||||||
|
|
||||||
|
00:00:45.462 --> 00:00:51.302
|
||||||
|
留下最划算的那几个,导出来,去真正值得的地方实地看房。
|
||||||
|
|
||||||
|
00:00:52.302 --> 00:00:55.422
|
||||||
|
别再为名气买单。找到真正的价值。
|
||||||
|
|
||||||
BIN
frontend/public/video/recording-zh.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording-zh.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording-zh.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording-zh.mp4
(Stored with Git LFS)
Binary file not shown.
26
frontend/public/video/recording-zh.vtt
Normal file
26
frontend/public/video/recording-zh.vtt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.244 --> 00:00:06.324
|
||||||
|
一个邮区的名气,早就算进了房价里。它真正的价值,却没有。
|
||||||
|
|
||||||
|
00:00:06.674 --> 00:00:15.154
|
||||||
|
所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。
|
||||||
|
|
||||||
|
00:00:15.524 --> 00:00:20.164
|
||||||
|
一份需求,整个英格兰就缩小到那些真正值这个价的邮区。
|
||||||
|
|
||||||
|
00:00:20.714 --> 00:00:25.674
|
||||||
|
把通勤再收紧一点,几秒钟,留下的好选择又少了一批。
|
||||||
|
|
||||||
|
00:00:26.274 --> 00:00:29.954
|
||||||
|
放大到街道层面,好街区自己浮现出来。
|
||||||
|
|
||||||
|
00:00:31.054 --> 00:00:38.174
|
||||||
|
点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。
|
||||||
|
|
||||||
|
00:00:40.375 --> 00:00:46.215
|
||||||
|
留下最划算的那几个,导出来,去真正值得的地方实地看房。
|
||||||
|
|
||||||
|
00:00:47.215 --> 00:00:50.335
|
||||||
|
别再为名气买单。找到真正的价值。
|
||||||
|
|
||||||
BIN
frontend/public/video/recording.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording.mp4
(Stored with Git LFS)
Binary file not shown.
26
frontend/public/video/recording.vtt
Normal file
26
frontend/public/video/recording.vtt
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.241 --> 00:00:05.201
|
||||||
|
A postcode's reputation is priced in. Its value isn't.
|
||||||
|
|
||||||
|
00:00:05.551 --> 00:00:12.111
|
||||||
|
So start with the brief. Budget, commute, schools, even how quiet the street is.
|
||||||
|
|
||||||
|
00:00:12.481 --> 00:00:17.201
|
||||||
|
One brief, and England narrows to the postcodes worth your money.
|
||||||
|
|
||||||
|
00:00:17.751 --> 00:00:22.631
|
||||||
|
Tighten the commute, and the keepers narrow further in seconds.
|
||||||
|
|
||||||
|
00:00:23.231 --> 00:00:29.151
|
||||||
|
Down at street level, the strongest streets start to stand out.
|
||||||
|
|
||||||
|
00:00:29.601 --> 00:00:37.921
|
||||||
|
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
|
||||||
|
|
||||||
|
00:00:40.021 --> 00:00:45.861
|
||||||
|
Keep the best-value few, export them, and scout where it actually counts.
|
||||||
|
|
||||||
|
00:00:46.861 --> 00:00:49.981
|
||||||
|
Stop paying for the name. Find the value.
|
||||||
|
|
||||||
|
|
@ -146,7 +146,14 @@ function ProductDemoVideo() {
|
||||||
onPlay={() => setIsVideoPlaying(true)}
|
onPlay={() => setIsVideoPlaying(true)}
|
||||||
onPause={() => setIsVideoPlaying(false)}
|
onPause={() => setIsVideoPlaying(false)}
|
||||||
onEnded={() => setIsVideoPlaying(false)}
|
onEnded={() => setIsVideoPlaying(false)}
|
||||||
|
>
|
||||||
|
<track
|
||||||
|
kind="captions"
|
||||||
|
srcLang={(i18n.language ?? 'en').split('-')[0]}
|
||||||
|
label="Captions"
|
||||||
|
src={`/video/${productDemoSlug}.vtt`}
|
||||||
/>
|
/>
|
||||||
|
</video>
|
||||||
{!isVideoPlaying && (
|
{!isVideoPlaying && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const SOCIAL_VIDEOS: { slug: string; titleKey: string; descKey: string }[] = [
|
||||||
descKey: 'learnPage.video07Desc',
|
descKey: 'learnPage.video07Desc',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'ad-08-cheap-insurance',
|
slug: 'ad-08-value',
|
||||||
titleKey: 'learnPage.video08Title',
|
titleKey: 'learnPage.video08Title',
|
||||||
descKey: 'learnPage.video08Desc',
|
descKey: 'learnPage.video08Desc',
|
||||||
},
|
},
|
||||||
|
|
@ -261,7 +261,9 @@ function SocialVideoCard({
|
||||||
onPlay={() => setIsPlaying(true)}
|
onPlay={() => setIsPlaying(true)}
|
||||||
onPause={() => setIsPlaying(false)}
|
onPause={() => setIsPlaying(false)}
|
||||||
onEnded={() => setIsPlaying(false)}
|
onEnded={() => setIsPlaying(false)}
|
||||||
/>
|
>
|
||||||
|
<track kind="captions" srcLang="en" label="Captions" src={`/video/${slug}.vtt`} />
|
||||||
|
</video>
|
||||||
{!isPlaying && (
|
{!isPlaying && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
import { useMemo, useState, type MutableRefObject, type ReactNode } from 'react';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type MutableRefObject,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -37,6 +45,7 @@ import StackedBarChart from './StackedBarChart';
|
||||||
import StackedEnumChart from './StackedEnumChart';
|
import StackedEnumChart from './StackedEnumChart';
|
||||||
import PriceHistoryChart from './PriceHistoryChart';
|
import PriceHistoryChart from './PriceHistoryChart';
|
||||||
import CrimeYearChart from './CrimeYearChart';
|
import CrimeYearChart from './CrimeYearChart';
|
||||||
|
import NumberLine, { type NumberLinePoint } from './NumberLine';
|
||||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||||
import { InfoIcon, TransitIcon } from '../ui/icons';
|
import { InfoIcon, TransitIcon } from '../ui/icons';
|
||||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||||
|
|
@ -239,6 +248,7 @@ export default function AreaPane({
|
||||||
}: AreaPaneProps) {
|
}: AreaPaneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const propertyCount = stats?.count;
|
const propertyCount = stats?.count;
|
||||||
|
const population = stats?.population;
|
||||||
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
|
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
|
||||||
const filtersActive = activeFilterCount > 0;
|
const filtersActive = activeFilterCount > 0;
|
||||||
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
|
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
|
||||||
|
|
@ -284,6 +294,65 @@ export default function AreaPane({
|
||||||
suspendSave: scrollSaveDisabled ?? (loading && stats == null),
|
suspendSave: scrollSaveDisabled ?? (loading && stats == null),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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<HTMLDivElement | null>(null);
|
||||||
|
const setScrollNode = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
scrollContainerRef.current = node;
|
||||||
|
scrollRef(node);
|
||||||
|
},
|
||||||
|
[scrollRef]
|
||||||
|
);
|
||||||
|
const groupRefs = useRef<Map<string, HTMLDivElement>>(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);
|
||||||
|
},
|
||||||
|
[isGroupExpanded, onToggleGroup]
|
||||||
|
);
|
||||||
|
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(() => {
|
const numericByName = useMemo(() => {
|
||||||
if (!stats) return new Map();
|
if (!stats) return new Map();
|
||||||
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
|
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
|
||||||
|
|
@ -306,6 +375,18 @@ export default function AreaPane({
|
||||||
return map;
|
return map;
|
||||||
}, [stats]);
|
}, [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.
|
||||||
|
const crimeAreaAvgByName = useMemo(() => {
|
||||||
|
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_area_averages']>[number]>();
|
||||||
|
for (const entry of stats?.crime_area_averages ?? []) {
|
||||||
|
map.set(entry.name, entry);
|
||||||
|
map.set(`${entry.name} (avg/yr)`, entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
const globalFeatureByName = useMemo(
|
const globalFeatureByName = useMemo(
|
||||||
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
||||||
[globalFeatures]
|
[globalFeatures]
|
||||||
|
|
@ -359,7 +440,7 @@ export default function AreaPane({
|
||||||
<>
|
<>
|
||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
<IndeterminateProgressBar show={loading && stats != null} />
|
<IndeterminateProgressBar show={loading && stats != null} />
|
||||||
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
|
<div ref={setScrollNode} onScroll={onScroll} className="flex-1 overflow-y-auto">
|
||||||
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
||||||
<div className="space-y-3 p-3">
|
<div className="space-y-3 p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|
@ -380,7 +461,18 @@ export default function AreaPane({
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right">
|
<div className="flex shrink-0 items-start gap-4 text-right">
|
||||||
|
{population != null && (
|
||||||
|
<div title={t('areaPane.residentsTooltip')}>
|
||||||
|
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||||
|
{population.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||||
|
{t('areaPane.residents')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||||
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -389,6 +481,7 @@ export default function AreaPane({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
|
@ -522,11 +615,17 @@ export default function AreaPane({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div
|
||||||
|
key={group.name}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) groupRefs.current.set(group.name, el);
|
||||||
|
else groupRefs.current.delete(group.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CollapsibleGroupHeader
|
<CollapsibleGroupHeader
|
||||||
name={group.name}
|
name={group.name}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={() => onToggleGroup(group.name)}
|
onToggle={() => handleToggleGroup(group.name)}
|
||||||
className="area-pane-group-header sticky top-0 z-10 bg-white px-3 pb-1.5 pt-4 text-[11px] font-bold uppercase tracking-wide text-warm-500 hover:bg-warm-50 dark:bg-navy-950 dark:text-warm-400 dark:hover:bg-navy-900"
|
className="area-pane-group-header sticky top-0 z-10 bg-white px-3 pb-1.5 pt-4 text-[11px] font-bold uppercase tracking-wide text-warm-500 hover:bg-warm-50 dark:bg-navy-950 dark:text-warm-400 dark:hover:bg-navy-900"
|
||||||
/>
|
/>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|
@ -571,6 +670,47 @@ export default function AreaPane({
|
||||||
const globalMean = featureMeta?.histogram
|
const globalMean = featureMeta?.histogram
|
||||||
? calculateHistogramMean(featureMeta.histogram)
|
? calculateHistogramMean(featureMeta.histogram)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const crimeAreaAvg = infoFeatureName
|
||||||
|
? crimeAreaAvgByName.get(infoFeatureName)
|
||||||
|
: undefined;
|
||||||
|
// For crime, prefer the exact national mean so it shares
|
||||||
|
// one estimator with the outcode/sector/selection values.
|
||||||
|
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||||
|
|
||||||
|
// Crime metrics get a number line comparing this area to
|
||||||
|
// its sector / outcode / nation instead of a flat list.
|
||||||
|
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)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
|
@ -601,9 +741,12 @@ export default function AreaPane({
|
||||||
{formatValue(displayValue)}
|
{formatValue(displayValue)}
|
||||||
{chart.unit ? ` ${chart.unit}` : ''}
|
{chart.unit ? ` ${chart.unit}` : ''}
|
||||||
</span>
|
</span>
|
||||||
{globalMean != null && (
|
{/* Crime shows its national/outcode/sector
|
||||||
|
comparison on the number line below; other
|
||||||
|
stacked metrics keep the inline national avg. */}
|
||||||
|
{!crimeAreaAvg && nationalAvg != null && (
|
||||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
||||||
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
{t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -617,6 +760,11 @@ export default function AreaPane({
|
||||||
: STACKED_SEGMENT_COLORS
|
: STACKED_SEGMENT_COLORS
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{numberLinePoints.length >= 2 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<NumberLine points={numberLinePoints} format={formatValue} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{crimeSeries && crimeSeries.points.length > 1 && (
|
{crimeSeries && crimeSeries.points.length > 1 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<CrimeYearChart
|
<CrimeYearChart
|
||||||
|
|
@ -653,6 +801,26 @@ export default function AreaPane({
|
||||||
? calculateHistogramMean(globalHistogram)
|
? calculateHistogramMean(globalHistogram)
|
||||||
: undefined;
|
: undefined;
|
||||||
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
|
const crimeSeries = crimeByYearByFeatureName.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
|
||||||
|
// averages join it on their own lines, and the exact
|
||||||
|
// national mean is preferred over the histogram one.
|
||||||
|
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||||
|
const valueTitle =
|
||||||
|
[
|
||||||
|
nationalAvg != null
|
||||||
|
? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
|
||||||
|
: null,
|
||||||
|
crimeAreaAvg?.outcode != null
|
||||||
|
? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
|
||||||
|
: null,
|
||||||
|
crimeAreaAvg?.sector != null
|
||||||
|
? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n') || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MetricRow
|
<MetricRow
|
||||||
|
|
@ -712,11 +880,7 @@ export default function AreaPane({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
value={formatValue(numericStats.mean, feature)}
|
value={formatValue(numericStats.mean, feature)}
|
||||||
valueTitle={
|
valueTitle={valueTitle}
|
||||||
globalMean != null
|
|
||||||
? `${t('areaPane.nationalAvg')}: ${formatValue(globalMean)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
frontend/src/components/map/DevelopmentPopup.tsx
Normal file
72
frontend/src/components/map/DevelopmentPopup.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { Development } from '../../types';
|
||||||
|
|
||||||
|
/** Turn a raw server status like "full-planning-permission" into "Full planning permission". */
|
||||||
|
function prettifyStatus(value: string): string {
|
||||||
|
const cleaned = value.replace(/[-_]+/g, ' ').trim();
|
||||||
|
if (!cleaned) return value;
|
||||||
|
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DevelopmentPopupContent = memo(function DevelopmentPopupContent({
|
||||||
|
development,
|
||||||
|
}: {
|
||||||
|
development: Development;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const min = development.min_dwellings ?? null;
|
||||||
|
const max = development.max_dwellings ?? null;
|
||||||
|
|
||||||
|
let dwellings: string | null = null;
|
||||||
|
if (min != null && max != null) {
|
||||||
|
dwellings =
|
||||||
|
min === max
|
||||||
|
? t('newDevelopments.homesExact', { count: max })
|
||||||
|
: t('newDevelopments.homesRange', { min, max });
|
||||||
|
} else if (max != null) {
|
||||||
|
dwellings = t('newDevelopments.homesUpTo', { count: max });
|
||||||
|
} else if (min != null) {
|
||||||
|
dwellings = t('newDevelopments.homesExact', { count: min });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel =
|
||||||
|
development.source === 'homes-england'
|
||||||
|
? t('newDevelopments.sourceHomesEngland')
|
||||||
|
: t('newDevelopments.sourceBrownfield');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[260px] px-3 py-2">
|
||||||
|
<div className="text-sm font-bold text-blue-700 dark:text-blue-400">
|
||||||
|
{development.name || t('newDevelopments.title')}
|
||||||
|
</div>
|
||||||
|
{dwellings && (
|
||||||
|
<div className="mt-0.5 text-xs font-medium text-warm-700 dark:text-warm-200">
|
||||||
|
{dwellings}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{development.planning_status && (
|
||||||
|
<div className="mt-0.5 text-xs text-warm-500 dark:text-warm-400">
|
||||||
|
{t('newDevelopments.planningStatus')}: {prettifyStatus(development.planning_status)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{development.local_authority && (
|
||||||
|
<div className="mt-0.5 text-[11px] text-warm-500 dark:text-warm-400">
|
||||||
|
{t('newDevelopments.localAuthority')}: {development.local_authority}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 text-[11px] text-warm-400 dark:text-warm-500">{sourceLabel}</div>
|
||||||
|
{development.url && (
|
||||||
|
<a
|
||||||
|
href={development.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-1.5 block text-[11px] font-medium text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{t('newDevelopments.viewRecord')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
isEthnicityFeatureName,
|
isEthnicityFeatureName,
|
||||||
isEthnicityFilterName,
|
isEthnicityFilterName,
|
||||||
} from '../../lib/ethnicity-filter';
|
} from '../../lib/ethnicity-filter';
|
||||||
|
import { isQualificationFeatureName } from '../../lib/qualification-filter';
|
||||||
import {
|
import {
|
||||||
SCHOOL_FILTER_NAME,
|
SCHOOL_FILTER_NAME,
|
||||||
getDefaultSchoolFeatureName,
|
getDefaultSchoolFeatureName,
|
||||||
|
|
@ -347,6 +348,10 @@ export default memo(function Filters({
|
||||||
maybeInsertPoiFilter(getPoiFilterName(feature.name));
|
maybeInsertPoiFilter(getPoiFilterName(feature.name));
|
||||||
continue;
|
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);
|
if (!enabledFeatures.has(feature.name)) result.push(feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,24 @@ import {
|
||||||
getMapStyle,
|
getMapStyle,
|
||||||
getMapDataBeforeId,
|
getMapDataBeforeId,
|
||||||
getMapCenterForTargetScreenPoint,
|
getMapCenterForTargetScreenPoint,
|
||||||
|
selectSpacedItems,
|
||||||
|
type PlacedItem,
|
||||||
} from '../../lib/map-utils';
|
} from '../../lib/map-utils';
|
||||||
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_AUTO_CARD_ZOOM_THRESHOLD } from '../../lib/consts';
|
import {
|
||||||
|
MAP_MIN_ZOOM,
|
||||||
|
MAP_BOUNDS,
|
||||||
|
POI_AUTO_CARD_ZOOM_THRESHOLD,
|
||||||
|
MAX_AUTO_POI_CARDS,
|
||||||
|
AUTO_POI_CARD_MIN_DX,
|
||||||
|
AUTO_POI_CARD_MIN_DY,
|
||||||
|
POSTCODE_ZOOM_THRESHOLD,
|
||||||
|
} from '../../lib/consts';
|
||||||
import type { SearchedLocation } from './LocationSearch';
|
import type { SearchedLocation } from './LocationSearch';
|
||||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||||
import type { FeatureFilters } from '../../types';
|
import type { FeatureFilters } from '../../types';
|
||||||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||||
|
import { useDevelopments } from '../../hooks/useDevelopments';
|
||||||
import { useMapCardLayout } from '../../hooks/useMapCardLayout';
|
import { useMapCardLayout } from '../../hooks/useMapCardLayout';
|
||||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
import { type OverlayId } from '../../lib/overlays';
|
import { type OverlayId } from '../../lib/overlays';
|
||||||
|
|
@ -41,6 +52,7 @@ import { OverlayTileLayers } from './OverlayTileLayers';
|
||||||
import { MapTopCards } from './MapTopCards';
|
import { MapTopCards } from './MapTopCards';
|
||||||
import { PoiPopupCardContent } from './PoiPopupCard';
|
import { PoiPopupCardContent } from './PoiPopupCard';
|
||||||
import { ListingClusterPopupContent, ListingPopupSingleContent } from './ListingPopups';
|
import { ListingClusterPopupContent, ListingPopupSingleContent } from './ListingPopups';
|
||||||
|
import { DevelopmentPopupContent } from './DevelopmentPopup';
|
||||||
import { HoverCardOverlay } from './HoverCardOverlay';
|
import { HoverCardOverlay } from './HoverCardOverlay';
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
|
|
@ -425,6 +437,14 @@ export default memo(function Map({
|
||||||
return center ? { lat: center.lat, lng: center.lng } : null;
|
return center ? { lat: center.lat, lng: center.lng } : null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Only fetch/show developments once zoomed in, matching the tile overlays
|
||||||
|
// (noise/crime/trees/borders) which all gate on POSTCODE_ZOOM_THRESHOLD.
|
||||||
|
const developmentsEnabled =
|
||||||
|
activeOverlays.has('new-developments') && viewState.zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||||
|
const { developments } = useDevelopments(viewportBounds ?? null, {
|
||||||
|
enabled: developmentsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
layers,
|
layers,
|
||||||
popupInfo,
|
popupInfo,
|
||||||
|
|
@ -432,6 +452,8 @@ export default memo(function Map({
|
||||||
visiblePois,
|
visiblePois,
|
||||||
listingPopup,
|
listingPopup,
|
||||||
clearListingPopup,
|
clearListingPopup,
|
||||||
|
developmentPopup,
|
||||||
|
clearDevelopmentPopup,
|
||||||
hoverPosition,
|
hoverPosition,
|
||||||
countRange,
|
countRange,
|
||||||
postcodeCountRange,
|
postcodeCountRange,
|
||||||
|
|
@ -444,6 +466,7 @@ export default memo(function Map({
|
||||||
zoom: viewState.zoom,
|
zoom: viewState.zoom,
|
||||||
pois,
|
pois,
|
||||||
actualListings,
|
actualListings,
|
||||||
|
developments,
|
||||||
viewFeature,
|
viewFeature,
|
||||||
colorRange,
|
colorRange,
|
||||||
filterRange,
|
filterRange,
|
||||||
|
|
@ -468,7 +491,8 @@ export default memo(function Map({
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return visiblePois.flatMap((poi) => {
|
const candidates: PlacedItem<POI>[] = [];
|
||||||
|
for (const poi of visiblePois) {
|
||||||
const point = map.project([poi.lng, poi.lat]);
|
const point = map.project([poi.lng, poi.lat]);
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(point.x) ||
|
!Number.isFinite(point.x) ||
|
||||||
|
|
@ -478,10 +502,18 @@ export default memo(function Map({
|
||||||
point.y < 0 ||
|
point.y < 0 ||
|
||||||
point.y > dimensions.height
|
point.y > dimensions.height
|
||||||
) {
|
) {
|
||||||
return [];
|
continue;
|
||||||
}
|
}
|
||||||
return [{ poi, x: point.x, y: point.y }];
|
candidates.push({ item: poi, x: point.x, y: point.y });
|
||||||
});
|
}
|
||||||
|
// Cull overlapping cards so a dense area with many categories doesn't render a
|
||||||
|
// wall of hundreds of cards over the map.
|
||||||
|
return selectSpacedItems(
|
||||||
|
candidates,
|
||||||
|
AUTO_POI_CARD_MIN_DX,
|
||||||
|
AUTO_POI_CARD_MIN_DY,
|
||||||
|
MAX_AUTO_POI_CARDS
|
||||||
|
);
|
||||||
// viewState isn't read directly but drives map.project — recompute when the camera moves.
|
// viewState isn't read directly but drives map.project — recompute when the camera moves.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
|
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
|
||||||
|
|
@ -599,7 +631,7 @@ export default memo(function Map({
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{autoPoiCards.map(({ poi, x, y }) => (
|
{autoPoiCards.map(({ item: poi, x, y }) => (
|
||||||
<div
|
<div
|
||||||
key={poi.id}
|
key={poi.id}
|
||||||
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
||||||
|
|
@ -673,6 +705,27 @@ export default memo(function Map({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{developmentPopup && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute max-w-[280px] rounded-lg bg-white text-sm shadow-lg dark:bg-warm-800 dark:text-white"
|
||||||
|
style={{
|
||||||
|
left: developmentPopup.x,
|
||||||
|
top: developmentPopup.y - 12,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
zIndex: 30,
|
||||||
|
}}
|
||||||
|
onMouseLeave={clearDevelopmentPopup}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
|
||||||
|
onClick={clearDevelopmentPopup}
|
||||||
|
>
|
||||||
|
<CloseIcon className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<DevelopmentPopupContent development={developmentPopup.development} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
|
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
|
||||||
<HoverCardOverlay
|
<HoverCardOverlay
|
||||||
x={hoverPosition.x}
|
x={hoverPosition.x}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { ActualListing, PostcodeGeometry } from '../../types';
|
import type { ActualListing, POI, PostcodeGeometry } from '../../types';
|
||||||
import type { SearchedLocation } from './LocationSearch';
|
import type { SearchedLocation } from './LocationSearch';
|
||||||
import { useMapData } from '../../hooks/useMapData';
|
import { useMapData } from '../../hooks/useMapData';
|
||||||
import { usePOIData } from '../../hooks/usePOIData';
|
import { usePOIData } from '../../hooks/usePOIData';
|
||||||
|
|
@ -88,6 +88,7 @@ export type { ExportState } from './map-page/types';
|
||||||
declare const __DEV__: boolean;
|
declare const __DEV__: boolean;
|
||||||
|
|
||||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||||
|
const EMPTY_POIS: POI[] = [];
|
||||||
|
|
||||||
export default function MapPage({
|
export default function MapPage({
|
||||||
features,
|
features,
|
||||||
|
|
@ -562,7 +563,11 @@ export default function MapPage({
|
||||||
if (filtersUnlimited) setDemoPromptOpen(false);
|
if (filtersUnlimited) setDemoPromptOpen(false);
|
||||||
}, [filtersUnlimited]);
|
}, [filtersUnlimited]);
|
||||||
|
|
||||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||||
|
// Disabling POIs (clearing every category) must remove all POI cards/markers
|
||||||
|
// immediately, not on the next fetch tick — gate on the selection itself so a
|
||||||
|
// stale fetch result can never keep cards on screen.
|
||||||
|
const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS;
|
||||||
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
|
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
|
||||||
const actualListingsFilterParam = useMemo(
|
const actualListingsFilterParam = useMemo(
|
||||||
() => buildFilterString(filters, features),
|
() => buildFilterString(filters, features),
|
||||||
|
|
|
||||||
83
frontend/src/components/map/NumberLine.test.ts
Normal file
83
frontend/src/components/map/NumberLine.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { computeNumberLineLayout, type NumberLinePoint } from './NumberLine';
|
||||||
|
|
||||||
|
const fmt = (v: number) => v.toFixed(0);
|
||||||
|
|
||||||
|
const points: NumberLinePoint[] = [
|
||||||
|
{ kind: 'area', label: 'This area', value: 50 },
|
||||||
|
{ kind: 'national', label: 'National', value: 30 },
|
||||||
|
{ kind: 'outcode', label: 'SE3', value: 49 },
|
||||||
|
{ kind: 'sector', label: 'SE3 9', value: 51 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('computeNumberLineLayout', () => {
|
||||||
|
it('returns null when there is nothing to draw', () => {
|
||||||
|
expect(computeNumberLineLayout(points, 0, fmt)).toBeNull();
|
||||||
|
expect(computeNumberLineLayout([], 300, fmt)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places ticks ordered by value', () => {
|
||||||
|
const layout = computeNumberLineLayout(points, 300, fmt)!;
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
// Items are sorted by tick position, which mirrors value order.
|
||||||
|
const ticks = layout.items.map((i) => i.tickX);
|
||||||
|
for (let i = 1; i < ticks.length; i++) {
|
||||||
|
expect(ticks[i]).toBeGreaterThanOrEqual(ticks[i - 1]);
|
||||||
|
}
|
||||||
|
// The smallest value (national, 30) sits left of the largest (sector, 51).
|
||||||
|
const national = layout.items.find((i) => i.kind === 'national')!;
|
||||||
|
const sector = layout.items.find((i) => i.kind === 'sector')!;
|
||||||
|
expect(national.tickX).toBeLessThan(sector.tickX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the lowest value to the left edge and the highest to the right', () => {
|
||||||
|
const layout = computeNumberLineLayout(
|
||||||
|
[
|
||||||
|
{ kind: 'national', label: 'N', value: 30 },
|
||||||
|
{ kind: 'area', label: 'A', value: 50 },
|
||||||
|
{ kind: 'sector', label: 'S', value: 45 },
|
||||||
|
],
|
||||||
|
300,
|
||||||
|
fmt
|
||||||
|
)!;
|
||||||
|
const low = layout.items.find((i) => i.kind === 'national')!; // 30 — lowest
|
||||||
|
const high = layout.items.find((i) => i.kind === 'area')!; // 50 — highest
|
||||||
|
expect(low.tickX).toBeCloseTo(layout.plotLeft, 5);
|
||||||
|
expect(high.tickX).toBeCloseTo(layout.plotRight, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centres ticks when all values are equal', () => {
|
||||||
|
const layout = computeNumberLineLayout(
|
||||||
|
[
|
||||||
|
{ kind: 'area', label: 'A', value: 7 },
|
||||||
|
{ kind: 'national', label: 'N', value: 7 },
|
||||||
|
],
|
||||||
|
300,
|
||||||
|
fmt
|
||||||
|
)!;
|
||||||
|
const centre = (layout.plotLeft + layout.plotRight) / 2;
|
||||||
|
for (const item of layout.items) {
|
||||||
|
expect(item.tickX).toBeCloseTo(centre, 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spreads clustered labels so their boxes never overlap', () => {
|
||||||
|
const layout = computeNumberLineLayout(points, 300, fmt)!;
|
||||||
|
for (let i = 1; i < layout.items.length; i++) {
|
||||||
|
const prev = layout.items[i - 1];
|
||||||
|
const cur = layout.items[i];
|
||||||
|
// Boxes [labelX ± halfWidth] must not overlap (GAP only adds more margin).
|
||||||
|
expect(cur.labelX - cur.halfWidth).toBeGreaterThanOrEqual(
|
||||||
|
prev.labelX + prev.halfWidth - 1e-6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps labels within the plot bounds', () => {
|
||||||
|
const layout = computeNumberLineLayout(points, 300, fmt)!;
|
||||||
|
for (const item of layout.items) {
|
||||||
|
expect(item.labelX - item.halfWidth).toBeGreaterThanOrEqual(layout.plotLeft - 1e-6);
|
||||||
|
expect(item.labelX + item.halfWidth).toBeLessThanOrEqual(layout.plotRight + 1e-6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
208
frontend/src/components/map/NumberLine.tsx
Normal file
208
frontend/src/components/map/NumberLine.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export type NumberLineKind = 'area' | 'national' | 'outcode' | 'sector';
|
||||||
|
|
||||||
|
export interface NumberLinePoint {
|
||||||
|
kind: NumberLineKind;
|
||||||
|
/** Short label shown above the tick (e.g. "This area", "National", "SE3", "SE3 9"). */
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NumberLineProps {
|
||||||
|
points: NumberLinePoint[];
|
||||||
|
format: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEIGHT = 54;
|
||||||
|
const PAD_X = 4;
|
||||||
|
const NAME_Y = 9;
|
||||||
|
const VALUE_Y = 19;
|
||||||
|
const LEADER_TOP = 23;
|
||||||
|
const BASE_Y = 40;
|
||||||
|
const GAP = 4;
|
||||||
|
|
||||||
|
// Per-kind tick (stroke) and label (fill) colours. `area` is the subject and
|
||||||
|
// is emphasised; the rest are reference points.
|
||||||
|
const KIND_STYLE: Record<NumberLineKind, { tick: string; text: string }> = {
|
||||||
|
area: { tick: 'stroke-teal-600 dark:stroke-teal-400', text: 'fill-teal-700 dark:fill-teal-300' },
|
||||||
|
national: {
|
||||||
|
tick: 'stroke-warm-400 dark:stroke-warm-500',
|
||||||
|
text: 'fill-warm-500 dark:fill-warm-300',
|
||||||
|
},
|
||||||
|
outcode: {
|
||||||
|
tick: 'stroke-amber-500 dark:stroke-amber-400',
|
||||||
|
text: 'fill-amber-700 dark:fill-amber-400',
|
||||||
|
},
|
||||||
|
sector: {
|
||||||
|
tick: 'stroke-indigo-500 dark:stroke-indigo-400',
|
||||||
|
text: 'fill-indigo-600 dark:fill-indigo-300',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NumberLineLayoutItem extends NumberLinePoint {
|
||||||
|
valueText: string;
|
||||||
|
/** x of the tick (the true value position on the scale). */
|
||||||
|
tickX: number;
|
||||||
|
/** x of the (de-collided) label centre. */
|
||||||
|
labelX: number;
|
||||||
|
/** half the estimated label-box width, used for collision spacing. */
|
||||||
|
halfWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberLineLayout {
|
||||||
|
items: NumberLineLayoutItem[];
|
||||||
|
plotLeft: number;
|
||||||
|
plotRight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure layout: place each value's tick on a scale spanning the lowest→highest
|
||||||
|
* value (not anchored at 0, to maximise separation) and spread the labels so
|
||||||
|
* their boxes never overlap (nested area/sector/outcode values tend to cluster),
|
||||||
|
* clamped within the plot bounds while preserving order. Returns null when there
|
||||||
|
* is nothing to draw. Exported for testing.
|
||||||
|
*/
|
||||||
|
export function computeNumberLineLayout(
|
||||||
|
points: NumberLinePoint[],
|
||||||
|
width: number,
|
||||||
|
format: (value: number) => string
|
||||||
|
): NumberLineLayout | null {
|
||||||
|
if (width <= 0 || points.length === 0) return null;
|
||||||
|
|
||||||
|
const values = points.map((p) => p.value);
|
||||||
|
const lo = Math.min(...values);
|
||||||
|
const hi = Math.max(...values);
|
||||||
|
const span = hi - lo;
|
||||||
|
const plotLeft = PAD_X;
|
||||||
|
const plotRight = width - PAD_X;
|
||||||
|
const plotW = Math.max(1, plotRight - plotLeft);
|
||||||
|
// Scale spans the lowest→highest value (not anchored at 0) to maximise the
|
||||||
|
// separation between the clustered ticks. When all values are equal, centre them.
|
||||||
|
const scaleX = (value: number) =>
|
||||||
|
span > 0 ? plotLeft + ((value - lo) / span) * plotW : plotLeft + plotW / 2;
|
||||||
|
|
||||||
|
const items: NumberLineLayoutItem[] = points.map((point) => {
|
||||||
|
const valueText = format(point.value);
|
||||||
|
// Rough text-width estimate at the 9px font (≈5px/char), padded a little.
|
||||||
|
const chars = Math.max(point.label.length, valueText.length);
|
||||||
|
const halfWidth = Math.max(9, (chars * 5) / 2 + 2);
|
||||||
|
const tickX = scaleX(point.value);
|
||||||
|
return { ...point, valueText, tickX, halfWidth, labelX: tickX };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spread labels left-to-right so adjacent boxes never overlap, then clamp to
|
||||||
|
// the plot bounds (right pass, then left pass) preserving order.
|
||||||
|
items.sort((a, b) => a.tickX - b.tickX);
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth;
|
||||||
|
if (items[i].labelX < minX) items[i].labelX = minX;
|
||||||
|
}
|
||||||
|
const last = items.length - 1;
|
||||||
|
if (last >= 0) {
|
||||||
|
items[last].labelX = Math.min(items[last].labelX, plotRight - items[last].halfWidth);
|
||||||
|
for (let i = last - 1; i >= 0; i--) {
|
||||||
|
const maxX = items[i + 1].labelX - items[i + 1].halfWidth - GAP - items[i].halfWidth;
|
||||||
|
if (items[i].labelX > maxX) items[i].labelX = maxX;
|
||||||
|
}
|
||||||
|
items[0].labelX = Math.max(items[0].labelX, plotLeft + items[0].halfWidth);
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const minX = items[i - 1].labelX + items[i - 1].halfWidth + GAP + items[i].halfWidth;
|
||||||
|
if (items[i].labelX < minX) items[i].labelX = minX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, plotLeft, plotRight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compact horizontal number line that places one tick per value on a scale
|
||||||
|
* spanning the lowest→highest value, so the selection can be read against its
|
||||||
|
* reference points (national / outcode / sector) at a glance. Labels are spread
|
||||||
|
* to avoid overlap
|
||||||
|
* — common since the nested area/sector/outcode values cluster — and connected
|
||||||
|
* back to their tick with a thin leader line.
|
||||||
|
*/
|
||||||
|
export default function NumberLine({ points, format }: NumberLineProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const w = entries[0].contentRect.width;
|
||||||
|
if (w > 0) setWidth(w);
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const laidOut = useMemo(
|
||||||
|
() => computeNumberLineLayout(points, width, format),
|
||||||
|
[points, width, format]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full">
|
||||||
|
{laidOut && (
|
||||||
|
<svg width={width} height={HEIGHT} className="block">
|
||||||
|
<line
|
||||||
|
x1={laidOut.plotLeft}
|
||||||
|
y1={BASE_Y}
|
||||||
|
x2={laidOut.plotRight}
|
||||||
|
y2={BASE_Y}
|
||||||
|
strokeWidth={1}
|
||||||
|
className="stroke-warm-300 dark:stroke-warm-600"
|
||||||
|
/>
|
||||||
|
{laidOut.items.map((item) => {
|
||||||
|
const style = KIND_STYLE[item.kind];
|
||||||
|
const emphasized = item.kind === 'area';
|
||||||
|
const halfTick = emphasized ? 7 : 5;
|
||||||
|
return (
|
||||||
|
<g key={item.kind}>
|
||||||
|
<line
|
||||||
|
x1={item.labelX}
|
||||||
|
y1={LEADER_TOP}
|
||||||
|
x2={item.tickX}
|
||||||
|
y2={BASE_Y - halfTick}
|
||||||
|
strokeWidth={0.75}
|
||||||
|
className="stroke-warm-300 dark:stroke-warm-600"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={item.tickX}
|
||||||
|
y1={BASE_Y - halfTick}
|
||||||
|
x2={item.tickX}
|
||||||
|
y2={BASE_Y + halfTick}
|
||||||
|
strokeWidth={emphasized ? 2.5 : 1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={style.tick}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={item.labelX}
|
||||||
|
y={NAME_Y}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fontWeight={emphasized ? 700 : 500}
|
||||||
|
className={style.text}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={item.labelX}
|
||||||
|
y={VALUE_Y}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
className={style.text}
|
||||||
|
>
|
||||||
|
{item.valueText}
|
||||||
|
</text>
|
||||||
|
<title>{`${item.label}: ${item.valueText}`}</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -79,8 +79,43 @@ export function OverlayTileLayers({
|
||||||
10,
|
10,
|
||||||
1,
|
1,
|
||||||
],
|
],
|
||||||
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
|
// police.uk snaps incidents to a sparse, fixed set of anonymised
|
||||||
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
|
// "map point" anchors (tens-to-hundreds of metres apart), so a
|
||||||
|
// pixel-fixed blur fragments into isolated dots as you zoom in
|
||||||
|
// (the ground a pixel covers halves each zoom level). Grow the
|
||||||
|
// radius ~geometrically with zoom to hold a roughly constant
|
||||||
|
// ~140m ground footprint, so neighbouring anchors' kernels keep
|
||||||
|
// overlapping into a connected surface — kernel-density smoothing
|
||||||
|
// is the honest form of "interpolating between points" for this
|
||||||
|
// data; we deliberately do NOT triangulate/IDW, which would
|
||||||
|
// invent crime values across parks/water and over-claim a
|
||||||
|
// precision the anonymised snap-points don't have. Cap past z16 so
|
||||||
|
// the blur doesn't saturate the whole screen at street zoom (where
|
||||||
|
// an honestly-connected crime surface isn't achievable anyway).
|
||||||
|
// Intensity is lowered to match: wider overlapping kernels pile up
|
||||||
|
// density, so the old isolated-dot boost would now go all-red.
|
||||||
|
'heatmap-intensity': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['zoom'],
|
||||||
|
14,
|
||||||
|
0.6,
|
||||||
|
16,
|
||||||
|
0.9,
|
||||||
|
18,
|
||||||
|
1.2,
|
||||||
|
],
|
||||||
|
'heatmap-radius': [
|
||||||
|
'interpolate',
|
||||||
|
['exponential', 2],
|
||||||
|
['zoom'],
|
||||||
|
14,
|
||||||
|
24,
|
||||||
|
16,
|
||||||
|
94,
|
||||||
|
18,
|
||||||
|
120,
|
||||||
|
],
|
||||||
'heatmap-opacity': 0.72,
|
'heatmap-opacity': 0.72,
|
||||||
'heatmap-color': [
|
'heatmap-color': [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,7 @@ export function ActiveFiltersPanel({
|
||||||
<div className="px-3 pb-2 space-y-2">
|
<div className="px-3 pb-2 space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={onShowPhilosophy}
|
onClick={onShowPhilosophy}
|
||||||
|
data-video-hide="ai-cta"
|
||||||
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<LightbulbIcon />
|
<LightbulbIcon />
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
Bounds,
|
Bounds,
|
||||||
ActualListing,
|
ActualListing,
|
||||||
|
Development,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import {
|
import {
|
||||||
DENSITY_GRADIENT,
|
DENSITY_GRADIENT,
|
||||||
|
|
@ -23,6 +24,7 @@ import { getFeatureFillColor } from '../lib/map-utils';
|
||||||
import type { TravelTimeEntry } from './useTravelTime';
|
import type { TravelTimeEntry } from './useTravelTime';
|
||||||
import { usePoiLayers } from './usePoiLayers';
|
import { usePoiLayers } from './usePoiLayers';
|
||||||
import { useListingLayers } from './useListingLayers';
|
import { useListingLayers } from './useListingLayers';
|
||||||
|
import { useDevelopmentLayers } from './useDevelopmentLayers';
|
||||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||||
import { PieHexExtension } from '../lib/PieHexExtension';
|
import { PieHexExtension } from '../lib/PieHexExtension';
|
||||||
import { normalizeColorOpacity } from '../lib/color-opacity';
|
import { normalizeColorOpacity } from '../lib/color-opacity';
|
||||||
|
|
@ -34,6 +36,7 @@ interface UseDeckLayersProps {
|
||||||
zoom: number;
|
zoom: number;
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
actualListings: ActualListing[];
|
actualListings: ActualListing[];
|
||||||
|
developments: Development[];
|
||||||
viewFeature: string | null;
|
viewFeature: string | null;
|
||||||
colorRange: [number, number] | null;
|
colorRange: [number, number] | null;
|
||||||
filterRange: [number, number] | null;
|
filterRange: [number, number] | null;
|
||||||
|
|
@ -85,6 +88,7 @@ export function useDeckLayers({
|
||||||
zoom,
|
zoom,
|
||||||
pois,
|
pois,
|
||||||
actualListings,
|
actualListings,
|
||||||
|
developments,
|
||||||
viewFeature,
|
viewFeature,
|
||||||
colorRange,
|
colorRange,
|
||||||
filterRange,
|
filterRange,
|
||||||
|
|
@ -126,6 +130,11 @@ export function useDeckLayers({
|
||||||
zoom,
|
zoom,
|
||||||
isDark,
|
isDark,
|
||||||
});
|
});
|
||||||
|
const { developmentLayers, developmentPopup, clearDevelopmentPopup } = useDevelopmentLayers({
|
||||||
|
developments,
|
||||||
|
zoom,
|
||||||
|
isDark,
|
||||||
|
});
|
||||||
|
|
||||||
// --- Refs for deck.gl accessors ---
|
// --- Refs for deck.gl accessors ---
|
||||||
const viewFeatureRef = useRef(viewFeature);
|
const viewFeatureRef = useRef(viewFeature);
|
||||||
|
|
@ -709,6 +718,7 @@ export function useDeckLayers({
|
||||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||||
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
|
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
|
||||||
if (listingLayers.length > 0) baseLayers.push(...listingLayers);
|
if (listingLayers.length > 0) baseLayers.push(...listingLayers);
|
||||||
|
if (developmentLayers.length > 0) baseLayers.push(...developmentLayers);
|
||||||
return baseLayers;
|
return baseLayers;
|
||||||
}, [
|
}, [
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
|
|
@ -720,6 +730,7 @@ export function useDeckLayers({
|
||||||
marchingAntsLayer,
|
marchingAntsLayer,
|
||||||
currentLocationLayer,
|
currentLocationLayer,
|
||||||
listingLayers,
|
listingLayers,
|
||||||
|
developmentLayers,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
|
@ -727,8 +738,9 @@ export function useDeckLayers({
|
||||||
setHoveredPostcode(null);
|
setHoveredPostcode(null);
|
||||||
clearPopupInfo();
|
clearPopupInfo();
|
||||||
clearListingPopup();
|
clearListingPopup();
|
||||||
|
clearDevelopmentPopup();
|
||||||
onHexagonHoverRef.current(null);
|
onHexagonHoverRef.current(null);
|
||||||
}, [clearPopupInfo, clearListingPopup]);
|
}, [clearPopupInfo, clearListingPopup, clearDevelopmentPopup]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layers,
|
layers,
|
||||||
|
|
@ -737,6 +749,8 @@ export function useDeckLayers({
|
||||||
clearPopupInfo,
|
clearPopupInfo,
|
||||||
listingPopup,
|
listingPopup,
|
||||||
clearListingPopup,
|
clearListingPopup,
|
||||||
|
developmentPopup,
|
||||||
|
clearDevelopmentPopup,
|
||||||
hoverPosition,
|
hoverPosition,
|
||||||
countRange,
|
countRange,
|
||||||
postcodeCountRange,
|
postcodeCountRange,
|
||||||
|
|
|
||||||
152
frontend/src/hooks/useDevelopmentLayers.ts
Normal file
152
frontend/src/hooks/useDevelopmentLayers.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||||
|
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||||
|
|
||||||
|
import type { Development } from '../types';
|
||||||
|
import { trackEvent } from '../lib/analytics';
|
||||||
|
|
||||||
|
const COUNT_LABEL_MIN_ZOOM = 14;
|
||||||
|
|
||||||
|
export interface DevelopmentPopupInfo {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
development: Development;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDevelopmentLayersProps {
|
||||||
|
developments: Development[];
|
||||||
|
zoom: number;
|
||||||
|
isDark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dwellingCount(d: Development): number | null {
|
||||||
|
return d.max_dwellings ?? d.min_dwellings ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pixel radius scales with the number of homes so larger schemes read as larger
|
||||||
|
// dots. sqrt compresses the range (a 5,000-home site shouldn't dwarf a 20-home
|
||||||
|
// one) and the result is clamped to a sane band; sites with no count get the floor.
|
||||||
|
function radiusForDwellings(d: Development): number {
|
||||||
|
const count = dwellingCount(d) ?? 0;
|
||||||
|
return Math.min(22, 4 + Math.sqrt(Math.max(count, 0)) * 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders development sites as blue point markers (a soft shadow, a pin, and a
|
||||||
|
* dwelling-count label at higher zoom). Sparse data — the endpoint caps the
|
||||||
|
* viewport at a few thousand points — so no clustering is needed, unlike the
|
||||||
|
* listings layer. Hover shows a popup; click opens the planning record.
|
||||||
|
*/
|
||||||
|
export function useDevelopmentLayers({ developments, zoom, isDark }: UseDevelopmentLayersProps) {
|
||||||
|
const [popupInfo, setPopupInfo] = useState<DevelopmentPopupInfo | null>(null);
|
||||||
|
|
||||||
|
// A fresh fetch returns a new array; the previously hovered object is gone.
|
||||||
|
useEffect(() => {
|
||||||
|
setPopupInfo(null);
|
||||||
|
}, [developments]);
|
||||||
|
|
||||||
|
const points = useMemo(
|
||||||
|
() => developments.filter((d) => Number.isFinite(d.lat) && Number.isFinite(d.lon)),
|
||||||
|
[developments]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHover = useCallback((info: PickingInfo<Development>) => {
|
||||||
|
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||||
|
setPopupInfo({ x: info.x, y: info.y, development: info.object });
|
||||||
|
} else {
|
||||||
|
setPopupInfo(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = useCallback((info: PickingInfo<Development>) => {
|
||||||
|
const url = info.object?.url;
|
||||||
|
if (!url) return;
|
||||||
|
trackEvent('Development Site Click', { url });
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleHoverRef = useRef(handleHover);
|
||||||
|
handleHoverRef.current = handleHover;
|
||||||
|
const stableHover = useCallback(
|
||||||
|
(info: PickingInfo<Development>) => handleHoverRef.current(info),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClickRef = useRef(handleClick);
|
||||||
|
handleClickRef.current = handleClick;
|
||||||
|
const stableClick = useCallback(
|
||||||
|
(info: PickingInfo<Development>) => handleClickRef.current(info),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shadowLayer = useMemo(
|
||||||
|
() =>
|
||||||
|
new ScatterplotLayer<Development>({
|
||||||
|
id: 'development-shadow',
|
||||||
|
data: points,
|
||||||
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
|
getRadius: (d) => radiusForDwellings(d) + 1.5,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
[points, isDark]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pinLayer = useMemo(
|
||||||
|
() =>
|
||||||
|
new ScatterplotLayer<Development>({
|
||||||
|
id: 'development-pin',
|
||||||
|
data: points,
|
||||||
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
|
getRadius: radiusForDwellings,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getFillColor: [37, 99, 235, 235],
|
||||||
|
getLineColor: [255, 255, 255, 255],
|
||||||
|
getLineWidth: 1.5,
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
stroked: true,
|
||||||
|
pickable: true,
|
||||||
|
autoHighlight: true,
|
||||||
|
highlightColor: [29, 228, 195, 220],
|
||||||
|
onHover: stableHover,
|
||||||
|
onClick: stableClick,
|
||||||
|
}),
|
||||||
|
[points, stableHover, stableClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const countLabelLayer = useMemo(() => {
|
||||||
|
if (zoom < COUNT_LABEL_MIN_ZOOM) return null;
|
||||||
|
const labeled = points.filter((d) => (dwellingCount(d) ?? 0) > 0);
|
||||||
|
return new TextLayer<Development>({
|
||||||
|
id: 'development-count',
|
||||||
|
data: labeled,
|
||||||
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
|
getText: (d) => String(dwellingCount(d) ?? ''),
|
||||||
|
getSize: 11,
|
||||||
|
getPixelOffset: (d) => [0, -(radiusForDwellings(d) + 6)],
|
||||||
|
getColor: isDark ? [191, 219, 254, 240] : [30, 58, 138, 240],
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'bottom',
|
||||||
|
outlineWidth: 3,
|
||||||
|
outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230],
|
||||||
|
fontSettings: { sdf: true },
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
sizeMinPixels: 9,
|
||||||
|
sizeMaxPixels: 13,
|
||||||
|
pickable: false,
|
||||||
|
});
|
||||||
|
}, [points, zoom, isDark]);
|
||||||
|
|
||||||
|
const developmentLayers = useMemo(() => {
|
||||||
|
const layers: Layer[] = [shadowLayer, pinLayer];
|
||||||
|
if (countLabelLayer) layers.push(countLabelLayer);
|
||||||
|
return layers;
|
||||||
|
}, [shadowLayer, pinLayer, countLabelLayer]);
|
||||||
|
|
||||||
|
const clearDevelopmentPopup = useCallback(() => setPopupInfo(null), []);
|
||||||
|
|
||||||
|
return { developmentLayers, developmentPopup: popupInfo, clearDevelopmentPopup };
|
||||||
|
}
|
||||||
81
frontend/src/hooks/useDevelopments.ts
Normal file
81
frontend/src/hooks/useDevelopments.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { Bounds, Development, DevelopmentsResponse } from '../types';
|
||||||
|
import { apiUrl, authHeaders, isAbortError, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 200;
|
||||||
|
|
||||||
|
interface UseDevelopmentsOptions {
|
||||||
|
/** Only fetch when the "new developments" overlay is toggled on. */
|
||||||
|
enabled?: boolean;
|
||||||
|
shareCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches planned/pipeline development sites (brownfield register + Homes
|
||||||
|
* England) within the current viewport. Mirrors useActualListings, but gated by
|
||||||
|
* an `enabled` flag so nothing is fetched until the overlay is switched on.
|
||||||
|
*/
|
||||||
|
export function useDevelopments(
|
||||||
|
bounds: Bounds | null,
|
||||||
|
{ enabled = false, shareCode = '' }: UseDevelopmentsOptions = {}
|
||||||
|
) {
|
||||||
|
const [developments, setDevelopments] = useState<Development[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
const requestId = requestIdRef.current;
|
||||||
|
|
||||||
|
if (!enabled || !bounds) {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
if (developments.length !== 0) setDevelopments([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
try {
|
||||||
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
|
if (shareCode) params.set('share', shareCode);
|
||||||
|
const res = await fetch(
|
||||||
|
apiUrl('developments', params),
|
||||||
|
authHeaders({ signal: abortControllerRef.current.signal })
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (requestIdRef.current === requestId) {
|
||||||
|
setDevelopments([]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
throw new Error(`Developments fetch failed: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const json: DevelopmentsResponse = await res.json();
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setDevelopments(json.developments || []);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (requestIdRef.current === requestId && !isAbortError(err)) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
logNonAbortError('Failed to fetch developments', err);
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
// developments intentionally excluded — it's internal state, not an input.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enabled, bounds, shareCode]);
|
||||||
|
|
||||||
|
return { developments, loading };
|
||||||
|
}
|
||||||
|
|
@ -186,6 +186,47 @@ describe('usePoiLayers', () => {
|
||||||
expect(result.current.popupInfo).toBeNull();
|
expect(result.current.popupInfo).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dismisses a stuck hover popup once its POI is gone', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ pois }) => usePoiLayers({ pois, zoom: 15, isDark: false }),
|
||||||
|
{ initialProps: { pois: [supermarket, busStop] } }
|
||||||
|
);
|
||||||
|
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
(backgroundLayer.props.onHover as (info: unknown) => void)({
|
||||||
|
object: supermarket,
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.popupInfo?.id).toBe(supermarket.id);
|
||||||
|
|
||||||
|
// Disabling POIs empties the list — the popup must not stay stuck on screen.
|
||||||
|
rerender({ pois: [] });
|
||||||
|
expect(result.current.popupInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses the hover popup when only its specific POI is deselected', () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ pois }) => usePoiLayers({ pois, zoom: 15, isDark: false }),
|
||||||
|
{ initialProps: { pois: [supermarket, busStop] } }
|
||||||
|
);
|
||||||
|
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
|
||||||
|
act(() => {
|
||||||
|
(backgroundLayer.props.onHover as (info: unknown) => void)({
|
||||||
|
object: supermarket,
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(result.current.popupInfo?.id).toBe(supermarket.id);
|
||||||
|
|
||||||
|
// busStop remains but the hovered supermarket is gone -> popup clears.
|
||||||
|
rerender({ pois: [busStop] });
|
||||||
|
expect(result.current.popupInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates cluster hover popup state from clustered POIs', () => {
|
it('creates cluster hover popup state from clustered POIs', () => {
|
||||||
const clusteredPois = Array.from(
|
const clusteredPois = Array.from(
|
||||||
{ length: 4 },
|
{ length: 4 },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { PickingInfo } from '@deck.gl/core';
|
import type { PickingInfo } from '@deck.gl/core';
|
||||||
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import Supercluster from 'supercluster';
|
import Supercluster from 'supercluster';
|
||||||
|
|
@ -67,6 +67,18 @@ function getPoiIconSize(poi: POI): number {
|
||||||
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||||
|
|
||||||
|
// Dismiss a lingering hover/cluster popup once the POIs behind it are gone — e.g.
|
||||||
|
// after the user clears the POI categories. Without this the card stays stuck on
|
||||||
|
// screen because it is only otherwise cleared on mouse-leave or the close button.
|
||||||
|
useEffect(() => {
|
||||||
|
setPopupInfo((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
if (pois.length === 0) return null;
|
||||||
|
if (current.isCluster) return current;
|
||||||
|
return pois.some((poi) => poi.id === current.id) ? current : null;
|
||||||
|
});
|
||||||
|
}, [pois]);
|
||||||
|
|
||||||
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||||
setPopupInfo({
|
setPopupInfo({
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,25 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Public order (avg/yr)': 'Moyenne annuelle des troubles à l’ordre public dans le secteur',
|
'Public order (avg/yr)': 'Moyenne annuelle des troubles à l’ordre public dans le secteur',
|
||||||
'Other crime (avg/yr)': 'Moyenne annuelle des autres infractions dans le secteur',
|
'Other crime (avg/yr)': 'Moyenne annuelle des autres infractions dans le secteur',
|
||||||
'Median age': 'Âge médian de la population locale',
|
'Median age': 'Âge médian de la population locale',
|
||||||
|
'% No qualifications': 'Part des résidents (16+) sans aucun diplôme',
|
||||||
|
'% Some GCSEs':
|
||||||
|
'Part des résidents (16+) dont le diplôme le plus élevé équivaut à 1 à 4 GCSE environ (niveau 1)',
|
||||||
|
'% Good GCSEs':
|
||||||
|
'Part des résidents (16+) dont le diplôme le plus élevé est 5 GCSE ou plus (niveau 2)',
|
||||||
|
'% Apprenticeship':
|
||||||
|
'Part des résidents (16+) dont le diplôme le plus élevé est un apprentissage',
|
||||||
|
'% A-levels':
|
||||||
|
'Part des résidents (16+) dont le diplôme le plus élevé est le A-levels (niveau 3)',
|
||||||
|
'% Degree or higher':
|
||||||
|
'Part des résidents (16+) ayant un diplôme de niveau universitaire ou supérieur',
|
||||||
|
'% Other qualifications':
|
||||||
|
"Part des résidents (16+) ayant d'autres diplômes, y compris professionnels ou obtenus à l'étranger",
|
||||||
|
'% Owner occupied':
|
||||||
|
'Part des ménages propriétaires de leur logement, intégralement ou avec un crédit',
|
||||||
|
'% Social rent':
|
||||||
|
"Part des ménages locataires auprès d'une collectivité locale ou d'un bailleur social",
|
||||||
|
'% Private rent':
|
||||||
|
'Part des ménages locataires dans le privé ou logés à titre gratuit',
|
||||||
'% White': 'Part de la population s’identifiant comme blanche',
|
'% White': 'Part de la population s’identifiant comme blanche',
|
||||||
'% South Asian': 'Part de la population s’identifiant comme sud-asiatique',
|
'% South Asian': 'Part de la population s’identifiant comme sud-asiatique',
|
||||||
'% Black': 'Part de la population s’identifiant comme noire',
|
'% Black': 'Part de la population s’identifiant comme noire',
|
||||||
|
|
@ -170,6 +189,23 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
|
'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
|
||||||
'Other crime (avg/yr)': 'Jährlicher Durchschnitt sonstiger Straftaten in der Gegend',
|
'Other crime (avg/yr)': 'Jährlicher Durchschnitt sonstiger Straftaten in der Gegend',
|
||||||
'Median age': 'Medianalter der lokalen Bevölkerung',
|
'Median age': 'Medianalter der lokalen Bevölkerung',
|
||||||
|
'% No qualifications': 'Anteil der Einwohner (16+) ohne formalen Abschluss',
|
||||||
|
'% Some GCSEs':
|
||||||
|
'Anteil der Einwohner (16+), deren höchster Abschluss etwa 1–4 GCSEs entspricht (Level 1)',
|
||||||
|
'% Good GCSEs':
|
||||||
|
'Anteil der Einwohner (16+), deren höchster Abschluss 5+ GCSEs entspricht (Level 2)',
|
||||||
|
'% Apprenticeship': 'Anteil der Einwohner (16+), deren höchster Abschluss eine Ausbildung ist',
|
||||||
|
'% A-levels': 'Anteil der Einwohner (16+), deren höchster Abschluss A-levels ist (Level 3)',
|
||||||
|
'% Degree or higher':
|
||||||
|
'Anteil der Einwohner (16+) mit einem Abschluss auf Hochschulniveau oder höher',
|
||||||
|
'% Other qualifications':
|
||||||
|
'Anteil der Einwohner (16+) mit sonstigen Abschlüssen, einschließlich beruflicher oder im Ausland erworbener',
|
||||||
|
'% Owner occupied':
|
||||||
|
'Anteil der Haushalte, die ihre Wohnung besitzen, schuldenfrei oder mit Hypothek',
|
||||||
|
'% Social rent':
|
||||||
|
'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten',
|
||||||
|
'% Private rent':
|
||||||
|
'Anteil der Haushalte, die privat mieten oder mietfrei wohnen',
|
||||||
'% White': 'Anteil der Personen, die sich als weiß identifizieren',
|
'% White': 'Anteil der Personen, die sich als weiß identifizieren',
|
||||||
'% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren',
|
'% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren',
|
||||||
'% Black': 'Anteil der Personen, die sich als schwarz identifizieren',
|
'% Black': 'Anteil der Personen, die sich als schwarz identifizieren',
|
||||||
|
|
@ -245,6 +281,16 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Public order (avg/yr)': '该地区年均扰乱公共秩序数',
|
'Public order (avg/yr)': '该地区年均扰乱公共秩序数',
|
||||||
'Other crime (avg/yr)': '该地区年均其他犯罪数',
|
'Other crime (avg/yr)': '该地区年均其他犯罪数',
|
||||||
'Median age': '当地人口的中位年龄',
|
'Median age': '当地人口的中位年龄',
|
||||||
|
'% No qualifications': '无任何学历的居民(16岁以上)占比',
|
||||||
|
'% Some GCSEs': '最高学历约为 1–4 门 GCSE(Level 1)的居民(16岁以上)占比',
|
||||||
|
'% Good GCSEs': '最高学历为 5 门及以上 GCSE(Level 2)的居民(16岁以上)占比',
|
||||||
|
'% Apprenticeship': '最高学历为学徒制的居民(16岁以上)占比',
|
||||||
|
'% A-levels': '最高学历为 A-levels(Level 3)的居民(16岁以上)占比',
|
||||||
|
'% Degree or higher': '拥有学位或更高学历的居民(16岁以上)占比',
|
||||||
|
'% Other qualifications': '拥有其他学历(包括职业资格或境外取得)的居民(16岁以上)占比',
|
||||||
|
'% Owner occupied': '拥有自住房(全款或按揭)的家庭比例',
|
||||||
|
'% Social rent': '向地方政府或住房协会租房的家庭比例',
|
||||||
|
'% Private rent': '私人租房或免租居住的家庭比例',
|
||||||
'% White': '白人人口比例',
|
'% White': '白人人口比例',
|
||||||
'% South Asian': '南亚裔人口比例',
|
'% South Asian': '南亚裔人口比例',
|
||||||
'% Black': '黑人人口比例',
|
'% Black': '黑人人口比例',
|
||||||
|
|
@ -324,6 +370,21 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Public order (avg/yr)': 'क्षेत्र में सार्वजनिक व्यवस्था अपराधों का सालाना औसत',
|
'Public order (avg/yr)': 'क्षेत्र में सार्वजनिक व्यवस्था अपराधों का सालाना औसत',
|
||||||
'Other crime (avg/yr)': 'क्षेत्र में अन्य अपराधों का सालाना औसत',
|
'Other crime (avg/yr)': 'क्षेत्र में अन्य अपराधों का सालाना औसत',
|
||||||
'Median age': 'स्थानीय आबादी की मध्य आयु',
|
'Median age': 'स्थानीय आबादी की मध्य आयु',
|
||||||
|
'% No qualifications': 'बिना किसी औपचारिक योग्यता वाले निवासियों (16+) का हिस्सा',
|
||||||
|
'% Some GCSEs':
|
||||||
|
'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता लगभग 1–4 GCSE (Level 1) है',
|
||||||
|
'% Good GCSEs': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता 5+ GCSE (Level 2) है',
|
||||||
|
'% Apprenticeship': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता अप्रेंटिसशिप है',
|
||||||
|
'% A-levels': 'ऐसे निवासियों (16+) का हिस्सा जिनकी सर्वोच्च योग्यता A-levels (Level 3) है',
|
||||||
|
'% Degree or higher': 'डिग्री स्तर या उससे ऊंची योग्यता वाले निवासियों (16+) का हिस्सा',
|
||||||
|
'% Other qualifications':
|
||||||
|
'अन्य योग्यताओं वाले निवासियों (16+) का हिस्सा, जिनमें व्यावसायिक या विदेश में प्राप्त योग्यताएं शामिल हैं',
|
||||||
|
'% Owner occupied':
|
||||||
|
'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ',
|
||||||
|
'% Social rent':
|
||||||
|
'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा',
|
||||||
|
'% Private rent':
|
||||||
|
'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा',
|
||||||
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||||
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||||
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||||
|
|
@ -409,6 +470,22 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Public order (avg/yr)': 'Közrend elleni bűncselekmények éves átlaga a környéken',
|
'Public order (avg/yr)': 'Közrend elleni bűncselekmények éves átlaga a környéken',
|
||||||
'Other crime (avg/yr)': 'Egyéb bűncselekmények éves átlaga a környéken',
|
'Other crime (avg/yr)': 'Egyéb bűncselekmények éves átlaga a környéken',
|
||||||
'Median age': 'A helyi lakosság medián életkora',
|
'Median age': 'A helyi lakosság medián életkora',
|
||||||
|
'% No qualifications': 'A végzettség nélküli lakosok (16+) aránya',
|
||||||
|
'% Some GCSEs':
|
||||||
|
'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége kb. 1–4 GCSE (Level 1)',
|
||||||
|
'% Good GCSEs':
|
||||||
|
'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége 5+ GCSE (Level 2)',
|
||||||
|
'% Apprenticeship': 'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége tanoncképzés',
|
||||||
|
'% A-levels': 'Azon lakosok (16+) aránya, akiknek a legmagasabb végzettsége A-levels (Level 3)',
|
||||||
|
'% Degree or higher': 'A diploma szintű vagy magasabb végzettségű lakosok (16+) aránya',
|
||||||
|
'% Other qualifications':
|
||||||
|
'Az egyéb végzettségű lakosok (16+) aránya, ideértve a szakmai vagy külföldön szerzett képesítéseket is',
|
||||||
|
'% Owner occupied':
|
||||||
|
'Azon háztartások aránya, amelyek saját tulajdonú lakásban élnek, tehermentesen vagy jelzáloggal',
|
||||||
|
'% Social rent':
|
||||||
|
'Azon háztartások aránya, amelyek önkormányzattól vagy lakásszövetkezettől bérelnek',
|
||||||
|
'% Private rent':
|
||||||
|
'Azon háztartások aránya, amelyek magánbérleményben élnek vagy lakbér nélkül laknak',
|
||||||
'% White': 'A fehérként azonosított lakosság aránya',
|
'% White': 'A fehérként azonosított lakosság aránya',
|
||||||
'% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya',
|
'% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya',
|
||||||
'% Black': 'A feketeként azonosított lakosság aránya',
|
'% Black': 'A feketeként azonosított lakosság aránya',
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,26 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
"Nombre moyen annuel d'infractions « other crime » dans un rayon de 50 m du code postal, d’après les données de criminalité street-level de police.uk. Catégorie résiduelle pour les infractions non classées ailleurs.",
|
"Nombre moyen annuel d'infractions « other crime » dans un rayon de 50 m du code postal, d’après les données de criminalité street-level de police.uk. Catégorie résiduelle pour les infractions non classées ailleurs.",
|
||||||
'Median age':
|
'Median age':
|
||||||
"Provient du Census 2021 (TS007A). Âge médian des résidents habituels dans le LSOA, calculé par interpolation linéaire à partir des effectifs par tranche d'âge de cinq ans. Les zones à population plus jeune ont tendance à être urbaines, universitaires ou à accueillir davantage de familles ; les médianes plus élevées sont typiques des zones rurales et côtières.",
|
"Provient du Census 2021 (TS007A). Âge médian des résidents habituels dans le LSOA, calculé par interpolation linéaire à partir des effectifs par tranche d'âge de cinq ans. Les zones à population plus jeune ont tendance à être urbaines, universitaires ou à accueillir davantage de familles ; les médianes plus élevées sont typiques des zones rurales et côtières.",
|
||||||
|
'% No qualifications':
|
||||||
|
"D'après le recensement de 2021 (TS067). Pourcentage des résidents habituels âgés de 16 ans et plus dans le quartier qui ne possèdent aucun diplôme officiel.",
|
||||||
|
'% Some GCSEs':
|
||||||
|
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé correspond à environ 1 à 4 GCSE aux notes 9–4 (A*–C), ou à des qualifications de niveau d'entrée ou fondamental.",
|
||||||
|
'% Good GCSEs':
|
||||||
|
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé correspond à environ 5 GCSE ou plus aux notes 9–4 (A*–C), à un apprentissage intermédiaire ou équivalent.",
|
||||||
|
'% Apprenticeship':
|
||||||
|
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est un apprentissage.",
|
||||||
|
'% A-levels':
|
||||||
|
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est le A-levels, AS-levels, T-levels, un apprentissage avancé ou équivalent — généralement étudié après 16 ans et avant un diplôme universitaire.",
|
||||||
|
'% Degree or higher':
|
||||||
|
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est de niveau universitaire ou supérieur — licence, master ou doctorat, foundation degree, HNC/HND, NVQ 4–5 ou qualification professionnelle supérieure. Le recensement ne distingue pas les diplômes de premier cycle de ceux de troisième cycle.",
|
||||||
|
'% Other qualifications':
|
||||||
|
"D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est classé « autre » — qualifications professionnelles ou techniques non rattachées à un niveau britannique, et diplômes obtenus hors du Royaume-Uni.",
|
||||||
|
'% Owner occupied':
|
||||||
|
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) qui possèdent leur logement sans crédit, le possèdent avec un emprunt ou un crédit, ou le détiennent en propriété partagée.",
|
||||||
|
'% Social rent':
|
||||||
|
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'une collectivité ou d'une autorité locale, ou d'un bailleur social ou autre bailleur à vocation sociale.",
|
||||||
|
'% Private rent':
|
||||||
|
"D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'un propriétaire privé ou d'une agence de location, plus la faible part logée à titre gratuit.",
|
||||||
'% White':
|
'% White':
|
||||||
"Provient du Census 2021. Pourcentage de la population du quartier (LSOA) s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).",
|
"Provient du Census 2021. Pourcentage de la population du quartier (LSOA) s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).",
|
||||||
'% South Asian':
|
'% South Asian':
|
||||||
|
|
@ -235,6 +255,25 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Durchschnittliche Anzahl sonstiger Straftaten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene. Eine Sammelkategorie für Straftaten, die nicht anderweitig eingestuft sind.',
|
'Durchschnittliche Anzahl sonstiger Straftaten pro Jahr im LSOA, aus police.uk-Kriminalitätsdaten auf Straßenebene. Eine Sammelkategorie für Straftaten, die nicht anderweitig eingestuft sind.',
|
||||||
'Median age':
|
'Median age':
|
||||||
'Aus dem Census 2021 (TS007A). Medianalter der ortsansässigen Bevölkerung im LSOA, berechnet durch lineare Interpolation aus Fünfjahres-Altersband-Zählungen. Gebiete mit jüngerer Bevölkerung sind tendenziell städtisch, Universitätsstädte oder haben mehr Familien; höhere Mediane sind typisch für ländliche und Küstengebiete.',
|
'Aus dem Census 2021 (TS007A). Medianalter der ortsansässigen Bevölkerung im LSOA, berechnet durch lineare Interpolation aus Fünfjahres-Altersband-Zählungen. Gebiete mit jüngerer Bevölkerung sind tendenziell städtisch, Universitätsstädte oder haben mehr Familien; höhere Mediane sind typisch für ländliche und Küstengebiete.',
|
||||||
|
'% No qualifications':
|
||||||
|
'Aus dem Census 2021 (TS067). Prozentsatz der gewöhnlichen Einwohner ab 16 Jahren im Viertel, die keinen formalen Bildungsabschluss haben.',
|
||||||
|
'% Some GCSEs':
|
||||||
|
'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 1 bis 4 GCSEs mit den Noten 9–4 (A*–C) oder Abschlüssen auf Einstiegs- bzw. Grundniveau.',
|
||||||
|
'% Good GCSEs':
|
||||||
|
'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 5 oder mehr GCSEs mit den Noten 9–4 (A*–C), einer mittleren Ausbildung oder Gleichwertigem.',
|
||||||
|
'% Apprenticeship': 'Aus dem Census 2021 (TS067). Der höchste Abschluss ist eine Ausbildung.',
|
||||||
|
'% A-levels':
|
||||||
|
'Aus dem Census 2021 (TS067). Der höchste Abschluss sind A-levels, AS-levels, T-levels, eine weiterführende Ausbildung oder Gleichwertiges — meist nach dem 16. Lebensjahr und vor einem Studienabschluss erworben.',
|
||||||
|
'% Degree or higher':
|
||||||
|
'Aus dem Census 2021 (TS067). Der höchste Abschluss liegt auf Hochschulniveau oder darüber — Bachelor, Master oder Doktortitel, Foundation Degree, HNC/HND, NVQ 4–5 oder höhere berufliche Qualifikation. Der Census unterscheidet nicht zwischen Bachelor- und Masterabschlüssen.',
|
||||||
|
'% Other qualifications':
|
||||||
|
'Aus dem Census 2021 (TS067). Der höchste Abschluss wird als „sonstiger“ eingestuft — berufliche oder fachliche Qualifikationen, die keinem britischen Niveau zugeordnet sind, sowie außerhalb des Vereinigten Königreichs erworbene Abschlüsse.',
|
||||||
|
'% Owner occupied':
|
||||||
|
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die ihre Wohnung schuldenfrei besitzen, sie mit Hypothek oder Darlehen besitzen oder sie über gemeinschaftliches Eigentum (Shared Ownership) halten.',
|
||||||
|
'% Social rent':
|
||||||
|
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einer Kommune oder Gemeindeverwaltung oder von einer Wohnungsbaugesellschaft oder einem anderen sozialen Vermieter mieten.',
|
||||||
|
'% Private rent':
|
||||||
|
'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einem privaten Vermieter oder einer Vermittlungsagentur mieten, zuzüglich des kleinen Anteils, der mietfrei wohnt.',
|
||||||
'% White':
|
'% White':
|
||||||
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Nachbarschaft (LSOA), die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).',
|
'Aus dem Census 2021. Prozentsatz der Bevölkerung der Nachbarschaft (LSOA), die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).',
|
||||||
'% South Asian':
|
'% South Asian':
|
||||||
|
|
@ -373,6 +412,25 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'LSOA内每年其他犯罪的平均数量,来自police.uk街道级犯罪数据。此类别涵盖未在其他分类中列出的犯罪行为。',
|
'LSOA内每年其他犯罪的平均数量,来自police.uk街道级犯罪数据。此类别涵盖未在其他分类中列出的犯罪行为。',
|
||||||
'Median age':
|
'Median age':
|
||||||
'来自2021年Census(TS007A)。通过对五岁年龄段人口数进行线性插值计算得出的LSOA常住居民年龄中位数。年轻人口集中的地区往往是城市、大学城或家庭聚居地;年龄中位数较高的地区多见于农村和沿海地区。',
|
'来自2021年Census(TS007A)。通过对五岁年龄段人口数进行线性插值计算得出的LSOA常住居民年龄中位数。年轻人口集中的地区往往是城市、大学城或家庭聚居地;年龄中位数较高的地区多见于农村和沿海地区。',
|
||||||
|
'% No qualifications':
|
||||||
|
'数据来自 2021 年 Census(TS067)。指该社区 16 岁及以上常住居民中没有任何正式学历者所占的百分比。',
|
||||||
|
'% Some GCSEs':
|
||||||
|
'数据来自 2021 年 Census(TS067)。最高学历约为 1 至 4 门成绩为 9–4(A*–C)的 GCSE,或入门级/基础级别的资格。',
|
||||||
|
'% Good GCSEs':
|
||||||
|
'数据来自 2021 年 Census(TS067)。最高学历约为 5 门或以上成绩为 9–4(A*–C)的 GCSE、中级学徒制或同等资格。',
|
||||||
|
'% Apprenticeship': '数据来自 2021 年 Census(TS067)。最高学历为学徒制。',
|
||||||
|
'% A-levels':
|
||||||
|
'数据来自 2021 年 Census(TS067)。最高学历为 A-levels、AS-levels、T-levels、高级学徒制或同等资格——通常在 16 岁之后、获得学位之前修读。',
|
||||||
|
'% Degree or higher':
|
||||||
|
'数据来自 2021 年 Census(TS067)。最高学历为学位及以上——学士、硕士或博士、预科学位、HNC/HND、NVQ 4–5,或更高级别的职业资格。该 Census 不区分本科与研究生学位。',
|
||||||
|
'% Other qualifications':
|
||||||
|
'数据来自 2021 年 Census(TS067)。最高学历被归为“其他”——未对应到英国级别的职业或专业资格,以及在英国境外取得的学历。',
|
||||||
|
'% Owner occupied':
|
||||||
|
'数据来自 2021 年 Census(TS054)。本地社区(LSOA)中全款拥有自住房、以按揭或贷款拥有自住房,或通过共享产权持有住房的家庭百分比。',
|
||||||
|
'% Social rent':
|
||||||
|
'数据来自 2021 年 Census(TS054)。本地社区(LSOA)中向地方议会或地方政府,或向住房协会及其他社会房东租房的家庭百分比。',
|
||||||
|
'% Private rent':
|
||||||
|
'数据来自 2021 年 Census(TS054)。本地社区(LSOA)中向私人房东或租赁中介租房的家庭百分比,外加少量免租居住的家庭。',
|
||||||
'% White':
|
'% White':
|
||||||
'来自2021年Census。本地社区(LSOA)人口中认同为白人(英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景)的百分比。',
|
'来自2021年Census。本地社区(LSOA)人口中认同为白人(英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景)的百分比。',
|
||||||
'% South Asian':
|
'% South Asian':
|
||||||
|
|
@ -508,6 +566,25 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'LSOA में प्रति वर्ष अन्य आपराधिक अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा से. यह उन अपराधों के लिए सामान्य श्रेणी है जिन्हें कहीं और वर्गीकृत नहीं किया गया है.',
|
'LSOA में प्रति वर्ष अन्य आपराधिक अपराधों की औसत संख्या, police.uk के सड़क-स्तर अपराध डेटा से. यह उन अपराधों के लिए सामान्य श्रेणी है जिन्हें कहीं और वर्गीकृत नहीं किया गया है.',
|
||||||
'Median age':
|
'Median age':
|
||||||
'Census 2021 (TS007A) से. LSOA में सामान्य निवासियों की मध्य आयु, पांच-वर्षीय आयु समूहों की गणना से रैखिक इंटरपोलेशन द्वारा निकाली गई. युवा आबादी वाले क्षेत्र आमतौर पर शहरी, विश्वविद्यालय नगर या अधिक परिवारों वाले होते हैं; अधिक मध्य आयु वाले क्षेत्र आमतौर पर ग्रामीण और तटीय इलाकों में मिलते हैं.',
|
'Census 2021 (TS007A) से. LSOA में सामान्य निवासियों की मध्य आयु, पांच-वर्षीय आयु समूहों की गणना से रैखिक इंटरपोलेशन द्वारा निकाली गई. युवा आबादी वाले क्षेत्र आमतौर पर शहरी, विश्वविद्यालय नगर या अधिक परिवारों वाले होते हैं; अधिक मध्य आयु वाले क्षेत्र आमतौर पर ग्रामीण और तटीय इलाकों में मिलते हैं.',
|
||||||
|
'% No qualifications':
|
||||||
|
'2021 की Census (TS067) से। पड़ोस में 16 वर्ष और उससे अधिक उम्र के उन सामान्य निवासियों का प्रतिशत जिनके पास कोई औपचारिक योग्यता नहीं है।',
|
||||||
|
'% Some GCSEs':
|
||||||
|
'2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 1 से 4 GCSE है जिनके ग्रेड 9–4 (A*–C) हैं, या एंट्री-लेवल/फाउंडेशन स्तर की योग्यताएं हैं।',
|
||||||
|
'% Good GCSEs':
|
||||||
|
'2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 5 या अधिक GCSE है जिनके ग्रेड 9–4 (A*–C) हैं, एक इंटरमीडिएट अप्रेंटिसशिप, या समकक्ष।',
|
||||||
|
'% Apprenticeship': '2021 की Census (TS067) से। सर्वोच्च योग्यता एक अप्रेंटिसशिप है।',
|
||||||
|
'% A-levels':
|
||||||
|
'2021 की Census (TS067) से। सर्वोच्च योग्यता A-levels, AS-levels, T-levels, एक एडवांस्ड अप्रेंटिसशिप, या समकक्ष है — आमतौर पर 16 वर्ष की उम्र के बाद और डिग्री से पहले पढ़ी जाती है।',
|
||||||
|
'% Degree or higher':
|
||||||
|
'2021 की Census (TS067) से। सर्वोच्च योग्यता डिग्री स्तर या उससे ऊपर है — बैचलर, मास्टर या PhD, फाउंडेशन डिग्री, HNC/HND, NVQ 4–5, या उच्च व्यावसायिक योग्यता। Census स्नातक और स्नातकोत्तर डिग्री में अंतर नहीं करती।',
|
||||||
|
'% Other qualifications':
|
||||||
|
"2021 की Census (TS067) से। सर्वोच्च योग्यता 'अन्य' श्रेणी में आती है — ऐसी व्यावसायिक या पेशेवर योग्यताएं जो किसी UK स्तर से मेल नहीं खातीं, और UK के बाहर प्राप्त योग्यताएं।",
|
||||||
|
'% Owner occupied':
|
||||||
|
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो अपना घर पूर्ण रूप से अपने पास रखते हैं, बंधक या ऋण के साथ रखते हैं, या साझा स्वामित्व के माध्यम से रखते हैं।',
|
||||||
|
'% Social rent':
|
||||||
|
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो स्थानीय काउंसिल या स्थानीय प्राधिकरण से, या किसी हाउसिंग एसोसिएशन या अन्य सामाजिक मकान-मालिक से किराए पर रहते हैं।',
|
||||||
|
'% Private rent':
|
||||||
|
'2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो किसी निजी मकान-मालिक या किराया एजेंसी से किराए पर रहते हैं, साथ ही बिना किराए के रहने वाला छोटा हिस्सा।',
|
||||||
'% White':
|
'% White':
|
||||||
'Census 2021 से. स्थानीय पड़ोस (LSOA) की आबादी का प्रतिशत जो खुद को श्वेत (अंग्रेज़, वेल्श, स्कॉटिश, उत्तरी आयरिश, ब्रिटिश, आयरिश, जिप्सी या आयरिश ट्रैवलर, रोमा या किसी अन्य श्वेत पृष्ठभूमि) के रूप में पहचानता है.',
|
'Census 2021 से. स्थानीय पड़ोस (LSOA) की आबादी का प्रतिशत जो खुद को श्वेत (अंग्रेज़, वेल्श, स्कॉटिश, उत्तरी आयरिश, ब्रिटिश, आयरिश, जिप्सी या आयरिश ट्रैवलर, रोमा या किसी अन्य श्वेत पृष्ठभूमि) के रूप में पहचानता है.',
|
||||||
'% South Asian':
|
'% South Asian':
|
||||||
|
|
@ -648,6 +725,25 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Az egyéb bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból. Gyűjtőkategória azoknak a bűncselekményeknek, amelyek máshol nem kerülnek besorolásra.',
|
'Az egyéb bűncselekmények átlagos éves száma az LSOA-ban, a police.uk utcai szintű bűnügyi adataiból. Gyűjtőkategória azoknak a bűncselekményeknek, amelyek máshol nem kerülnek besorolásra.',
|
||||||
'Median age':
|
'Median age':
|
||||||
'A 2021-es Census alapján (TS007A). Az LSOA szokásos lakóinak medián életkora, ötéves korcsoport-számlálásokból lineáris interpolációval számítva. A fiatalabb népességű területek jellemzően városiak, egyetemi városok vagy több családot vonzanak; az idősebb medián értékek jellemzően vidéki és tengerparti területekre jellemzők.',
|
'A 2021-es Census alapján (TS007A). Az LSOA szokásos lakóinak medián életkora, ötéves korcsoport-számlálásokból lineáris interpolációval számítva. A fiatalabb népességű területek jellemzően városiak, egyetemi városok vagy több családot vonzanak; az idősebb medián értékek jellemzően vidéki és tengerparti területekre jellemzők.',
|
||||||
|
'% No qualifications':
|
||||||
|
'A 2021-es Census (TS067) alapján. A városrész 16 éves és idősebb állandó lakosainak aránya, akik nem rendelkeznek semmilyen formális végzettséggel.',
|
||||||
|
'% Some GCSEs':
|
||||||
|
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 1–4 GCSE 9–4 (A*–C) osztályzattal, vagy belépő szintű/alapszintű képesítés.',
|
||||||
|
'% Good GCSEs':
|
||||||
|
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 5 vagy több GCSE 9–4 (A*–C) osztályzattal, középszintű tanoncképzés vagy ezzel egyenértékű.',
|
||||||
|
'% Apprenticeship': 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség tanoncképzés.',
|
||||||
|
'% A-levels':
|
||||||
|
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség A-levels, AS-levels, T-levels, emelt szintű tanoncképzés vagy ezzel egyenértékű — jellemzően 16 éves kor után és a diploma előtt szerezhető meg.',
|
||||||
|
'% Degree or higher':
|
||||||
|
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség diploma szintű vagy afölötti — alapdiploma, mesterdiploma vagy PhD, foundation degree, HNC/HND, NVQ 4–5, vagy magasabb szakmai képesítés. A Census nem különbözteti meg az alap- és mesterszintű diplomákat.',
|
||||||
|
'% Other qualifications':
|
||||||
|
'A 2021-es Census (TS067) alapján. A legmagasabb végzettség „egyéb“ besorolású — olyan szakmai vagy hivatásbeli képesítések, amelyek nem feleltethetők meg egy brit szintnek, valamint az Egyesült Királyságon kívül szerzett képesítések.',
|
||||||
|
'% Owner occupied':
|
||||||
|
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek tehermentesen birtokolják lakásukat, jelzáloggal vagy kölcsönnel birtokolják, vagy résztulajdon (shared ownership) keretében tartják.',
|
||||||
|
'% Social rent':
|
||||||
|
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek helyi önkormányzattól vagy hatóságtól, illetve lakásszövetkezettől vagy más szociális bérbeadótól bérelnek.',
|
||||||
|
'% Private rent':
|
||||||
|
'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek magán bérbeadótól vagy ingatlanügynökségtől bérelnek, kiegészülve a lakbér nélkül lakók kis arányával.',
|
||||||
'% White':
|
'% White':
|
||||||
'A 2021-es Census alapján. A helyi környéken (LSOA) fehérként (angol, walesi, skót, észak-ír, brit, ír, cigány vagy ír vándor, roma, vagy bármely más fehér háttér) azonosított népesség százaléka.',
|
'A 2021-es Census alapján. A helyi környéken (LSOA) fehérként (angol, walesi, skót, észak-ír, brit, ír, cigány vagy ír vándor, roma, vagy bármely más fehér háttér) azonosított népesség százaléka.',
|
||||||
'% South Asian':
|
'% South Asian':
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import type { Translations } from './en';
|
import type { Translations } from './en';
|
||||||
|
|
||||||
const de: Translations = {
|
const de: Translations = {
|
||||||
|
newDevelopments: {
|
||||||
|
title: 'Bauvorhaben',
|
||||||
|
homesUpTo: 'Bis zu {{count}} Wohnungen',
|
||||||
|
homesRange: '{{min}}–{{max}} Wohnungen',
|
||||||
|
homesExact: '{{count}} Wohnungen',
|
||||||
|
planningStatus: 'Planungsstatus',
|
||||||
|
sourceBrownfield: 'Brachflächenregister',
|
||||||
|
sourceHomesEngland: 'Homes-England-Fläche',
|
||||||
|
localAuthority: 'Kommunalbehörde',
|
||||||
|
viewRecord: 'Planungsunterlagen ansehen ↗',
|
||||||
|
},
|
||||||
// ── Common ──────────────────────────────────────────
|
// ── Common ──────────────────────────────────────────
|
||||||
common: {
|
common: {
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
|
|
@ -787,10 +798,10 @@ const de: Translations = {
|
||||||
describeIdealArea: 'Beschreibe, wo du wohnen möchtest',
|
describeIdealArea: 'Beschreibe, wo du wohnen möchtest',
|
||||||
aiSearch: 'KI-Suche',
|
aiSearch: 'KI-Suche',
|
||||||
describeHint: 'beschreibe, wonach du suchst',
|
describeHint: 'beschreibe, wonach du suchst',
|
||||||
placeholder: 'z. B. 2-bed unter £525k, 45 Min. zur Arbeit, ruhig...',
|
placeholder: 'z. B. gleiche Schulen, günstigere Postleitzahl, unter £500k…',
|
||||||
example1: '2-bed unter £525k, 45 Min. zur Arbeit',
|
example1: 'Gleiche Schulen, günstigere Postleitzahl',
|
||||||
example2: 'Familienfreundliche Gebiete nahe guter Schulen unter £650k',
|
example2: 'Bestes Preis-Leistungs-Verhältnis in 30 Minuten zur Arbeit',
|
||||||
example3: 'Mehr Platz mit vernünftigem Pendelweg',
|
example3: 'Unterbewertete Gegenden nahe guter Grundschulen',
|
||||||
analysing: 'Anfrage wird ausgewertet...',
|
analysing: 'Anfrage wird ausgewertet...',
|
||||||
searchingDestinations: 'Ziele werden gesucht...',
|
searchingDestinations: 'Ziele werden gesucht...',
|
||||||
generatingFilters: 'Filter werden generiert...',
|
generatingFilters: 'Filter werden generiert...',
|
||||||
|
|
@ -888,7 +899,13 @@ const de: Translations = {
|
||||||
walk: 'Zu Fuß',
|
walk: 'Zu Fuß',
|
||||||
cycle: 'Fahrrad',
|
cycle: 'Fahrrad',
|
||||||
nationalAvg: 'England-Schnitt',
|
nationalAvg: 'England-Schnitt',
|
||||||
|
outcodeAvg: 'Outcode-Schnitt',
|
||||||
|
sectorAvg: 'Sektor-Schnitt',
|
||||||
|
thisArea: 'Dieses Gebiet',
|
||||||
|
national: 'England',
|
||||||
crimeDataEnds: 'Polizeidaten für dieses Gebiet enden {{year}}',
|
crimeDataEnds: 'Polizeidaten für dieses Gebiet enden {{year}}',
|
||||||
|
residents: 'Einwohner',
|
||||||
|
residentsTooltip: 'Wohnbevölkerung (ONS-Zensus 2021)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -1132,30 +1149,30 @@ const de: Translations = {
|
||||||
videosTitle: 'Social-Media-Videos',
|
videosTitle: 'Social-Media-Videos',
|
||||||
videosIntro:
|
videosIntro:
|
||||||
'Kurze Clips aus unseren Social-Media-Kanälen – jeder zeigt eine einzelne Suche in Aktion, von ruhigen Straßen über Schuleinzugsgebiete bis zu Pendelzeiten.',
|
'Kurze Clips aus unseren Social-Media-Kanälen – jeder zeigt eine einzelne Suche in Aktion, von ruhigen Straßen über Schuleinzugsgebiete bis zu Pendelzeiten.',
|
||||||
video01Title: 'Ein Satz, jede Postleitzahl',
|
video01Title: 'Finde den günstigeren Zwilling',
|
||||||
video01Desc:
|
video01Desc:
|
||||||
'Gib deinen kompletten Wohnungswunsch in normaler Sprache ein und sieh zu, wie jede passende Postleitzahl in England aufleuchtet.',
|
'Gib deinen kompletten Wohnungswunsch in einem schlichten Satz ein und sieh zu, wie sich jede passende Postleitzahl in England nach Preis-Leistung sortiert – und der günstigere Zwilling auftaucht, den niemand hochgeboten hat.',
|
||||||
video02Title: 'Die 20-Minuten-Karte',
|
video02Title: 'Die 20-Minuten-Pendelkarte',
|
||||||
video02Desc:
|
video02Desc:
|
||||||
'Färbe die Karte nach Pendelzeit und sieh genau, was dir 20 Minuten ins Zentrum von London wirklich übrig lassen.',
|
'Färbe London nach Pendelzeit ins Zentrum, beschränke auf eine 20-minütige Fahrt und sieh, wie sich identische Wege in die Namen, die jeder kennt, und ruhigere Postleitzahlen aufteilen, die niemand hochgeboten hat.',
|
||||||
video03Title: 'Jede Postleitzahl hat eine Akte',
|
video03Title: 'Die Beweisakte zur Postleitzahl',
|
||||||
video03Desc:
|
video03Desc:
|
||||||
'Tippe auf eine Postleitzahl und lies ihre Akte – verkaufte Preise, Schulen, Kriminalität und Street View an einem Ort.',
|
'Tippe auf eine Postleitzahl und ein Panel öffnet sich mit ihren verkauften Preisen, Schul-Einzugsgebieten, Kriminalität und Street View – so erkennst du, ob du für Substanz zahlst oder nur für einen Ruf.',
|
||||||
video04Title: 'Ein Foto kann man nicht hören',
|
video04Title: 'Die übersehene ruhige Straße',
|
||||||
video04Desc:
|
video04Desc:
|
||||||
'Anzeigenfotos sind stumm. Filtere nach Lärmpegel und finde die wirklich ruhigen Straßen unter 55 Dezibel.',
|
'Berühmte Postleitzahlen preisen ihren Ruf ein, doch ein Anzeigenfoto schweigt zum Lärm. Filtere nach Dezibel und finde die wirklich ruhige Straße eine weiter, die niemand hochgeboten hat.',
|
||||||
video05Title: 'Die Schulweg-Karte',
|
video05Title: 'Das Einzugsgebiet ohne den Aufschlag',
|
||||||
video05Desc:
|
video05Desc:
|
||||||
'Gute Grundschul-Einzugsgebiete, wenig Kriminalität und ein Budget – der Familienwunsch, über eine ganze Stadt kartiert.',
|
'Eine Familie in Leeds sucht eine gute Grundschule, wenig Kriminalität und ein Budget unter £350k – und findet die still günstigeren Straßen, die genau dasselbe Einzugsgebiet teilen.',
|
||||||
video06Title: 'Der Waitrose-Test',
|
video06Title: 'Der Waitrose-Effekt, eingepreist',
|
||||||
video06Desc:
|
video06Desc:
|
||||||
'Zu Fuß zu einem Supermarkt, einer U-Bahn-Station und einem Park – filtere nach dem Leben, nicht nur nach dem Grundriss.',
|
'Zu Fuß zu einem Waitrose, einer U-Bahn-Station und einem Park ist ein eingepreister Aufschlag – finde die nahen Postleitzahlen mit denselben Annehmlichkeiten für weniger pro Quadratmeter.',
|
||||||
video07Title: 'Auch Mieter bekommen eine Karte',
|
video07Title: 'Eine Preis-Leistungs-Karte für Mieter',
|
||||||
video07Desc:
|
video07Desc:
|
||||||
'Miete im Budget, kurzer Arbeitsweg und eine ruhige Straße – Mietportale zeigen Wohnungen, das hier zeigt dir Gegenden.',
|
'Auch namhafte Postleitzahlen kosten mehr Miete. Lege deine Miete, deinen Arbeitsweg und eine ruhige Straße fest und sieh, welche Londoner Postleitzahlen wirklich ins Budget passen.',
|
||||||
video08Title: '9,99 £ gegen einen verlorenen Samstag',
|
video08Title: 'Hör auf, für einen Namen zu viel zu zahlen',
|
||||||
video08Desc:
|
video08Desc:
|
||||||
'Eine schlechte Besichtigung kostet eine Zugfahrt und ein halbes Wochenende. Sieh vorher, wo du nicht hinmusst.',
|
'Lege Budget, Arbeitsweg, Kriminalität und Schulen fest, und die London-Karte füllt sich mit unterbewerteten Postleitzahlen, die eine Straße neben den berühmten Namen liegen.',
|
||||||
source: 'Quelle:',
|
source: 'Quelle:',
|
||||||
optOut: 'Widerspruch gegen öffentliche Offenlegung',
|
optOut: 'Widerspruch gegen öffentliche Offenlegung',
|
||||||
attribution: 'Quellenangaben',
|
attribution: 'Quellenangaben',
|
||||||
|
|
@ -1581,6 +1598,16 @@ const de: Translations = {
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': 'Medianalter',
|
'Median age': 'Medianalter',
|
||||||
|
'% No qualifications': '% Ohne Abschluss',
|
||||||
|
'% Some GCSEs': '% Einige GCSEs',
|
||||||
|
'% Good GCSEs': '% Gute GCSEs',
|
||||||
|
'% Apprenticeship': '% Ausbildung',
|
||||||
|
'% A-levels': '% A-levels',
|
||||||
|
'% Degree or higher': '% Hochschulabschluss oder höher',
|
||||||
|
'% Other qualifications': '% Sonstige Abschlüsse',
|
||||||
|
'% Owner occupied': '% Wohneigentum',
|
||||||
|
'% Social rent': '% Sozialwohnung',
|
||||||
|
'% Private rent': '% Privatmiete',
|
||||||
'% White': '% weiß',
|
'% White': '% weiß',
|
||||||
'% South Asian': '% südasiatisch',
|
'% South Asian': '% südasiatisch',
|
||||||
'% Black': '% schwarz',
|
'% Black': '% schwarz',
|
||||||
|
|
@ -1627,6 +1654,8 @@ const de: Translations = {
|
||||||
'Minor crime': 'Leichte Straftaten',
|
'Minor crime': 'Leichte Straftaten',
|
||||||
'Ethnic composition': 'Ethnische Zusammensetzung',
|
'Ethnic composition': 'Ethnische Zusammensetzung',
|
||||||
'Political vote share': 'Politischer Stimmenanteil',
|
'Political vote share': 'Politischer Stimmenanteil',
|
||||||
|
Qualifications: 'Bildungsabschlüsse',
|
||||||
|
Tenure: 'Eigentumsverhältnis',
|
||||||
'Anti-social': 'Antisoziales Verhalten',
|
'Anti-social': 'Antisoziales Verhalten',
|
||||||
Vehicle: 'Fahrzeug',
|
Vehicle: 'Fahrzeug',
|
||||||
Burglary: 'Einbruch',
|
Burglary: 'Einbruch',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,15 @@
|
||||||
const en = {
|
const en = {
|
||||||
|
newDevelopments: {
|
||||||
|
title: 'Development site',
|
||||||
|
homesUpTo: 'Up to {{count}} homes',
|
||||||
|
homesRange: '{{min}}–{{max}} homes',
|
||||||
|
homesExact: '{{count}} homes',
|
||||||
|
planningStatus: 'Planning status',
|
||||||
|
sourceBrownfield: 'Brownfield land register',
|
||||||
|
sourceHomesEngland: 'Homes England land',
|
||||||
|
localAuthority: 'Local authority',
|
||||||
|
viewRecord: 'View planning record ↗',
|
||||||
|
},
|
||||||
// ── Common ──────────────────────────────────────────
|
// ── Common ──────────────────────────────────────────
|
||||||
common: {
|
common: {
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
|
|
@ -773,10 +784,10 @@ const en = {
|
||||||
describeIdealArea: 'Describe where you want to live',
|
describeIdealArea: 'Describe where you want to live',
|
||||||
aiSearch: 'AI Search',
|
aiSearch: 'AI Search',
|
||||||
describeHint: 'describe what you’re looking for',
|
describeHint: 'describe what you’re looking for',
|
||||||
placeholder: 'e.g. 2-bed under £525k, 45 mins to work, quiet...',
|
placeholder: 'e.g. same schools, cheaper postcode, under £500k…',
|
||||||
example1: '2-bed under £525k, 45 mins to work',
|
example1: 'Same schools, cheaper postcode',
|
||||||
example2: 'Family areas near good schools under £650k',
|
example2: 'Best value within 30 minutes of work',
|
||||||
example3: 'More space with a sane commute',
|
example3: 'Underpriced areas near good primaries',
|
||||||
analysing: 'Analysing your query...',
|
analysing: 'Analysing your query...',
|
||||||
searchingDestinations: 'Searching for destinations...',
|
searchingDestinations: 'Searching for destinations...',
|
||||||
generatingFilters: 'Generating filters...',
|
generatingFilters: 'Generating filters...',
|
||||||
|
|
@ -872,7 +883,13 @@ const en = {
|
||||||
walk: 'Walk',
|
walk: 'Walk',
|
||||||
cycle: 'Cycle',
|
cycle: 'Cycle',
|
||||||
nationalAvg: 'National avg',
|
nationalAvg: 'National avg',
|
||||||
|
outcodeAvg: 'Outcode avg',
|
||||||
|
sectorAvg: 'Sector avg',
|
||||||
|
thisArea: 'This area',
|
||||||
|
national: 'National',
|
||||||
crimeDataEnds: 'Police data for this area ends {{year}}',
|
crimeDataEnds: 'Police data for this area ends {{year}}',
|
||||||
|
residents: 'Residents',
|
||||||
|
residentsTooltip: 'Usual residents (ONS Census 2021)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -1114,30 +1131,30 @@ const en = {
|
||||||
videosTitle: 'Social media videos',
|
videosTitle: 'Social media videos',
|
||||||
videosIntro:
|
videosIntro:
|
||||||
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
|
'Short clips from our social channels — each one shows a single search in action, from quiet streets to school catchments and commute times.',
|
||||||
video01Title: 'One sentence, every postcode',
|
video01Title: 'Find the cheaper twin',
|
||||||
video01Desc:
|
video01Desc:
|
||||||
'Type your whole house brief in plain English and watch every matching postcode in England light up.',
|
'Type your whole house brief in one plain sentence and watch every matching postcode in England sort by value, surfacing the cheaper twin nobody bid up.',
|
||||||
video02Title: 'The 20-minute map',
|
video02Title: 'The 20-minute commute map',
|
||||||
video02Desc:
|
video02Desc:
|
||||||
'Colour the map by commute time and see exactly what a 20-minute journey to central London actually leaves you.',
|
'Colour London by commute to the centre, prune to a 20-minute ride, and watch identical journeys split into the names everyone knows and quieter postcodes nobody bid up.',
|
||||||
video03Title: 'Every postcode has a file',
|
video03Title: 'The postcode evidence file',
|
||||||
video03Desc:
|
video03Desc:
|
||||||
'Tap any postcode to read its file — sold prices, schools, crime and Street View, all in one place.',
|
'Tap any postcode and a panel opens with its sold prices, school catchments, crime and Street View — so you can tell whether you are paying for value or just a reputation.',
|
||||||
video04Title: 'You can’t hear a photo',
|
video04Title: 'The overlooked quiet street',
|
||||||
video04Desc:
|
video04Desc:
|
||||||
'Listing photos are silent. Filter by noise level to find the genuinely quiet streets under 55 decibels.',
|
'Famous postcodes price in their reputation, but a listing photo stays silent on noise. Filter by decibels to find the genuinely quiet street one over that nobody bid up.',
|
||||||
video05Title: 'The school-run map',
|
video05Title: 'The catchment without the premium',
|
||||||
video05Desc:
|
video05Desc:
|
||||||
'Good primary catchments, low crime and a budget — the family brief, mapped across a whole city.',
|
'A Leeds family search for a good primary, low crime and a budget under £350k surfaces the quietly cheaper streets that share the very same catchment.',
|
||||||
video06Title: 'The Waitrose test',
|
video06Title: 'The Waitrose effect, priced in',
|
||||||
video06Desc:
|
video06Desc:
|
||||||
'Walking distance to a Waitrose, a tube station and a park — filter for the life, not just the floor plan.',
|
'Walking distance to a Waitrose, a tube station and a park is a priced-in premium — find the nearby postcodes with the same amenities for less per square metre.',
|
||||||
video07Title: 'Renters get a map too',
|
video07Title: 'A value map for renters',
|
||||||
video07Desc:
|
video07Desc:
|
||||||
'Rent under budget, a short commute and a quiet street — letting sites show flats, this shows you areas.',
|
'Big-name postcodes cost more to rent, too. Set your rent, commute and a quiet street, and see which London postcodes actually fit the money.',
|
||||||
video08Title: '£9.99 vs a wasted Saturday',
|
video08Title: 'Stop overpaying for a name',
|
||||||
video08Desc:
|
video08Desc:
|
||||||
'A bad viewing costs a train ticket and half a weekend. See where not to go before you book one.',
|
'Set a budget, commute, crime and schools, and the London map fills with underpriced postcodes sitting one street over from the famous names.',
|
||||||
source: 'Source:',
|
source: 'Source:',
|
||||||
optOut: 'Opt out of public disclosure',
|
optOut: 'Opt out of public disclosure',
|
||||||
attribution: 'Attribution',
|
attribution: 'Attribution',
|
||||||
|
|
@ -1452,7 +1469,7 @@ const en = {
|
||||||
travelDestination: '{{count}} travel time destination',
|
travelDestination: '{{count}} travel time destination',
|
||||||
travelDestinations: '{{count}} travel time destinations',
|
travelDestinations: '{{count}} travel time destinations',
|
||||||
propertiesMatch: '{{count}} properties match',
|
propertiesMatch: '{{count}} properties match',
|
||||||
setFilters: 'Set {{count}} filter(s): {{list}}',
|
setFilters: 'Set {{count}} filters: {{list}}',
|
||||||
noFiltersSet: 'No filters set',
|
noFiltersSet: 'No filters set',
|
||||||
toDestination: '{{mode}} to {{label}} {{bounds}}',
|
toDestination: '{{mode}} to {{label}} {{bounds}}',
|
||||||
lessThanMin: '< {{max}} min',
|
lessThanMin: '< {{max}} min',
|
||||||
|
|
@ -1555,6 +1572,16 @@ const en = {
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': 'Median age',
|
'Median age': 'Median age',
|
||||||
|
'% No qualifications': '% No qualifications',
|
||||||
|
'% Some GCSEs': '% Some GCSEs',
|
||||||
|
'% Good GCSEs': '% Good GCSEs',
|
||||||
|
'% Apprenticeship': '% Apprenticeship',
|
||||||
|
'% A-levels': '% A-levels',
|
||||||
|
'% Degree or higher': '% Degree or higher',
|
||||||
|
'% Other qualifications': '% Other qualifications',
|
||||||
|
'% Owner occupied': '% Owner occupied',
|
||||||
|
'% Social rent': '% Social rent',
|
||||||
|
'% Private rent': '% Private rent',
|
||||||
'% White': '% White',
|
'% White': '% White',
|
||||||
'% South Asian': '% South Asian',
|
'% South Asian': '% South Asian',
|
||||||
'% Black': '% Black',
|
'% Black': '% Black',
|
||||||
|
|
@ -1601,6 +1628,8 @@ const en = {
|
||||||
'Minor crime': 'Minor crime',
|
'Minor crime': 'Minor crime',
|
||||||
'Ethnic composition': 'Ethnic composition',
|
'Ethnic composition': 'Ethnic composition',
|
||||||
'Political vote share': 'Political vote share',
|
'Political vote share': 'Political vote share',
|
||||||
|
Qualifications: 'Qualifications',
|
||||||
|
Tenure: 'Tenure',
|
||||||
'Anti-social': 'Anti-social',
|
'Anti-social': 'Anti-social',
|
||||||
Vehicle: 'Vehicle',
|
Vehicle: 'Vehicle',
|
||||||
Burglary: 'Burglary',
|
Burglary: 'Burglary',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import { Translations } from './en';
|
import { Translations } from './en';
|
||||||
|
|
||||||
const fr: Translations = {
|
const fr: Translations = {
|
||||||
|
newDevelopments: {
|
||||||
|
title: 'Site de construction',
|
||||||
|
homesUpTo: "Jusqu'à {{count}} logements",
|
||||||
|
homesRange: '{{min}}–{{max}} logements',
|
||||||
|
homesExact: '{{count}} logements',
|
||||||
|
planningStatus: "Statut d'urbanisme",
|
||||||
|
sourceBrownfield: 'Registre des friches',
|
||||||
|
sourceHomesEngland: 'Terrain Homes England',
|
||||||
|
localAuthority: 'Autorité locale',
|
||||||
|
viewRecord: "Voir le dossier d'urbanisme ↗",
|
||||||
|
},
|
||||||
// ── Common ──────────────────────────────────────────
|
// ── Common ──────────────────────────────────────────
|
||||||
common: {
|
common: {
|
||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
|
|
@ -801,10 +812,10 @@ const fr: Translations = {
|
||||||
describeIdealArea: 'Décrivez où vous voulez vivre',
|
describeIdealArea: 'Décrivez où vous voulez vivre',
|
||||||
aiSearch: 'Recherche IA',
|
aiSearch: 'Recherche IA',
|
||||||
describeHint: 'décrivez ce que vous recherchez',
|
describeHint: 'décrivez ce que vous recherchez',
|
||||||
placeholder: 'ex. 2 chambres à moins de £525k, 45 min du travail, calme...',
|
placeholder: 'ex. mêmes écoles, code postal moins cher, sous £500k…',
|
||||||
example1: '2 chambres à moins de £525k, 45 min du travail',
|
example1: 'Mêmes écoles, code postal moins cher',
|
||||||
example2: 'Quartiers familiaux près de bonnes écoles à moins de £650k',
|
example2: 'Meilleur rapport qualité-prix à 30 minutes du travail',
|
||||||
example3: 'Plus d’espace avec un trajet raisonnable',
|
example3: 'Zones sous-cotées près de bonnes écoles primaires',
|
||||||
analysing: 'Analyse de votre requête...',
|
analysing: 'Analyse de votre requête...',
|
||||||
searchingDestinations: 'Recherche de destinations...',
|
searchingDestinations: 'Recherche de destinations...',
|
||||||
generatingFilters: 'Génération des filtres...',
|
generatingFilters: 'Génération des filtres...',
|
||||||
|
|
@ -901,7 +912,13 @@ const fr: Translations = {
|
||||||
walk: 'Marche',
|
walk: 'Marche',
|
||||||
cycle: 'Vélo',
|
cycle: 'Vélo',
|
||||||
nationalAvg: 'Moyenne nationale',
|
nationalAvg: 'Moyenne nationale',
|
||||||
|
outcodeAvg: 'Moyenne outcode',
|
||||||
|
sectorAvg: 'Moyenne secteur',
|
||||||
|
thisArea: 'Cette zone',
|
||||||
|
national: 'National',
|
||||||
crimeDataEnds: "Les données de police pour cette zone s'arrêtent en {{year}}",
|
crimeDataEnds: "Les données de police pour cette zone s'arrêtent en {{year}}",
|
||||||
|
residents: 'Habitants',
|
||||||
|
residentsTooltip: 'Résidents habituels (recensement ONS 2021)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -1146,30 +1163,30 @@ const fr: Translations = {
|
||||||
videosTitle: 'Vidéos pour les réseaux sociaux',
|
videosTitle: 'Vidéos pour les réseaux sociaux',
|
||||||
videosIntro:
|
videosIntro:
|
||||||
'De courtes vidéos de nos réseaux sociaux — chacune montre une recherche en action, des rues calmes aux secteurs scolaires en passant par les temps de trajet.',
|
'De courtes vidéos de nos réseaux sociaux — chacune montre une recherche en action, des rues calmes aux secteurs scolaires en passant par les temps de trajet.',
|
||||||
video01Title: 'Une phrase, chaque code postal',
|
video01Title: 'Trouvez le jumeau moins cher',
|
||||||
video01Desc:
|
video01Desc:
|
||||||
'Décrivez tout votre projet immobilier en langage courant et voyez s’allumer chaque code postal correspondant en Angleterre.',
|
'Décrivez tout votre projet immobilier en une phrase simple et voyez chaque code postal correspondant en Angleterre se classer par rapport qualité-prix, révélant le jumeau moins cher que personne n’a fait monter aux enchères.',
|
||||||
video02Title: 'La carte des 20 minutes',
|
video02Title: 'La carte des trajets de 20 minutes',
|
||||||
video02Desc:
|
video02Desc:
|
||||||
'Colorez la carte selon le temps de trajet et voyez exactement ce que 20 minutes du centre de Londres vous laissent vraiment.',
|
'Colorez Londres selon le temps de trajet vers le centre, limitez à 20 minutes, et voyez des trajets identiques se diviser entre les noms que tout le monde connaît et les codes postaux plus calmes que personne n’a fait monter.',
|
||||||
video03Title: 'Chaque code postal a sa fiche',
|
video03Title: 'Le dossier de preuves du code postal',
|
||||||
video03Desc:
|
video03Desc:
|
||||||
'Touchez un code postal pour lire sa fiche — prix de vente, écoles, criminalité et Street View, au même endroit.',
|
'Touchez un code postal et un panneau s’ouvre avec ses prix de vente, ses secteurs scolaires, la criminalité et Street View — pour savoir si vous payez pour de la valeur ou seulement pour une réputation.',
|
||||||
video04Title: 'Une photo, ça ne s’entend pas',
|
video04Title: 'La rue calme qu’on néglige',
|
||||||
video04Desc:
|
video04Desc:
|
||||||
'Les photos d’annonces sont muettes. Filtrez par niveau de bruit pour trouver les rues vraiment calmes, sous 55 décibels.',
|
'Les codes postaux célèbres intègrent leur réputation au prix, mais une photo d’annonce reste muette sur le bruit. Filtrez par décibels pour trouver la rue vraiment calme juste à côté que personne n’a fait monter.',
|
||||||
video05Title: 'La carte du trajet d’école',
|
video05Title: 'Le secteur scolaire sans le surcoût',
|
||||||
video05Desc:
|
video05Desc:
|
||||||
'Bons secteurs d’école primaire, faible criminalité et un budget — le projet des familles, cartographié sur toute une ville.',
|
'Une famille de Leeds cherche une bonne école primaire, peu de criminalité et un budget sous £350k, et fait apparaître les rues discrètement moins chères qui partagent exactement le même secteur.',
|
||||||
video06Title: 'Le test Waitrose',
|
video06Title: 'L’effet Waitrose, déjà dans le prix',
|
||||||
video06Desc:
|
video06Desc:
|
||||||
'À pied d’un supermarché, d’une station de métro et d’un parc — filtrez selon votre vie, pas seulement selon le plan.',
|
'Être à pied d’un Waitrose, d’une station de métro et d’un parc est un surcoût déjà intégré — trouvez les codes postaux voisins offrant les mêmes commodités pour moins cher au mètre carré.',
|
||||||
video07Title: 'Les locataires aussi ont une carte',
|
video07Title: 'Une carte de la valeur pour les locataires',
|
||||||
video07Desc:
|
video07Desc:
|
||||||
'Loyer dans le budget, trajet court et rue calme — les sites de location montrent des logements, ceci vous montre des quartiers.',
|
'Les codes postaux réputés coûtent aussi plus cher à louer. Indiquez votre loyer, votre trajet et une rue calme, et voyez quels codes postaux de Londres tiennent vraiment dans le budget.',
|
||||||
video08Title: '9,99 £ contre un samedi gâché',
|
video08Title: 'Arrêtez de surpayer pour un nom',
|
||||||
video08Desc:
|
video08Desc:
|
||||||
'Une mauvaise visite coûte un billet de train et la moitié d’un week-end. Voyez où ne pas aller avant d’en réserver une.',
|
'Définissez un budget, un trajet, la criminalité et les écoles, et la carte de Londres se remplit de codes postaux sous-cotés situés à une rue des noms célèbres.',
|
||||||
source: 'Source :',
|
source: 'Source :',
|
||||||
optOut: 'Refus de la publication publique',
|
optOut: 'Refus de la publication publique',
|
||||||
attribution: 'Attribution',
|
attribution: 'Attribution',
|
||||||
|
|
@ -1494,7 +1511,7 @@ const fr: Translations = {
|
||||||
travelDestination: '{{count}} destination de trajet',
|
travelDestination: '{{count}} destination de trajet',
|
||||||
travelDestinations: '{{count}} destinations de trajet',
|
travelDestinations: '{{count}} destinations de trajet',
|
||||||
propertiesMatch: '{{count}} biens correspondent',
|
propertiesMatch: '{{count}} biens correspondent',
|
||||||
setFilters: 'Définir {{count}} filtre(s) : {{list}}',
|
setFilters: 'Définir {{count}} filtres : {{list}}',
|
||||||
noFiltersSet: 'Aucun filtre défini',
|
noFiltersSet: 'Aucun filtre défini',
|
||||||
toDestination: '{{mode}} vers {{label}} {{bounds}}',
|
toDestination: '{{mode}} vers {{label}} {{bounds}}',
|
||||||
lessThanMin: '< {{max}} min',
|
lessThanMin: '< {{max}} min',
|
||||||
|
|
@ -1598,6 +1615,16 @@ const fr: Translations = {
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': 'Âge médian',
|
'Median age': 'Âge médian',
|
||||||
|
'% No qualifications': '% Sans diplôme',
|
||||||
|
'% Some GCSEs': '% Quelques GCSE',
|
||||||
|
'% Good GCSEs': '% Bons GCSE',
|
||||||
|
'% Apprenticeship': '% Apprentissage',
|
||||||
|
'% A-levels': '% A-levels',
|
||||||
|
'% Degree or higher': '% Diplôme universitaire ou plus',
|
||||||
|
'% Other qualifications': '% Autres diplômes',
|
||||||
|
'% Owner occupied': '% Propriétaires occupants',
|
||||||
|
'% Social rent': '% Logements sociaux',
|
||||||
|
'% Private rent': '% Location privée',
|
||||||
'% White': '% Blancs',
|
'% White': '% Blancs',
|
||||||
'% South Asian': '% Sud-Asiatiques',
|
'% South Asian': '% Sud-Asiatiques',
|
||||||
'% Black': '% Noirs',
|
'% Black': '% Noirs',
|
||||||
|
|
@ -1644,6 +1671,8 @@ const fr: Translations = {
|
||||||
'Minor crime': 'Infractions mineures',
|
'Minor crime': 'Infractions mineures',
|
||||||
'Ethnic composition': 'Composition ethnique',
|
'Ethnic composition': 'Composition ethnique',
|
||||||
'Political vote share': 'Répartition des voix',
|
'Political vote share': 'Répartition des voix',
|
||||||
|
Qualifications: 'Diplômes',
|
||||||
|
Tenure: 'Statut d’occupation',
|
||||||
'Anti-social': 'Antisocial',
|
'Anti-social': 'Antisocial',
|
||||||
Vehicle: 'Véhicule',
|
Vehicle: 'Véhicule',
|
||||||
Burglary: 'Cambriolage',
|
Burglary: 'Cambriolage',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import type { Translations } from './en';
|
import type { Translations } from './en';
|
||||||
|
|
||||||
const hi: Translations = {
|
const hi: Translations = {
|
||||||
|
newDevelopments: {
|
||||||
|
title: 'विकास स्थल',
|
||||||
|
homesUpTo: '{{count}} तक घर',
|
||||||
|
homesRange: '{{min}}–{{max}} घर',
|
||||||
|
homesExact: '{{count}} घर',
|
||||||
|
planningStatus: 'योजना स्थिति',
|
||||||
|
sourceBrownfield: 'ब्राउनफ़ील्ड रजिस्टर',
|
||||||
|
sourceHomesEngland: 'Homes England भूमि',
|
||||||
|
localAuthority: 'स्थानीय प्राधिकरण',
|
||||||
|
viewRecord: 'योजना रिकॉर्ड देखें ↗',
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
save: 'सहेजें',
|
save: 'सहेजें',
|
||||||
update: 'अपडेट करें',
|
update: 'अपडेट करें',
|
||||||
|
|
@ -764,10 +775,10 @@ const hi: Translations = {
|
||||||
describeIdealArea: 'बताएं आप कहां रहना चाहते हैं',
|
describeIdealArea: 'बताएं आप कहां रहना चाहते हैं',
|
||||||
aiSearch: 'AI खोज',
|
aiSearch: 'AI खोज',
|
||||||
describeHint: 'बताएं आप क्या खोज रहे हैं',
|
describeHint: 'बताएं आप क्या खोज रहे हैं',
|
||||||
placeholder: 'जैसे 2 बेडरूम £525,000 से कम, काम तक 45 मिनट, शांत...',
|
placeholder: 'जैसे वही स्कूल, सस्ता पोस्टकोड, £500,000 से कम…',
|
||||||
example1: '2 बेडरूम £525,000 से कम, काम तक 45 मिनट',
|
example1: 'वही स्कूल, सस्ता पोस्टकोड',
|
||||||
example2: '£650,000 से कम अच्छे स्कूलों के पास परिवारों वाले क्षेत्र',
|
example2: 'काम से 30 मिनट के भीतर सबसे अच्छा मूल्य',
|
||||||
example3: 'समझदारी वाले आवागमन के साथ ज्यादा जगह',
|
example3: 'अच्छे प्राइमरी स्कूलों के पास कम-कीमत वाले इलाके',
|
||||||
analysing: 'आपकी क्वेरी का विश्लेषण हो रहा है...',
|
analysing: 'आपकी क्वेरी का विश्लेषण हो रहा है...',
|
||||||
searchingDestinations: 'गंतव्य खोजे जा रहे हैं...',
|
searchingDestinations: 'गंतव्य खोजे जा रहे हैं...',
|
||||||
generatingFilters: 'फ़िल्टर बनाए जा रहे हैं...',
|
generatingFilters: 'फ़िल्टर बनाए जा रहे हैं...',
|
||||||
|
|
@ -860,7 +871,13 @@ const hi: Translations = {
|
||||||
walk: 'पैदल',
|
walk: 'पैदल',
|
||||||
cycle: 'साइकिल',
|
cycle: 'साइकिल',
|
||||||
nationalAvg: 'राष्ट्रीय औसत',
|
nationalAvg: 'राष्ट्रीय औसत',
|
||||||
|
outcodeAvg: 'आउटकोड औसत',
|
||||||
|
sectorAvg: 'सेक्टर औसत',
|
||||||
|
thisArea: 'यह क्षेत्र',
|
||||||
|
national: 'राष्ट्रीय',
|
||||||
crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है',
|
crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है',
|
||||||
|
residents: 'निवासी',
|
||||||
|
residentsTooltip: 'सामान्य निवासी (ONS जनगणना 2021)',
|
||||||
},
|
},
|
||||||
|
|
||||||
streetView: {
|
streetView: {
|
||||||
|
|
@ -1093,30 +1110,30 @@ const hi: Translations = {
|
||||||
videosTitle: 'सोशल मीडिया वीडियो',
|
videosTitle: 'सोशल मीडिया वीडियो',
|
||||||
videosIntro:
|
videosIntro:
|
||||||
'हमारे सोशल चैनलों की छोटी क्लिप्स — हर एक एक खोज को क्रिया में दिखाती है, शांत गलियों से लेकर स्कूल कैचमेंट और सफर के समय तक.',
|
'हमारे सोशल चैनलों की छोटी क्लिप्स — हर एक एक खोज को क्रिया में दिखाती है, शांत गलियों से लेकर स्कूल कैचमेंट और सफर के समय तक.',
|
||||||
video01Title: 'एक वाक्य, हर पोस्टकोड',
|
video01Title: 'सस्ता जुड़वाँ खोजें',
|
||||||
video01Desc:
|
video01Desc:
|
||||||
'अपनी पूरी घर की ज़रूरत सामान्य भाषा में लिखें और इंग्लैंड का हर मेल खाता पोस्टकोड जगमगाते देखें.',
|
'अपनी पूरी घर की ज़रूरत एक सरल वाक्य में लिखें और देखें कि इंग्लैंड का हर मेल खाता पोस्टकोड मूल्य के अनुसार छँटता है, उस सस्ते जुड़वाँ को सामने लाता है जिसकी किसी ने बोली नहीं बढ़ाई.',
|
||||||
video02Title: '20 मिनट का नक्शा',
|
video02Title: '20 मिनट का आवागमन नक्शा',
|
||||||
video02Desc:
|
video02Desc:
|
||||||
'नक्शे को सफर के समय के अनुसार रंगें और देखें कि सेंट्रल लंदन से 20 मिनट वास्तव में आपको क्या देते हैं.',
|
'लंदन को केंद्र तक आवागमन के अनुसार रंगें, 20 मिनट की सवारी तक सीमित करें, और देखें कि एक जैसे सफर कैसे बँट जाते हैं — मशहूर नामों और उन शांत पोस्टकोडों में जिनकी किसी ने बोली नहीं बढ़ाई.',
|
||||||
video03Title: 'हर पोस्टकोड की एक फ़ाइल है',
|
video03Title: 'पोस्टकोड की सबूत-फ़ाइल',
|
||||||
video03Desc:
|
video03Desc:
|
||||||
'किसी भी पोस्टकोड पर टैप करके उसकी फ़ाइल पढ़ें — बिक्री मूल्य, स्कूल, अपराध और स्ट्रीट व्यू, सब एक जगह.',
|
'किसी भी पोस्टकोड पर टैप करें और एक पैनल खुलता है जिसमें उसके बिक्री मूल्य, स्कूल कैचमेंट, अपराध और स्ट्रीट व्यू होते हैं — ताकि आप जान सकें कि आप मूल्य के लिए चुका रहे हैं या सिर्फ़ एक नाम के लिए.',
|
||||||
video04Title: 'फ़ोटो सुनी नहीं जा सकती',
|
video04Title: 'अनदेखी की गई शांत गली',
|
||||||
video04Desc:
|
video04Desc:
|
||||||
'विज्ञापन की तस्वीरें खामोश होती हैं. शोर के स्तर से छानकर 55 डेसिबल से नीचे की सचमुच शांत गलियाँ खोजें.',
|
'मशहूर पोस्टकोड अपनी प्रतिष्ठा को कीमत में जोड़ लेते हैं, पर विज्ञापन की तस्वीर शोर पर खामोश रहती है. डेसिबल से छानकर ठीक बगल की सचमुच शांत गली खोजें जिसकी किसी ने बोली नहीं बढ़ाई.',
|
||||||
video05Title: 'स्कूल-रन नक्शा',
|
video05Title: 'बिना प्रीमियम वाला कैचमेंट',
|
||||||
video05Desc:
|
video05Desc:
|
||||||
'अच्छे प्राइमरी कैचमेंट, कम अपराध और एक बजट — परिवार की ज़रूरत, पूरे शहर पर मैप की गई.',
|
'लीड्स का एक परिवार अच्छे प्राइमरी, कम अपराध और £350,000 से कम बजट की खोज करता है, और उन चुपचाप सस्ती गलियों को सामने लाता है जो ठीक वही कैचमेंट साझा करती हैं.',
|
||||||
video06Title: 'वेट्रोज़ टेस्ट',
|
video06Title: 'वेट्रोज़ असर, कीमत में शामिल',
|
||||||
video06Desc:
|
video06Desc:
|
||||||
'सुपरमार्केट, ट्यूब स्टेशन और पार्क से पैदल दूरी — सिर्फ़ फ़्लोर प्लान नहीं, अपनी ज़िंदगी के हिसाब से छानें.',
|
'किसी वेट्रोज़, ट्यूब स्टेशन और पार्क से पैदल दूरी एक कीमत-में-शामिल प्रीमियम है — पास के वे पोस्टकोड खोजें जिनमें वही सुविधाएँ प्रति वर्ग मीटर कम दाम पर मिलती हैं.',
|
||||||
video07Title: 'किराएदारों के लिए भी नक्शा',
|
video07Title: 'किराएदारों के लिए मूल्य नक्शा',
|
||||||
video07Desc:
|
video07Desc:
|
||||||
'बजट में किराया, छोटा सफर और शांत गली — किराये की साइटें फ़्लैट दिखाती हैं, यह आपको इलाके दिखाता है.',
|
'मशहूर पोस्टकोड किराये पर भी महँगे पड़ते हैं. अपना किराया, आवागमन और एक शांत गली तय करें, और देखें कि लंदन के कौन-से पोस्टकोड वाकई बजट में बैठते हैं.',
|
||||||
video08Title: '£9.99 बनाम एक बर्बाद शनिवार',
|
video08Title: 'नाम के लिए ज़्यादा चुकाना बंद करें',
|
||||||
video08Desc:
|
video08Desc:
|
||||||
'एक ख़राब विज़िट एक ट्रेन टिकट और आधा सप्ताहांत ले जाती है. बुकिंग से पहले देखें कि कहाँ नहीं जाना है.',
|
'बजट, आवागमन, अपराध और स्कूल तय करें, और लंदन का नक्शा उन कम-कीमत वाले पोस्टकोडों से भर जाता है जो मशहूर नामों से बस एक गली दूर बैठे हैं.',
|
||||||
source: 'स्रोत:',
|
source: 'स्रोत:',
|
||||||
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
|
optOut: 'सार्वजनिक प्रकटीकरण से बाहर निकलें',
|
||||||
attribution: 'श्रेय',
|
attribution: 'श्रेय',
|
||||||
|
|
@ -1501,6 +1518,16 @@ const hi: Translations = {
|
||||||
'Public order (avg/yr)': 'सार्वजनिक व्यवस्था अपराध (घनत्व)',
|
'Public order (avg/yr)': 'सार्वजनिक व्यवस्था अपराध (घनत्व)',
|
||||||
'Other crime (avg/yr)': 'अन्य अपराध (घनत्व)',
|
'Other crime (avg/yr)': 'अन्य अपराध (घनत्व)',
|
||||||
'Median age': 'मध्य आयु',
|
'Median age': 'मध्य आयु',
|
||||||
|
'% No qualifications': '% कोई योग्यता नहीं',
|
||||||
|
'% Some GCSEs': '% कुछ GCSE',
|
||||||
|
'% Good GCSEs': '% अच्छे GCSE',
|
||||||
|
'% Apprenticeship': '% अप्रेंटिसशिप',
|
||||||
|
'% A-levels': '% A-levels',
|
||||||
|
'% Degree or higher': '% डिग्री या उससे ऊपर',
|
||||||
|
'% Other qualifications': '% अन्य योग्यताएं',
|
||||||
|
'% Owner occupied': '% स्वामित्व वाले घर',
|
||||||
|
'% Social rent': '% सामाजिक किराया',
|
||||||
|
'% Private rent': '% निजी किराया',
|
||||||
'% White': '% श्वेत',
|
'% White': '% श्वेत',
|
||||||
'% South Asian': '% दक्षिण एशियाई',
|
'% South Asian': '% दक्षिण एशियाई',
|
||||||
'% Black': '% अश्वेत',
|
'% Black': '% अश्वेत',
|
||||||
|
|
@ -1538,6 +1565,8 @@ const hi: Translations = {
|
||||||
'Minor crime': 'मामूली अपराध',
|
'Minor crime': 'मामूली अपराध',
|
||||||
'Ethnic composition': 'जातीय संरचना',
|
'Ethnic composition': 'जातीय संरचना',
|
||||||
'Political vote share': 'राजनीतिक मत हिस्सेदारी',
|
'Political vote share': 'राजनीतिक मत हिस्सेदारी',
|
||||||
|
Qualifications: 'योग्यताएं',
|
||||||
|
Tenure: 'आवास स्वामित्व',
|
||||||
'Anti-social': 'असामाजिक',
|
'Anti-social': 'असामाजिक',
|
||||||
Vehicle: 'वाहन',
|
Vehicle: 'वाहन',
|
||||||
Burglary: 'सेंधमारी',
|
Burglary: 'सेंधमारी',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import type { Translations } from './en';
|
import type { Translations } from './en';
|
||||||
|
|
||||||
const hu: Translations = {
|
const hu: Translations = {
|
||||||
|
newDevelopments: {
|
||||||
|
title: 'Fejlesztési terület',
|
||||||
|
homesUpTo: 'Akár {{count}} lakás',
|
||||||
|
homesRange: '{{min}}–{{max}} lakás',
|
||||||
|
homesExact: '{{count}} lakás',
|
||||||
|
planningStatus: 'Tervezési állapot',
|
||||||
|
sourceBrownfield: 'Barnamezős nyilvántartás',
|
||||||
|
sourceHomesEngland: 'Homes England terület',
|
||||||
|
localAuthority: 'Helyi önkormányzat',
|
||||||
|
viewRecord: 'Tervezési dokumentum megtekintése ↗',
|
||||||
|
},
|
||||||
// ── Common ──────────────────────────────────────────
|
// ── Common ──────────────────────────────────────────
|
||||||
common: {
|
common: {
|
||||||
save: 'Mentés',
|
save: 'Mentés',
|
||||||
|
|
@ -790,10 +801,10 @@ const hu: Translations = {
|
||||||
describeIdealArea: 'Írd le, hol szeretnél élni',
|
describeIdealArea: 'Írd le, hol szeretnél élni',
|
||||||
aiSearch: 'AI-keresés',
|
aiSearch: 'AI-keresés',
|
||||||
describeHint: 'Írd le, mit keresel',
|
describeHint: 'Írd le, mit keresel',
|
||||||
placeholder: 'pl. 2 hálószoba £525k alatt, 45 perc munkába, csendes...',
|
placeholder: 'pl. ugyanazok az iskolák, olcsóbb irányítószám, £500k alatt…',
|
||||||
example1: '2 hálószoba £525k alatt, 45 perc munkába',
|
example1: 'Ugyanazok az iskolák, olcsóbb irányítószám',
|
||||||
example2: 'Családbarát területek jó iskolák közelében £650k alatt',
|
example2: 'A legjobb ár-érték a munkahelytől 30 percen belül',
|
||||||
example3: 'Több hely és ésszerű ingázás',
|
example3: 'Alulárazott területek jó általános iskolák közelében',
|
||||||
analysing: 'Lekérdezés elemzése...',
|
analysing: 'Lekérdezés elemzése...',
|
||||||
searchingDestinations: 'Úticélok keresése...',
|
searchingDestinations: 'Úticélok keresése...',
|
||||||
generatingFilters: 'Szűrők létrehozása...',
|
generatingFilters: 'Szűrők létrehozása...',
|
||||||
|
|
@ -889,7 +900,13 @@ const hu: Translations = {
|
||||||
walk: 'Gyalog',
|
walk: 'Gyalog',
|
||||||
cycle: 'Kerékpár',
|
cycle: 'Kerékpár',
|
||||||
nationalAvg: 'Országos átlag',
|
nationalAvg: 'Országos átlag',
|
||||||
|
outcodeAvg: 'Outcode átlag',
|
||||||
|
sectorAvg: 'Szektor átlag',
|
||||||
|
thisArea: 'Ez a terület',
|
||||||
|
national: 'Országos',
|
||||||
crimeDataEnds: 'A körzet rendőrségi adatai {{year}}-ig érhetők el',
|
crimeDataEnds: 'A körzet rendőrségi adatai {{year}}-ig érhetők el',
|
||||||
|
residents: 'Lakosok',
|
||||||
|
residentsTooltip: 'Állandó lakosok (ONS 2021-es népszámlálás)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -1130,30 +1147,30 @@ const hu: Translations = {
|
||||||
videosTitle: 'Közösségimédia-videók',
|
videosTitle: 'Közösségimédia-videók',
|
||||||
videosIntro:
|
videosIntro:
|
||||||
'Rövid klipek a közösségi csatornáinkról – mindegyik egy-egy keresést mutat be működés közben, a csendes utcáktól az iskolai körzeteken át az utazási időkig.',
|
'Rövid klipek a közösségi csatornáinkról – mindegyik egy-egy keresést mutat be működés közben, a csendes utcáktól az iskolai körzeteken át az utazási időkig.',
|
||||||
video01Title: 'Egy mondat, minden irányítószám',
|
video01Title: 'Találd meg az olcsóbb ikertestvért',
|
||||||
video01Desc:
|
video01Desc:
|
||||||
'Írd le a teljes lakáskeresési igényedet hétköznapi nyelven, és nézd, ahogy Anglia minden illeszkedő irányítószáma felgyúl.',
|
'Írd le a teljes lakáskeresési igényedet egyetlen egyszerű mondatban, és nézd, ahogy Anglia minden illeszkedő irányítószáma ár-érték szerint rendeződik, felszínre hozva az olcsóbb ikertestvért, amit senki sem vert fel.',
|
||||||
video02Title: 'A 20 perces térkép',
|
video02Title: 'A 20 perces ingázási térkép',
|
||||||
video02Desc:
|
video02Desc:
|
||||||
'Színezd a térképet utazási idő szerint, és lásd pontosan, mit hagy neked valójában 20 perc London központjától.',
|
'Színezd Londont a központba tartó ingázás szerint, szűkítsd 20 perces útra, és nézd, ahogy az azonos utak kettéválnak a mindenki által ismert nevekre és a csendesebb irányítószámokra, amelyeket senki sem vert fel.',
|
||||||
video03Title: 'Minden irányítószámnak van aktája',
|
video03Title: 'Az irányítószám bizonyítékaktája',
|
||||||
video03Desc:
|
video03Desc:
|
||||||
'Koppints bármelyik irányítószámra, és olvasd el az aktáját – eladási árak, iskolák, bűnözés és Street View egy helyen.',
|
'Koppints bármelyik irányítószámra, és megnyílik egy panel az eladási áraival, iskolai körzeteivel, bűnözéssel és Street View-val – így megállapíthatod, valódi értékért fizetsz-e, vagy csak egy hírnévért.',
|
||||||
video04Title: 'Egy fotót nem lehet meghallani',
|
video04Title: 'A figyelmen kívül hagyott csendes utca',
|
||||||
video04Desc:
|
video04Desc:
|
||||||
'A hirdetésfotók némák. Szűrj zajszint szerint, és találd meg a valóban csendes, 55 decibel alatti utcákat.',
|
'A híres irányítószámak beárazzák a hírnevüket, de egy hirdetésfotó néma a zajról. Szűrj decibel szerint, és találd meg az igazán csendes utcát egy sarokkal arrébb, amit senki sem vert fel.',
|
||||||
video05Title: 'Az iskolába vezető út térképe',
|
video05Title: 'A körzet felár nélkül',
|
||||||
video05Desc:
|
video05Desc:
|
||||||
'Jó általános iskolai körzetek, alacsony bűnözés és egy költségvetés – a családi igény, egy egész városra térképezve.',
|
'Egy leedsi család jó általános iskolát, alacsony bűnözést és £350k alatti költségvetést keres, és felszínre hozza a csendben olcsóbb utcákat, amelyek ugyanazt a körzetet osztják.',
|
||||||
video06Title: 'A Waitrose-teszt',
|
video06Title: 'A Waitrose-hatás, beárazva',
|
||||||
video06Desc:
|
video06Desc:
|
||||||
'Sétatávolságra egy szupermarkettől, egy metrómegállótól és egy parktól – az életedre szűrj, ne csak az alaprajzra.',
|
'Sétatávolságra egy Waitrose-tól, egy metrómegállótól és egy parktól – ez egy beárazott felár; találd meg a közeli irányítószámokat ugyanazokkal a szolgáltatásokkal, négyzetméterenként olcsóbban.',
|
||||||
video07Title: 'A bérlőknek is jár térkép',
|
video07Title: 'Ár-érték térkép bérlőknek',
|
||||||
video07Desc:
|
video07Desc:
|
||||||
'Költségvetésbe férő bérleti díj, rövid ingázás és csendes utca – a bérleti oldalak lakásokat mutatnak, ez környékeket.',
|
'A neves irányítószámok bérelni is drágábbak. Add meg a bérleti díjat, az ingázást és egy csendes utcát, és lásd, mely londoni irányítószámok férnek tényleg a pénztárcádba.',
|
||||||
video08Title: '9,99 £ egy elpazarolt szombat ellen',
|
video08Title: 'Ne fizess túl egy névért',
|
||||||
video08Desc:
|
video08Desc:
|
||||||
'Egy rossz megtekintés egy vonatjegybe és egy fél hétvégébe kerül. Még foglalás előtt lásd, hova ne menj.',
|
'Állíts be költségvetést, ingázást, bűnözést és iskolákat, és a londoni térkép megtelik alulárazott irányítószámokkal, amelyek egy utcányira fekszenek a híres nevektől.',
|
||||||
source: 'Forrás:',
|
source: 'Forrás:',
|
||||||
optOut: 'Nyilvános közzététel visszautasítása',
|
optOut: 'Nyilvános közzététel visszautasítása',
|
||||||
attribution: 'Forrásmegnevezés',
|
attribution: 'Forrásmegnevezés',
|
||||||
|
|
@ -1577,6 +1594,16 @@ const hu: Translations = {
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': 'Medián életkor',
|
'Median age': 'Medián életkor',
|
||||||
|
'% No qualifications': '% Végzettség nélkül',
|
||||||
|
'% Some GCSEs': '% Néhány GCSE',
|
||||||
|
'% Good GCSEs': '% Jó GCSE',
|
||||||
|
'% Apprenticeship': '% Tanoncképzés',
|
||||||
|
'% A-levels': '% A-levels',
|
||||||
|
'% Degree or higher': '% Diploma vagy magasabb',
|
||||||
|
'% Other qualifications': '% Egyéb végzettség',
|
||||||
|
'% Owner occupied': '% Saját tulajdonú lakás',
|
||||||
|
'% Social rent': '% Szociális bérlakás',
|
||||||
|
'% Private rent': '% Magánbérlemény',
|
||||||
'% White': '% fehér',
|
'% White': '% fehér',
|
||||||
'% South Asian': '% dél-ázsiai',
|
'% South Asian': '% dél-ázsiai',
|
||||||
'% Black': '% fekete',
|
'% Black': '% fekete',
|
||||||
|
|
@ -1623,6 +1650,8 @@ const hu: Translations = {
|
||||||
'Minor crime': 'Kisebb bűncselekmény',
|
'Minor crime': 'Kisebb bűncselekmény',
|
||||||
'Ethnic composition': 'Etnikai összetétel',
|
'Ethnic composition': 'Etnikai összetétel',
|
||||||
'Political vote share': 'Szavazati megoszlás',
|
'Political vote share': 'Szavazati megoszlás',
|
||||||
|
Qualifications: 'Végzettség',
|
||||||
|
Tenure: 'Lakhatási jogviszony',
|
||||||
'Anti-social': 'Közösségellenes',
|
'Anti-social': 'Közösségellenes',
|
||||||
Vehicle: 'Jármű',
|
Vehicle: 'Jármű',
|
||||||
Burglary: 'Betörés',
|
Burglary: 'Betörés',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import type { Translations } from './en';
|
import type { Translations } from './en';
|
||||||
|
|
||||||
const zh: Translations = {
|
const zh: Translations = {
|
||||||
|
newDevelopments: {
|
||||||
|
title: '开发地块',
|
||||||
|
homesUpTo: '最多 {{count}} 套住宅',
|
||||||
|
homesRange: '{{min}}–{{max}} 套住宅',
|
||||||
|
homesExact: '{{count}} 套住宅',
|
||||||
|
planningStatus: '规划状态',
|
||||||
|
sourceBrownfield: '棕地登记册',
|
||||||
|
sourceHomesEngland: 'Homes England 用地',
|
||||||
|
localAuthority: '地方政府',
|
||||||
|
viewRecord: '查看规划记录 ↗',
|
||||||
|
},
|
||||||
// ── Common ──────────────────────────────────────────
|
// ── Common ──────────────────────────────────────────
|
||||||
common: {
|
common: {
|
||||||
save: '保存',
|
save: '保存',
|
||||||
|
|
@ -735,10 +746,10 @@ const zh: Translations = {
|
||||||
describeIdealArea: '描述您想住在哪里',
|
describeIdealArea: '描述您想住在哪里',
|
||||||
aiSearch: 'AI 搜索',
|
aiSearch: 'AI 搜索',
|
||||||
describeHint: '描述您要找的区域',
|
describeHint: '描述您要找的区域',
|
||||||
placeholder: '例如:两居室,£525,000 以下,到公司 45 分钟,安静...',
|
placeholder: '例如:同样的学校,更便宜的邮编,£500,000 以下…',
|
||||||
example1: '两居室,£525,000 以下,到公司 45 分钟',
|
example1: '同样的学校,更便宜的邮编',
|
||||||
example2: '靠近好学校、低于 £650,000 的家庭友好区域',
|
example2: '通勤 30 分钟内性价比最高',
|
||||||
example3: '空间更大,通勤别太累',
|
example3: '优质小学附近被低估的区域',
|
||||||
analysing: '正在分析您的需求...',
|
analysing: '正在分析您的需求...',
|
||||||
searchingDestinations: '正在搜索目的地...',
|
searchingDestinations: '正在搜索目的地...',
|
||||||
generatingFilters: '正在生成筛选条件...',
|
generatingFilters: '正在生成筛选条件...',
|
||||||
|
|
@ -831,7 +842,13 @@ const zh: Translations = {
|
||||||
walk: '步行',
|
walk: '步行',
|
||||||
cycle: '骑行',
|
cycle: '骑行',
|
||||||
nationalAvg: '全国平均',
|
nationalAvg: '全国平均',
|
||||||
|
outcodeAvg: '邮区平均',
|
||||||
|
sectorAvg: '分区平均',
|
||||||
|
thisArea: '本区域',
|
||||||
|
national: '全国',
|
||||||
crimeDataEnds: '该地区的警方数据截至{{year}}年',
|
crimeDataEnds: '该地区的警方数据截至{{year}}年',
|
||||||
|
residents: '居民',
|
||||||
|
residentsTooltip: '常住居民(ONS 2021 年人口普查)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -1066,22 +1083,30 @@ const zh: Translations = {
|
||||||
videosTitle: '社交媒体视频',
|
videosTitle: '社交媒体视频',
|
||||||
videosIntro:
|
videosIntro:
|
||||||
'来自我们社交平台的短片——每一个都展示一次实际搜索,从安静街道到学校学区,再到通勤时间。',
|
'来自我们社交平台的短片——每一个都展示一次实际搜索,从安静街道到学校学区,再到通勤时间。',
|
||||||
video01Title: '一句话,每个邮编',
|
video01Title: '找到更便宜的孪生邮编',
|
||||||
video01Desc: '用日常语言写下你的全部购房需求,看着英格兰每个符合条件的邮编亮起来。',
|
video01Desc:
|
||||||
video02Title: '20 分钟地图',
|
'用一句平实的话写下你的全部购房需求,看着英格兰每个符合条件的邮编按性价比排序,浮现出那个无人抬价、更便宜的孪生邮编。',
|
||||||
video02Desc: '按通勤时间为地图着色,看清从伦敦市中心 20 分钟究竟能到达哪里。',
|
video02Title: '20 分钟通勤地图',
|
||||||
video03Title: '每个邮编都有一份档案',
|
video02Desc:
|
||||||
video03Desc: '点按任意邮编即可查看其档案——成交价、学校、犯罪和街景,尽在一处。',
|
'按到市中心的通勤时间为伦敦着色,收窄到 20 分钟车程,看着相同的通勤路程被分成人人皆知的名字,和那些无人抬价、更安静的邮编。',
|
||||||
video04Title: '照片听不见声音',
|
video03Title: '邮编证据档案',
|
||||||
video04Desc: '房源照片是无声的。按噪音水平筛选,找到真正安静、低于 55 分贝的街道。',
|
video03Desc:
|
||||||
video05Title: '上学路线地图',
|
'点按任意邮编,便会打开一个面板,列出它的成交价、学校学区、犯罪和街景——让你看清自己付的是真正的价值,还是仅仅一个名声。',
|
||||||
video05Desc: '优质小学学区、低犯罪率和预算——把家庭需求映射到整座城市。',
|
video04Title: '被忽视的安静街道',
|
||||||
video06Title: 'Waitrose 测试',
|
video04Desc:
|
||||||
video06Desc: '步行可达超市、地铁站和公园——按你的生活方式筛选,而不只是户型图。',
|
'知名邮编把名声计入了价格,但房源照片对噪音却只字不提。按分贝筛选,找到隔壁那条真正安静、无人抬价的街道。',
|
||||||
video07Title: '租房者也有地图',
|
video05Title: '没有溢价的学区',
|
||||||
video07Desc: '预算内的租金、短通勤和安静街道——租房网站展示房源,这里为你展示区域。',
|
video05Desc:
|
||||||
video08Title: '£9.99 对比浪费的周六',
|
'利兹的一家人寻找优质小学、低犯罪率以及 £350,000 以下的预算,便浮现出那些悄然更便宜、却共享同一学区的街道。',
|
||||||
video08Desc: '一次糟糕的看房要花一张车票和半个周末。在预约之前就看清哪里不该去。',
|
video06Title: 'Waitrose 效应,已计入价格',
|
||||||
|
video06Desc:
|
||||||
|
'步行可达 Waitrose、地铁站和公园是一种已计入价格的溢价——找到附近拥有同样配套、每平方米却更便宜的邮编。',
|
||||||
|
video07Title: '为租房者准备的价值地图',
|
||||||
|
video07Desc:
|
||||||
|
'知名邮编租起来也更贵。设定你的租金、通勤和一条安静街道,看看伦敦哪些邮编真正符合预算。',
|
||||||
|
video08Title: '别再为一个名字多花钱',
|
||||||
|
video08Desc:
|
||||||
|
'设定预算、通勤、犯罪和学校,伦敦地图便会布满被低估的邮编,它们就坐落在知名地段隔壁一条街。',
|
||||||
source: '来源:',
|
source: '来源:',
|
||||||
optOut: '选择不公开',
|
optOut: '选择不公开',
|
||||||
attribution: '数据引用声明',
|
attribution: '数据引用声明',
|
||||||
|
|
@ -1488,6 +1513,16 @@ const zh: Translations = {
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': '中位年龄',
|
'Median age': '中位年龄',
|
||||||
|
'% No qualifications': '% 无学历',
|
||||||
|
'% Some GCSEs': '% 部分 GCSE',
|
||||||
|
'% Good GCSEs': '% 良好 GCSE',
|
||||||
|
'% Apprenticeship': '% 学徒制',
|
||||||
|
'% A-levels': '% A-levels',
|
||||||
|
'% Degree or higher': '% 学位及以上',
|
||||||
|
'% Other qualifications': '% 其他学历',
|
||||||
|
'% Owner occupied': '% 自有住房',
|
||||||
|
'% Social rent': '% 社会租赁',
|
||||||
|
'% Private rent': '% 私人租赁',
|
||||||
'% White': '% 白人',
|
'% White': '% 白人',
|
||||||
'% South Asian': '% 南亚裔',
|
'% South Asian': '% 南亚裔',
|
||||||
'% Black': '% 黑人',
|
'% Black': '% 黑人',
|
||||||
|
|
@ -1534,6 +1569,8 @@ const zh: Translations = {
|
||||||
'Minor crime': '轻微犯罪',
|
'Minor crime': '轻微犯罪',
|
||||||
'Ethnic composition': '族裔组成',
|
'Ethnic composition': '族裔组成',
|
||||||
'Political vote share': '政党得票率',
|
'Political vote share': '政党得票率',
|
||||||
|
Qualifications: '学历',
|
||||||
|
Tenure: '住房产权',
|
||||||
'Anti-social': '反社会',
|
'Anti-social': '反社会',
|
||||||
Vehicle: '车辆',
|
Vehicle: '车辆',
|
||||||
Burglary: '入室盗窃',
|
Burglary: '入室盗窃',
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ const DEMO_GATED_ENDPOINTS = new Set([
|
||||||
'hexagon-properties',
|
'hexagon-properties',
|
||||||
'journey',
|
'journey',
|
||||||
'actual-listings',
|
'actual-listings',
|
||||||
|
'developments',
|
||||||
'export',
|
'export',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,14 @@ export const POI_CLUSTER_MAX_ZOOM = 15;
|
||||||
/** Zoom level at which individual POI cards are shown without hovering */
|
/** Zoom level at which individual POI cards are shown without hovering */
|
||||||
export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1;
|
export const POI_AUTO_CARD_ZOOM_THRESHOLD = POI_CLUSTER_MAX_ZOOM + 1;
|
||||||
|
|
||||||
|
/** Hard cap on auto POI cards rendered at once. With every category enabled a
|
||||||
|
* dense area can yield hundreds of overlapping cards — an unreadable wall — so we
|
||||||
|
* show a spaced subset and rely on the map markers (+ hover) for the rest. */
|
||||||
|
export const MAX_AUTO_POI_CARDS = 40;
|
||||||
|
/** Minimum screen-space gap (px) between two auto POI cards before one is culled. */
|
||||||
|
export const AUTO_POI_CARD_MIN_DX = 130;
|
||||||
|
export const AUTO_POI_CARD_MIN_DY = 34;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Groups whose features should be collapsed into stacked bar charts.
|
* Groups whose features should be collapsed into stacked bar charts.
|
||||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||||
|
|
@ -290,6 +298,19 @@ export const STACKED_GROUPS: Record<
|
||||||
'% Other',
|
'% Other',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Qualifications',
|
||||||
|
unit: '%',
|
||||||
|
components: [
|
||||||
|
'% No qualifications',
|
||||||
|
'% Some GCSEs',
|
||||||
|
'% Good GCSEs',
|
||||||
|
'% Apprenticeship',
|
||||||
|
'% A-levels',
|
||||||
|
'% Degree or higher',
|
||||||
|
'% Other qualifications',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Political vote share',
|
label: 'Political vote share',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
|
|
@ -302,6 +323,11 @@ export const STACKED_GROUPS: Record<
|
||||||
'% Other parties',
|
'% Other parties',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Tenure',
|
||||||
|
unit: '%',
|
||||||
|
components: ['% Owner occupied', '% Social rent', '% Private rent'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -470,6 +496,18 @@ export const STACKED_SEGMENT_COLORS: Record<string, string> = {
|
||||||
'% Black': '#8b5cf6',
|
'% Black': '#8b5cf6',
|
||||||
'% Mixed': '#14b8a6',
|
'% Mixed': '#14b8a6',
|
||||||
'% Other': '#6b7280',
|
'% Other': '#6b7280',
|
||||||
|
// Qualifications: low-attainment (red) → high-attainment (green) ramp.
|
||||||
|
'% No qualifications': '#ef4444',
|
||||||
|
'% Some GCSEs': '#f97316',
|
||||||
|
'% Good GCSEs': '#eab308',
|
||||||
|
'% Apprenticeship': '#14b8a6',
|
||||||
|
'% A-levels': '#3b82f6',
|
||||||
|
'% Degree or higher': '#22c55e',
|
||||||
|
'% Other qualifications': '#6b7280',
|
||||||
|
// Tenure (Census 2021 TS054): owner-occupied (green) → social rent (amber) → private rent (blue).
|
||||||
|
'% Owner occupied': '#22c55e',
|
||||||
|
'% Social rent': '#f59e0b',
|
||||||
|
'% Private rent': '#3b82f6',
|
||||||
'Anti-social': '#14b8a6',
|
'Anti-social': '#14b8a6',
|
||||||
Vehicle: '#3b82f6',
|
Vehicle: '#3b82f6',
|
||||||
Burglary: '#eab308',
|
Burglary: '#eab308',
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,39 @@ import {
|
||||||
getFeatureFillColor,
|
getFeatureFillColor,
|
||||||
getMapCenterForTargetScreenPoint,
|
getMapCenterForTargetScreenPoint,
|
||||||
getPoiIconUrl,
|
getPoiIconUrl,
|
||||||
|
selectSpacedItems,
|
||||||
zoomToResolution,
|
zoomToResolution,
|
||||||
} from './map-utils';
|
} from './map-utils';
|
||||||
|
|
||||||
|
describe('selectSpacedItems', () => {
|
||||||
|
const mk = (id: string, x: number, y: number) => ({ item: id, x, y });
|
||||||
|
|
||||||
|
it('keeps every item when none overlap', () => {
|
||||||
|
const items = [mk('a', 0, 0), mk('b', 200, 0), mk('c', 0, 200)];
|
||||||
|
const kept = selectSpacedItems(items, 130, 34, 40);
|
||||||
|
expect(kept.map((k) => k.item)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('culls items that fall within the spacing of an already-kept item', () => {
|
||||||
|
// b and c sit on top of a; d is clear.
|
||||||
|
const items = [mk('a', 100, 100), mk('b', 120, 110), mk('c', 150, 100), mk('d', 400, 400)];
|
||||||
|
const kept = selectSpacedItems(items, 130, 34, 40);
|
||||||
|
expect(kept.map((k) => k.item)).toEqual(['a', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats dx and dy independently (far enough on either axis is kept)', () => {
|
||||||
|
const items = [mk('a', 0, 0), mk('b', 10, 40)]; // dx<130 but dy>=34
|
||||||
|
const kept = selectSpacedItems(items, 130, 34, 40);
|
||||||
|
expect(kept.map((k) => k.item)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at the max cap', () => {
|
||||||
|
const items = Array.from({ length: 100 }, (_, i) => mk(String(i), i * 500, 0));
|
||||||
|
const kept = selectSpacedItems(items, 130, 34, 40);
|
||||||
|
expect(kept).toHaveLength(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('map utilities', () => {
|
describe('map utilities', () => {
|
||||||
it('maps zoom levels to H3 resolutions at configured thresholds', () => {
|
it('maps zoom levels to H3 resolutions at configured thresholds', () => {
|
||||||
expect(zoomToResolution(6.9)).toBe(5);
|
expect(zoomToResolution(6.9)).toBe(5);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,36 @@ import {
|
||||||
MAP_MIN_ZOOM,
|
MAP_MIN_ZOOM,
|
||||||
type GradientStop,
|
type GradientStop,
|
||||||
} from './consts';
|
} from './consts';
|
||||||
|
/** A candidate overlay anchored at screen-space (x, y). */
|
||||||
|
export interface PlacedItem<T> {
|
||||||
|
item: T;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greedily keep a spaced-out subset of screen-space items so dense areas don't
|
||||||
|
* render a wall of overlapping cards. Walks `candidates` in order, keeping one
|
||||||
|
* only when it clears every already-kept item by `minDx`/`minDy`, and stops at
|
||||||
|
* `max`. Order-dependent by design — pass candidates in priority order.
|
||||||
|
*/
|
||||||
|
export function selectSpacedItems<T>(
|
||||||
|
candidates: PlacedItem<T>[],
|
||||||
|
minDx: number,
|
||||||
|
minDy: number,
|
||||||
|
max: number
|
||||||
|
): PlacedItem<T>[] {
|
||||||
|
const kept: PlacedItem<T>[] = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (kept.length >= max) break;
|
||||||
|
const overlaps = kept.some(
|
||||||
|
(k) => Math.abs(k.x - candidate.x) < minDx && Math.abs(k.y - candidate.y) < minDy
|
||||||
|
);
|
||||||
|
if (!overlaps) kept.push(candidate);
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
const ROAD_OPACITY = 0.4;
|
const ROAD_OPACITY = 0.4;
|
||||||
const TILE_SIZE = 512;
|
const TILE_SIZE = 512;
|
||||||
const MAX_MERCATOR_LATITUDE = 85;
|
const MAX_MERCATOR_LATITUDE = 85;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export const OVERLAY_IDS = [
|
||||||
'crime-hotspots',
|
'crime-hotspots',
|
||||||
'trees-outside-woodlands',
|
'trees-outside-woodlands',
|
||||||
'property-borders',
|
'property-borders',
|
||||||
|
'new-developments',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type OverlayId = (typeof OVERLAY_IDS)[number];
|
export type OverlayId = (typeof OVERLAY_IDS)[number];
|
||||||
|
|
@ -47,6 +48,13 @@ export const OVERLAYS: OverlayDefinition[] = [
|
||||||
detail:
|
detail:
|
||||||
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are "general boundaries" for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 85–90% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
|
'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are "general boundaries" for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 85–90% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'new-developments',
|
||||||
|
label: 'New developments',
|
||||||
|
description: 'Planned new-home sites (brownfield register + Homes England)',
|
||||||
|
detail:
|
||||||
|
'A forward-looking pipeline of new housing. Blue markers mark sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Overlays shown on a fresh visit, before any `overlay` URL params apply. */
|
/** Overlays shown on a fresh visit, before any `overlay` URL params apply. */
|
||||||
|
|
@ -73,4 +81,5 @@ export const OVERLAY_MIN_ZOOM: Record<OverlayId, number> = {
|
||||||
noise: 12,
|
noise: 12,
|
||||||
'property-borders': 12,
|
'property-borders': 12,
|
||||||
'trees-outside-woodlands': 12,
|
'trees-outside-woodlands': 12,
|
||||||
|
'new-developments': 12,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
22
frontend/src/lib/qualification-filter.ts
Normal file
22
frontend/src/lib/qualification-filter.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* The Census 2021 qualification-breakdown features (TS067). These render as a
|
||||||
|
* single stacked "Qualifications" composition in the area pane (see
|
||||||
|
* STACKED_GROUPS["Neighbours"] in consts.ts) and are display-only: they are
|
||||||
|
* hidden from the filter browser rather than offered as seven individual
|
||||||
|
* sliders, so the breakdown reads as a ratio without cluttering the filter list.
|
||||||
|
*/
|
||||||
|
export const QUALIFICATION_FEATURE_NAMES = [
|
||||||
|
'% No qualifications',
|
||||||
|
'% Some GCSEs',
|
||||||
|
'% Good GCSEs',
|
||||||
|
'% Apprenticeship',
|
||||||
|
'% A-levels',
|
||||||
|
'% Degree or higher',
|
||||||
|
'% Other qualifications',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const QUALIFICATION_FEATURE_NAME_SET = new Set<string>(QUALIFICATION_FEATURE_NAMES);
|
||||||
|
|
||||||
|
export function isQualificationFeatureName(name: string): boolean {
|
||||||
|
return QUALIFICATION_FEATURE_NAME_SET.has(name);
|
||||||
|
}
|
||||||
|
|
@ -165,6 +165,27 @@ export interface ActualListingsResponse {
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Development {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
source: 'brownfield' | 'homes-england';
|
||||||
|
name?: string | null;
|
||||||
|
min_dwellings?: number | null;
|
||||||
|
max_dwellings?: number | null;
|
||||||
|
planning_status?: string | null;
|
||||||
|
permission_type?: string | null;
|
||||||
|
permission_date?: string | null;
|
||||||
|
hectares?: number | null;
|
||||||
|
local_authority?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevelopmentsResponse {
|
||||||
|
developments: Development[];
|
||||||
|
total: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface POICategoryGroup {
|
export interface POICategoryGroup {
|
||||||
name: string;
|
name: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
|
@ -294,6 +315,18 @@ export interface CrimeYearStats {
|
||||||
points: CrimeYearPoint[];
|
points: CrimeYearPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CrimeAreaAverage {
|
||||||
|
/** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */
|
||||||
|
name: string;
|
||||||
|
/** Exact national mean (avg/yr). Preferred over the histogram-bin national
|
||||||
|
* average for crime so all reference numbers share one estimator. */
|
||||||
|
national?: number;
|
||||||
|
/** Mean headline rate (avg/yr) across the selection's outcode. */
|
||||||
|
outcode?: number;
|
||||||
|
/** Mean headline rate (avg/yr) across the selection's postcode sector. */
|
||||||
|
sector?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FilterExclusion {
|
export interface FilterExclusion {
|
||||||
name: string;
|
name: string;
|
||||||
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
||||||
|
|
@ -319,6 +352,18 @@ export interface HexagonStatsResponse {
|
||||||
* since mid-2019) and its crime figures are captioned as stale.
|
* since mid-2019) and its crime figures are captioned as stale.
|
||||||
*/
|
*/
|
||||||
crime_latest_year?: number;
|
crime_latest_year?: number;
|
||||||
|
/** Outward code (e.g. "E14") of the selection's central postcode, when
|
||||||
|
* outcode crime averages are available for it. */
|
||||||
|
crime_outcode?: string;
|
||||||
|
/** Postcode sector (e.g. "E14 2") of the selection's central postcode, when
|
||||||
|
* sector crime averages are available for it. */
|
||||||
|
crime_sector?: string;
|
||||||
|
/** Per-crime-type average rates across the central postcode's outcode and
|
||||||
|
* sector, shown alongside the national average for each crime metric. */
|
||||||
|
crime_area_averages?: CrimeAreaAverage[];
|
||||||
central_postcode?: string;
|
central_postcode?: string;
|
||||||
|
/** Total usual residents (ONS Census 2021) across the postcodes in this
|
||||||
|
* selection. Display-only; independent of active filters. */
|
||||||
|
population?: number;
|
||||||
filter_exclusions?: FilterExclusion[];
|
filter_exclusions?: FilterExclusion[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
153
pipeline/download/census_population.py
Normal file
153
pipeline/download/census_population.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""Download Census 2021 usual-resident counts per unit postcode.
|
||||||
|
|
||||||
|
ONS Census 2021 publishes a *direct* headcount of usual residents for every
|
||||||
|
unit postcode in England and Wales (table P001, disaggregated by sex) as a bulk
|
||||||
|
CSV on the NOMIS webservice. We sum the two sex rows per postcode to get the
|
||||||
|
total usual-resident population and emit one row per unit postcode.
|
||||||
|
|
||||||
|
This is a display-only side table (shown in the right-hand area pane); it is NOT
|
||||||
|
merged into the property/postcode feature frames and is never a filterable
|
||||||
|
attribute. The Rust server loads the parquet directly via --population-path.
|
||||||
|
|
||||||
|
Source: NOMIS bulk product "Postcode resident and household estimates, England
|
||||||
|
and Wales: Census 2021" (table P001), as at census day 21 March 2021.
|
||||||
|
https://www.nomisweb.co.uk/sources/census_2021_pc
|
||||||
|
License: Open Government Licence v3.0
|
||||||
|
|
||||||
|
Caveats (Census 2021 disclosure control): counts carry targeted record swapping
|
||||||
|
and cell-key perturbation, and only postcodes with at least one usual resident
|
||||||
|
are present, so vacant/non-residential postcodes are absent from the output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.utils import download
|
||||||
|
|
||||||
|
# P001 = "Number of usual residents by postcode by sex", split alphabetically by
|
||||||
|
# postcode into four CSVs (each: Postcode, Sex Code, Sex Label, Count). The
|
||||||
|
# per-file minimums are ~95% of the published record counts and let us retry
|
||||||
|
# until a download is complete (see _fetch_csv).
|
||||||
|
BASE = "https://www.nomisweb.co.uk/output/census/2021"
|
||||||
|
P001_FILES = [
|
||||||
|
("pcd_p001_a_d.csv", 720_000),
|
||||||
|
("pcd_p001_e_l.csv", 540_000),
|
||||||
|
("pcd_p001_m_r.csv", 630_000),
|
||||||
|
("pcd_p001_s_z.csv", 750_000),
|
||||||
|
]
|
||||||
|
|
||||||
|
# P001 covers England & Wales (~1.37M postcodes). A materially smaller result
|
||||||
|
# means a split file was truncated or silently 404'd.
|
||||||
|
MIN_EXPECTED_POSTCODES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_postcode(col: pl.Expr) -> pl.Expr:
|
||||||
|
"""Canonical spaced unit postcode (e.g. "AL1 1AG"), matching the Rust
|
||||||
|
server's `normalize_postcode`: uppercase, strip non-alphanumerics, then
|
||||||
|
insert a single space before the final three (inward-code) characters."""
|
||||||
|
cleaned = col.cast(pl.String).str.to_uppercase().str.replace_all(r"[^A-Z0-9]", "")
|
||||||
|
return (
|
||||||
|
cleaned.str.slice(0, cleaned.str.len_chars() - 3)
|
||||||
|
+ pl.lit(" ")
|
||||||
|
+ cleaned.str.slice(-3)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_csv(
|
||||||
|
url: str, dest: Path, min_rows: int, *, max_attempts: int = 20
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""Download a CSV, defending against silent truncation.
|
||||||
|
|
||||||
|
The NOMIS bulk files are served with chunked transfer encoding and no
|
||||||
|
Content-Length, so a dropped connection ends the stream without raising —
|
||||||
|
yielding a short file. We retry until the parsed row count reaches the
|
||||||
|
file's known approximate size, keeping the largest parse seen.
|
||||||
|
"""
|
||||||
|
best: pl.DataFrame | None = None
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
try:
|
||||||
|
download(url, dest)
|
||||||
|
df = pl.read_csv(dest)
|
||||||
|
except Exception as exc: # noqa: BLE001 — retry any transport/parse error
|
||||||
|
print(f" {url} attempt {attempt}: error {exc}")
|
||||||
|
time.sleep(3)
|
||||||
|
continue
|
||||||
|
rows = df.height
|
||||||
|
if best is None or rows > best.height:
|
||||||
|
best = df
|
||||||
|
complete = rows >= min_rows
|
||||||
|
print(
|
||||||
|
f" {url} attempt {attempt}: {rows} rows "
|
||||||
|
f"({'complete' if complete else f'< {min_rows}, retrying'})"
|
||||||
|
)
|
||||||
|
if complete:
|
||||||
|
return df
|
||||||
|
time.sleep(2)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to fully download {url} after {max_attempts} attempts "
|
||||||
|
f"(best {best.height if best is not None else 0} rows, need >= {min_rows})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_and_convert(output_path: Path) -> None:
|
||||||
|
frames: list[pl.DataFrame] = []
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
for name, min_rows in P001_FILES:
|
||||||
|
url = f"{BASE}/{name}"
|
||||||
|
dest = Path(tmp) / name
|
||||||
|
print(f"Downloading {url} ...")
|
||||||
|
frames.append(_fetch_csv(url, dest, min_rows))
|
||||||
|
|
||||||
|
df = pl.concat(frames)
|
||||||
|
print(f"Total P001 rows (postcode x sex): {df.height}")
|
||||||
|
|
||||||
|
if "Count" not in df.columns or "Postcode" not in df.columns:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected P001 columns {df.columns}; expected 'Postcode' and 'Count'."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = (
|
||||||
|
df.with_columns(_canonical_postcode(pl.col("Postcode")).alias("postcode"))
|
||||||
|
.filter(pl.col("postcode").str.len_chars() >= 5)
|
||||||
|
.group_by("postcode")
|
||||||
|
.agg(pl.col("Count").cast(pl.Int64).sum().alias("population"))
|
||||||
|
.filter(pl.col("population") > 0)
|
||||||
|
.with_columns(pl.col("population").cast(pl.UInt32))
|
||||||
|
.sort("postcode")
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Unit postcodes with population: {result.height}")
|
||||||
|
if result.height < MIN_EXPECTED_POSTCODES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Only {result.height} postcodes (expected >= {MIN_EXPECTED_POSTCODES}); "
|
||||||
|
"a NOMIS P001 split file was likely truncated or unavailable."
|
||||||
|
)
|
||||||
|
total = result["population"].sum()
|
||||||
|
print(f"Total usual residents (England & Wales): {total:,}")
|
||||||
|
print(
|
||||||
|
f"Per-postcode population range: {result['population'].min()} - "
|
||||||
|
f"{result['population'].max()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
result.write_parquet(output_path, compression="zstd")
|
||||||
|
print(f"Saved to {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download Census 2021 usual-resident counts per unit postcode"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
download_and_convert(args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
302
pipeline/download/development_sites.py
Normal file
302
pipeline/download/development_sites.py
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
"""Download planned/pipeline development sites for the "new developments" layer.
|
||||||
|
|
||||||
|
This is the forward-looking "where new homes are coming" signal that complements
|
||||||
|
the EPC new-build + Land Registry "first sale" delivery signals already in the
|
||||||
|
pipeline. Two national, Open Government Licence v3.0 sources are merged into a
|
||||||
|
single coordinate-keyed parquet that the Rust server serves as a map layer:
|
||||||
|
|
||||||
|
- MHCLG Brownfield Land register (statutory LPA brownfield registers, normalised)
|
||||||
|
https://www.planning.data.gov.uk/dataset/brownfield-land
|
||||||
|
- Homes England Land Hub (public land being disposed for development)
|
||||||
|
https://www.gov.uk/government/publications/homes-england-land-hub
|
||||||
|
|
||||||
|
Output schema (one row per site):
|
||||||
|
lat, lon, source, name, min_dwellings, max_dwellings, planning_status,
|
||||||
|
permission_type, permission_date, hectares, local_authority, url
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import polars as pl
|
||||||
|
from shapely.geometry import shape
|
||||||
|
|
||||||
|
from pipeline.local_temp import local_tmp_dir
|
||||||
|
from pipeline.utils.download import download
|
||||||
|
|
||||||
|
BROWNFIELD_URL = "https://files.planning.data.gov.uk/dataset/brownfield-land.parquet"
|
||||||
|
HOMES_ENGLAND_URL = (
|
||||||
|
"https://services-eu1.arcgis.com/yo0w4PgP4XL49bfF/arcgis/rest/services/"
|
||||||
|
"Homes_England_Land_Hub_Sites/FeatureServer/0/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Output column order; doubles as the polars schema so empty inputs still produce
|
||||||
|
# a well-typed frame. The Rust loader (data/developments.rs) reads these names.
|
||||||
|
SCHEMA: dict[str, pl.DataType] = {
|
||||||
|
"lat": pl.Float64,
|
||||||
|
"lon": pl.Float64,
|
||||||
|
"source": pl.String,
|
||||||
|
"name": pl.String,
|
||||||
|
"min_dwellings": pl.Int32,
|
||||||
|
"max_dwellings": pl.Int32,
|
||||||
|
"planning_status": pl.String,
|
||||||
|
"permission_type": pl.String,
|
||||||
|
"permission_date": pl.String,
|
||||||
|
"hectares": pl.Float64,
|
||||||
|
"local_authority": pl.String,
|
||||||
|
"url": pl.String,
|
||||||
|
}
|
||||||
|
|
||||||
|
_COORD = r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?"
|
||||||
|
_POINT_RE = re.compile(rf"POINT\s*\(\s*({_COORD})\s+({_COORD})\s*\)", re.IGNORECASE)
|
||||||
|
|
||||||
|
# 1 acre = 0.404686 hectares. The Homes England feed reports area in acres.
|
||||||
|
_ACRES_TO_HECTARES = 0.404686
|
||||||
|
|
||||||
|
# Great Britain bounding box, used to reject mis-ordered or projected coordinates
|
||||||
|
# (e.g. a WKT written "lat lon", or stray British National Grid eastings).
|
||||||
|
_LON_MIN, _LON_MAX = -9.0, 2.1
|
||||||
|
_LAT_MIN, _LAT_MAX = 49.0, 61.1
|
||||||
|
|
||||||
|
|
||||||
|
def _first(row: dict, *keys: str):
|
||||||
|
"""First present, non-None value among ``keys`` (case-sensitive)."""
|
||||||
|
for key in keys:
|
||||||
|
if key in row and row[key] is not None:
|
||||||
|
return row[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_str(value) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not math.isfinite(number): # NaN or +/-inf (int(inf) raises OverflowError)
|
||||||
|
return None
|
||||||
|
return int(number)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not math.isfinite(number): # NaN or +/-inf
|
||||||
|
return None
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_lonlat(lon: float, lat: float) -> bool:
|
||||||
|
return _LON_MIN <= lon <= _LON_MAX and _LAT_MIN <= lat <= _LAT_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def _point_lonlat(row: dict) -> tuple[float, float] | None:
|
||||||
|
"""Extract (lon, lat) from a brownfield row's WKT point or lat/lon columns."""
|
||||||
|
wkt = _clean_str(_first(row, "point"))
|
||||||
|
if wkt:
|
||||||
|
match = _POINT_RE.match(wkt)
|
||||||
|
if match:
|
||||||
|
lon, lat = float(match.group(1)), float(match.group(2))
|
||||||
|
if _valid_lonlat(lon, lat):
|
||||||
|
return lon, lat
|
||||||
|
lon = _to_float(_first(row, "longitude", "long", "lng"))
|
||||||
|
lat = _to_float(_first(row, "latitude", "lat"))
|
||||||
|
if lon is not None and lat is not None and _valid_lonlat(lon, lat):
|
||||||
|
return lon, lat
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _geometry_centroid(geom) -> tuple[float, float] | None:
|
||||||
|
"""Centroid (lon, lat) of a GeoJSON geometry already in WGS84."""
|
||||||
|
if not geom:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
centroid = shape(geom).centroid
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
if centroid.is_empty:
|
||||||
|
return None
|
||||||
|
lon, lat = float(centroid.x), float(centroid.y)
|
||||||
|
return (lon, lat) if _valid_lonlat(lon, lat) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_dwellings(min_d: int | None, max_d: int | None) -> bool:
|
||||||
|
return (min_d is not None and min_d > 0) or (max_d is not None and max_d > 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _brownfield_url(entity) -> str | None:
|
||||||
|
"""Canonical per-site page on the Planning Data platform.
|
||||||
|
|
||||||
|
The register's own ``site-plan-url`` is unreliable — many LPAs point every
|
||||||
|
row at a single generic register landing page — so we link to the stable,
|
||||||
|
always-per-site entity page instead.
|
||||||
|
"""
|
||||||
|
entity_id = _to_int(entity)
|
||||||
|
if entity_id is None:
|
||||||
|
return None
|
||||||
|
return f"https://www.planning.data.gov.uk/entity/{entity_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_residential(use: str | None, capacity: int | None) -> bool:
|
||||||
|
"""Keep Homes England sites that will deliver homes."""
|
||||||
|
if capacity is not None and capacity > 0:
|
||||||
|
return True
|
||||||
|
if use is None:
|
||||||
|
return False
|
||||||
|
lowered = use.lower()
|
||||||
|
return any(token in lowered for token in ("resid", "housing", "mixed", "dwelling"))
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_from_rows(rows: list[dict]) -> pl.DataFrame:
|
||||||
|
return pl.DataFrame(rows, schema=SCHEMA, orient="row")
|
||||||
|
|
||||||
|
|
||||||
|
def brownfield_to_frame(raw: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
"""Normalise the brownfield-land register to the unified development schema.
|
||||||
|
|
||||||
|
Drops sites that have left the register (a non-empty ``end-date`` means the
|
||||||
|
site was built out or withdrawn) and sites with no residential capacity, so
|
||||||
|
the layer shows pipeline housing rather than every parcel ever registered.
|
||||||
|
"""
|
||||||
|
rows: list[dict] = []
|
||||||
|
for row in raw.iter_rows(named=True):
|
||||||
|
lonlat = _point_lonlat(row)
|
||||||
|
if lonlat is None:
|
||||||
|
continue
|
||||||
|
if _clean_str(_first(row, "end-date", "end_date")):
|
||||||
|
continue
|
||||||
|
min_d = _to_int(_first(row, "minimum-net-dwellings", "minimum_net_dwellings"))
|
||||||
|
max_d = _to_int(_first(row, "maximum-net-dwellings", "maximum_net_dwellings"))
|
||||||
|
if not _has_dwellings(min_d, max_d):
|
||||||
|
continue
|
||||||
|
lon, lat = lonlat
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"source": "brownfield",
|
||||||
|
"name": _clean_str(_first(row, "site-address", "name")),
|
||||||
|
"min_dwellings": min_d,
|
||||||
|
"max_dwellings": max_d,
|
||||||
|
"planning_status": _clean_str(
|
||||||
|
_first(row, "planning-permission-status")
|
||||||
|
),
|
||||||
|
"permission_type": _clean_str(_first(row, "planning-permission-type")),
|
||||||
|
"permission_date": _clean_str(_first(row, "planning-permission-date")),
|
||||||
|
"hectares": _to_float(_first(row, "hectares")),
|
||||||
|
"local_authority": _clean_str(_first(row, "organisation")),
|
||||||
|
"url": _brownfield_url(_first(row, "entity")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _frame_from_rows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def homes_england_to_frame(features: list[dict]) -> pl.DataFrame:
|
||||||
|
"""Normalise Homes England Land Hub GeoJSON features to the unified schema."""
|
||||||
|
rows: list[dict] = []
|
||||||
|
for feature in features:
|
||||||
|
props = feature.get("properties") or {}
|
||||||
|
lonlat = _geometry_centroid(feature.get("geometry"))
|
||||||
|
if lonlat is None:
|
||||||
|
continue
|
||||||
|
capacity = _to_int(
|
||||||
|
_first(props, "Housing_Capacity", "HousingCapacity", "Units", "Homes")
|
||||||
|
)
|
||||||
|
use = _clean_str(_first(props, "Proposed_Use"))
|
||||||
|
if not _is_residential(use, capacity):
|
||||||
|
continue
|
||||||
|
lon, lat = lonlat
|
||||||
|
# The feed reports area in acres (`Gross_Area__Acres_`); convert to hectares
|
||||||
|
# so the column is consistent with the brownfield register.
|
||||||
|
acres = _to_float(_first(props, "Gross_Area__Acres_"))
|
||||||
|
hectares = round(acres * _ACRES_TO_HECTARES, 4) if acres is not None else None
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"source": "homes-england",
|
||||||
|
"name": _clean_str(_first(props, "Parcel_Name", "Site_Reference")),
|
||||||
|
"min_dwellings": None,
|
||||||
|
"max_dwellings": capacity,
|
||||||
|
"planning_status": _clean_str(_first(props, "Planning_Status")),
|
||||||
|
"permission_type": None,
|
||||||
|
"permission_date": None,
|
||||||
|
"hectares": hectares,
|
||||||
|
"local_authority": _clean_str(_first(props, "Local_Authority")),
|
||||||
|
# The Land Hub exposes no stable per-site page; leave the link unset
|
||||||
|
# rather than pointing at a generic landing page.
|
||||||
|
"url": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _frame_from_rows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_homes_england() -> list[dict]:
|
||||||
|
params = {
|
||||||
|
"where": "1=1",
|
||||||
|
"outFields": "*",
|
||||||
|
"outSR": "4326",
|
||||||
|
"f": "geojson",
|
||||||
|
"resultRecordCount": "2000",
|
||||||
|
}
|
||||||
|
response = httpx.get(
|
||||||
|
HOMES_ENGLAND_URL, params=params, follow_redirects=True, timeout=120
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if payload.get("exceededTransferLimit"):
|
||||||
|
print(
|
||||||
|
" WARNING: Homes England query hit the transfer limit; "
|
||||||
|
"only the first page of sites was fetched."
|
||||||
|
)
|
||||||
|
return payload.get("features", []) or []
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download brownfield + Homes England development sites"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as cache_dir:
|
||||||
|
brownfield_path = Path(cache_dir) / "brownfield-land.parquet"
|
||||||
|
print("Downloading MHCLG brownfield land register...")
|
||||||
|
download(BROWNFIELD_URL, brownfield_path)
|
||||||
|
brownfield = brownfield_to_frame(pl.read_parquet(brownfield_path))
|
||||||
|
print(f" Brownfield: {brownfield.height} residential sites")
|
||||||
|
|
||||||
|
print("Downloading Homes England Land Hub...")
|
||||||
|
homes_england = homes_england_to_frame(_fetch_homes_england())
|
||||||
|
print(f" Homes England: {homes_england.height} residential sites")
|
||||||
|
|
||||||
|
combined = pl.concat([brownfield, homes_england]).filter(
|
||||||
|
pl.col("lat").is_not_null() & pl.col("lon").is_not_null()
|
||||||
|
)
|
||||||
|
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
combined.write_parquet(args.output, compression="zstd")
|
||||||
|
size_kb = args.output.stat().st_size / 1024
|
||||||
|
print(f"Saved {combined.height} development sites to {args.output} ({size_kb:.0f} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
177
pipeline/download/education.py
Normal file
177
pipeline/download/education.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""Download Census 2021 highest level of qualification (TS067) by LSOA.
|
||||||
|
|
||||||
|
Downloads the 8-category "highest level of qualification" breakdown (TS067,
|
||||||
|
classification C2021_HIQUAL_8) from the NOMIS API at LSOA 2021 granularity and
|
||||||
|
emits one row per LSOA with the percentage of usual residents aged 16+ in each
|
||||||
|
qualification band. The bands sum to 100%, so downstream they render as a
|
||||||
|
composition/ratio (like the ethnicity and political-vote-share stacked bars)
|
||||||
|
rather than a single headline number.
|
||||||
|
|
||||||
|
We give the ONS bands colloquial labels (the census's "Level 1/2/3/4+" jargon
|
||||||
|
means little to a homebuyer): No qualifications / Some GCSEs / Good GCSEs /
|
||||||
|
Apprenticeship / A-levels / Degree or higher / Other qualifications. NOTE the
|
||||||
|
census does NOT split undergraduate from postgraduate — "Level 4 and above" is a
|
||||||
|
single bucket ("Degree or higher").
|
||||||
|
|
||||||
|
The join key downstream (merge.py) is `lsoa21`, the same key used for ethnicity,
|
||||||
|
median age, and IoD.
|
||||||
|
|
||||||
|
Source: NOMIS (ONS Census 2021 — TS067 dataset, NM_2084_1)
|
||||||
|
License: Open Government Licence v3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.utils import ENGLAND_LSOA_COUNT_2021, download_nomis_csv
|
||||||
|
|
||||||
|
pl.Config.set_tbl_cols(-1)
|
||||||
|
|
||||||
|
# NOMIS API: Census 2021 TS067 (highest level of qualification) by LSOA 2021
|
||||||
|
# (TYPE151). c2021_hiqual_8=1..7 selects the 7 substantive bands (excluding
|
||||||
|
# 0 = Total, which we re-derive by summing). measures=20100 selects the count.
|
||||||
|
BASE_URL = (
|
||||||
|
"https://www.nomisweb.co.uk/api/v01/dataset/NM_2084_1.data.csv"
|
||||||
|
"?date=latest"
|
||||||
|
"&geography=TYPE151"
|
||||||
|
"&c2021_hiqual_8=1,2,3,4,5,6,7"
|
||||||
|
"&measures=20100"
|
||||||
|
"&select=GEOGRAPHY_CODE,C2021_HIQUAL_8_NAME,OBS_VALUE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map each canonical NOMIS C2021_HIQUAL_8 band to our colloquial output bucket.
|
||||||
|
# 1:1 mapping (no folding) — keyed on the exact NOMIS label so a relabelled or
|
||||||
|
# missing band fails loudly in validation instead of silently dropping people.
|
||||||
|
BAND_MAP = {
|
||||||
|
"No qualifications": "No qualifications",
|
||||||
|
"Level 1 and entry level qualifications": "Some GCSEs",
|
||||||
|
"Level 2 qualifications": "Good GCSEs",
|
||||||
|
"Apprenticeship": "Apprenticeship",
|
||||||
|
"Level 3 qualifications": "A-levels",
|
||||||
|
"Level 4 qualifications or above": "Degree or higher",
|
||||||
|
"Other qualifications": "Other qualifications",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Output buckets in ascending-attainment order, so the stacked composition reads
|
||||||
|
# left-to-right from least to most qualified.
|
||||||
|
OUTPUT_BUCKETS = [
|
||||||
|
"No qualifications",
|
||||||
|
"Some GCSEs",
|
||||||
|
"Good GCSEs",
|
||||||
|
"Apprenticeship",
|
||||||
|
"A-levels",
|
||||||
|
"Degree or higher",
|
||||||
|
"Other qualifications",
|
||||||
|
]
|
||||||
|
assert set(BAND_MAP.values()) == set(OUTPUT_BUCKETS), (
|
||||||
|
"BAND_MAP values must be exactly the OUTPUT_BUCKETS"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _qualification_percentages(df: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
"""Fold the 7 NOMIS bands into percentage buckets per LSOA (summing to 100).
|
||||||
|
|
||||||
|
`df` is the long-format NOMIS download with columns GEOGRAPHY_CODE,
|
||||||
|
C2021_HIQUAL_8_NAME (the band label) and OBS_VALUE (a count). A missing,
|
||||||
|
extra, or relabelled band would silently change the denominator (the sum over
|
||||||
|
bands) so we validate the band set against BAND_MAP first and fail otherwise.
|
||||||
|
Returns one row per LSOA with `lsoa21` and `% <bucket>` for each bucket.
|
||||||
|
"""
|
||||||
|
found = set(df["C2021_HIQUAL_8_NAME"].unique().to_list())
|
||||||
|
expected = set(BAND_MAP)
|
||||||
|
if found != expected:
|
||||||
|
missing = sorted(expected - found)
|
||||||
|
unexpected = sorted(found - expected)
|
||||||
|
raise ValueError(
|
||||||
|
"Census qualification bands do not match the expected NOMIS "
|
||||||
|
"TS067 C2021_HIQUAL_8 set.\n"
|
||||||
|
f" expected {len(expected)} bands, found {len(found)}\n"
|
||||||
|
f" missing: {missing}\n"
|
||||||
|
f" unexpected: {unexpected}\n"
|
||||||
|
"Refusing to compute percentages against an unrecognised breakdown."
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped = (
|
||||||
|
df.with_columns(
|
||||||
|
pl.col("C2021_HIQUAL_8_NAME").replace_strict(BAND_MAP).alias("bucket"),
|
||||||
|
pl.col("OBS_VALUE").cast(pl.Float64, strict=False).alias("_count"),
|
||||||
|
)
|
||||||
|
.group_by("GEOGRAPHY_CODE", "bucket")
|
||||||
|
.agg(pl.col("_count").sum())
|
||||||
|
)
|
||||||
|
wide = grouped.pivot(on="bucket", index="GEOGRAPHY_CODE", values="_count").rename(
|
||||||
|
{"GEOGRAPHY_CODE": "lsoa21"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# A bucket with no people in an LSOA is absent from the long rows, so the
|
||||||
|
# pivot leaves a null; treat it as 0 before normalising.
|
||||||
|
wide = wide.with_columns(pl.col(OUTPUT_BUCKETS).fill_null(0.0))
|
||||||
|
|
||||||
|
# Normalize so each row sums to exactly 100%, then round with the
|
||||||
|
# largest-remainder method to preserve the sum (independent rounding of 7
|
||||||
|
# values can drift +/-0.3).
|
||||||
|
row_total = sum(pl.col(c) for c in OUTPUT_BUCKETS)
|
||||||
|
wide = wide.with_columns(
|
||||||
|
[(pl.col(c) / row_total * 100.0).alias(c) for c in OUTPUT_BUCKETS]
|
||||||
|
)
|
||||||
|
wide = wide.with_columns([pl.col(c).round(1).alias(c) for c in OUTPUT_BUCKETS])
|
||||||
|
rounded_sum = sum(pl.col(c) for c in OUTPUT_BUCKETS)
|
||||||
|
residual = (100.0 - rounded_sum).round(1)
|
||||||
|
largest_col = pl.concat_list(OUTPUT_BUCKETS).list.arg_max()
|
||||||
|
wide = wide.with_columns(
|
||||||
|
[
|
||||||
|
pl.when(largest_col == i)
|
||||||
|
.then(pl.col(c) + residual)
|
||||||
|
.otherwise(pl.col(c))
|
||||||
|
.alias(c)
|
||||||
|
for i, c in enumerate(OUTPUT_BUCKETS)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
rename_map = {col: f"% {col}" for col in OUTPUT_BUCKETS}
|
||||||
|
return wide.rename(rename_map).select(
|
||||||
|
"lsoa21", *[f"% {c}" for c in OUTPUT_BUCKETS]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_and_convert(output_path: Path) -> None:
|
||||||
|
print("Downloading Census 2021 highest qualification (TS067) by LSOA from NOMIS...")
|
||||||
|
df = download_nomis_csv(BASE_URL)
|
||||||
|
print(f"Total rows: {df.height}")
|
||||||
|
|
||||||
|
# Filter to England only (E-prefixed LSOA codes); the merge joins on the
|
||||||
|
# English postcode universe and the LSOA coverage check is England-wide.
|
||||||
|
df = df.filter(pl.col("GEOGRAPHY_CODE").str.starts_with("E"))
|
||||||
|
|
||||||
|
wide = _qualification_percentages(df)
|
||||||
|
|
||||||
|
print(f"England LSOAs: {wide.height}")
|
||||||
|
if wide.height != ENGLAND_LSOA_COUNT_2021:
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected {ENGLAND_LSOA_COUNT_2021} England LSOAs, "
|
||||||
|
f"got {wide.height}: truncated NOMIS download?"
|
||||||
|
)
|
||||||
|
print(f"Columns: {wide.columns}")
|
||||||
|
deg = wide["% Degree or higher"]
|
||||||
|
print(f"% Degree or higher: {deg.min()}% - {deg.max()}% (mean {deg.mean():.1f}%)")
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
wide.write_parquet(output_path, compression="zstd")
|
||||||
|
print(f"Saved to {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download Census 2021 highest level of qualification (TS067) by LSOA"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
download_and_convert(args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
179
pipeline/download/tenure.py
Normal file
179
pipeline/download/tenure.py
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""Download Census 2021 household tenure (TS054) by LSOA.
|
||||||
|
|
||||||
|
Downloads the household-tenure breakdown (TS054, classification C2021_TENURE_9)
|
||||||
|
from the NOMIS API at LSOA 2021 granularity and folds the 8 detailed leaf
|
||||||
|
categories into our 3 output buckets — Owner occupied / Social rent /
|
||||||
|
Private rent — emitting one row per LSOA with the percentage of households in
|
||||||
|
each. The three buckets sum to 100%, so downstream they render as a
|
||||||
|
composition/ratio (like the ethnicity and qualifications stacked bars) AND each
|
||||||
|
percentage is independently filterable.
|
||||||
|
|
||||||
|
The 3-bucket grouping follows ONS's standard tenure summary:
|
||||||
|
* Owner occupied = Owns outright + Owns with a mortgage/loan + Shared ownership
|
||||||
|
* Social rent = Rents from council/LA + Other social rented
|
||||||
|
* Private rent = Private landlord/letting agency + Other private + Lives rent free
|
||||||
|
(Shared ownership is part-owned and rolls into owner-occupied; "lives rent free"
|
||||||
|
rolls into private rent, mirroring ONS's "Private rented or lives rent free".)
|
||||||
|
|
||||||
|
NOTE this table counts HOUSEHOLDS (not usual residents). The join key downstream
|
||||||
|
(merge.py) is `lsoa21`, the same key used for ethnicity, qualifications,
|
||||||
|
median age, and IoD.
|
||||||
|
|
||||||
|
Source: NOMIS (ONS Census 2021 — TS054 dataset, NM_2072_1)
|
||||||
|
License: Open Government Licence v3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.utils import ENGLAND_LSOA_COUNT_2021, download_nomis_csv
|
||||||
|
|
||||||
|
pl.Config.set_tbl_cols(-1)
|
||||||
|
|
||||||
|
# NOMIS API: Census 2021 TS054 (household tenure) by LSOA 2021 (TYPE151).
|
||||||
|
# c2021_tenure_9=1..8 selects the 8 substantive leaf categories (excluding the
|
||||||
|
# aggregates 0/1001-1004/9996/9997, whose components we sum ourselves).
|
||||||
|
# measures=20100 selects the count.
|
||||||
|
BASE_URL = (
|
||||||
|
"https://www.nomisweb.co.uk/api/v01/dataset/NM_2072_1.data.csv"
|
||||||
|
"?date=latest"
|
||||||
|
"&geography=TYPE151"
|
||||||
|
"&c2021_tenure_9=1,2,3,4,5,6,7,8"
|
||||||
|
"&measures=20100"
|
||||||
|
"&select=GEOGRAPHY_CODE,C2021_TENURE_9_NAME,OBS_VALUE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map each canonical NOMIS C2021_TENURE_9 leaf to our 3 output buckets. Keyed on
|
||||||
|
# the exact NOMIS label so a relabelled or missing leaf fails loudly in
|
||||||
|
# validation instead of silently dropping households from the denominator.
|
||||||
|
TENURE_MAP = {
|
||||||
|
"Owned: Owns outright": "Owner occupied",
|
||||||
|
"Owned: Owns with a mortgage or loan": "Owner occupied",
|
||||||
|
"Shared ownership: Shared ownership": "Owner occupied",
|
||||||
|
"Social rented: Rents from council or Local Authority": "Social rent",
|
||||||
|
"Social rented: Other social rented": "Social rent",
|
||||||
|
"Private rented: Private landlord or letting agency": "Private rent",
|
||||||
|
"Private rented: Other private rented": "Private rent",
|
||||||
|
"Lives rent free": "Private rent",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Output buckets in a fixed order (own -> social rent -> private rent), so the
|
||||||
|
# stacked composition reads left-to-right and the largest-remainder rounding
|
||||||
|
# below is deterministic regardless of pivot column ordering.
|
||||||
|
OUTPUT_BUCKETS = ["Owner occupied", "Social rent", "Private rent"]
|
||||||
|
assert set(TENURE_MAP.values()) == set(OUTPUT_BUCKETS), (
|
||||||
|
"TENURE_MAP values must be exactly the OUTPUT_BUCKETS"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenure_percentages(df: pl.DataFrame) -> pl.DataFrame:
|
||||||
|
"""Fold the 8 NOMIS tenure leaves into 3-bucket percentages per LSOA.
|
||||||
|
|
||||||
|
`df` is the long-format NOMIS download with columns GEOGRAPHY_CODE,
|
||||||
|
C2021_TENURE_9_NAME (the leaf label) and OBS_VALUE (a household count). A
|
||||||
|
missing, extra, or relabelled leaf would silently change the denominator (the
|
||||||
|
sum over leaves) so we validate the leaf set against TENURE_MAP first and fail
|
||||||
|
otherwise. Returns one row per LSOA with `lsoa21` and `% <bucket>` for each
|
||||||
|
bucket.
|
||||||
|
"""
|
||||||
|
found = set(df["C2021_TENURE_9_NAME"].unique().to_list())
|
||||||
|
expected = set(TENURE_MAP)
|
||||||
|
if found != expected:
|
||||||
|
missing = sorted(expected - found)
|
||||||
|
unexpected = sorted(found - expected)
|
||||||
|
raise ValueError(
|
||||||
|
"Census tenure categories do not match the expected NOMIS "
|
||||||
|
"TS054 C2021_TENURE_9 leaf set.\n"
|
||||||
|
f" expected {len(expected)} categories, found {len(found)}\n"
|
||||||
|
f" missing: {missing}\n"
|
||||||
|
f" unexpected: {unexpected}\n"
|
||||||
|
"Refusing to compute percentages against an unrecognised breakdown."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map each leaf to its output bucket and sum counts per (LSOA, bucket).
|
||||||
|
# Summing counts (not rounded percentages) keeps the denominator exact.
|
||||||
|
grouped = (
|
||||||
|
df.with_columns(
|
||||||
|
pl.col("C2021_TENURE_9_NAME").replace_strict(TENURE_MAP).alias("bucket"),
|
||||||
|
pl.col("OBS_VALUE").cast(pl.Float64, strict=False).alias("_count"),
|
||||||
|
)
|
||||||
|
.group_by("GEOGRAPHY_CODE", "bucket")
|
||||||
|
.agg(pl.col("_count").sum())
|
||||||
|
)
|
||||||
|
wide = grouped.pivot(on="bucket", index="GEOGRAPHY_CODE", values="_count").rename(
|
||||||
|
{"GEOGRAPHY_CODE": "lsoa21"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# A bucket with no households in an LSOA is absent from the long rows, so the
|
||||||
|
# pivot leaves a null; treat it as 0 before normalising.
|
||||||
|
wide = wide.with_columns(pl.col(OUTPUT_BUCKETS).fill_null(0.0))
|
||||||
|
|
||||||
|
# Normalize so each row sums to exactly 100%, then round with the
|
||||||
|
# largest-remainder method to preserve the sum (independent rounding of 3
|
||||||
|
# values can drift +/-0.1).
|
||||||
|
row_total = sum(pl.col(c) for c in OUTPUT_BUCKETS)
|
||||||
|
wide = wide.with_columns(
|
||||||
|
[(pl.col(c) / row_total * 100.0).alias(c) for c in OUTPUT_BUCKETS]
|
||||||
|
)
|
||||||
|
wide = wide.with_columns([pl.col(c).round(1).alias(c) for c in OUTPUT_BUCKETS])
|
||||||
|
rounded_sum = sum(pl.col(c) for c in OUTPUT_BUCKETS)
|
||||||
|
residual = (100.0 - rounded_sum).round(1)
|
||||||
|
largest_col = pl.concat_list(OUTPUT_BUCKETS).list.arg_max()
|
||||||
|
wide = wide.with_columns(
|
||||||
|
[
|
||||||
|
pl.when(largest_col == i)
|
||||||
|
.then(pl.col(c) + residual)
|
||||||
|
.otherwise(pl.col(c))
|
||||||
|
.alias(c)
|
||||||
|
for i, c in enumerate(OUTPUT_BUCKETS)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
rename_map = {col: f"% {col}" for col in OUTPUT_BUCKETS}
|
||||||
|
return wide.rename(rename_map).select(
|
||||||
|
"lsoa21", *[f"% {c}" for c in OUTPUT_BUCKETS]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_and_convert(output_path: Path) -> None:
|
||||||
|
print("Downloading Census 2021 household tenure (TS054) by LSOA from NOMIS...")
|
||||||
|
df = download_nomis_csv(BASE_URL)
|
||||||
|
print(f"Total rows: {df.height}")
|
||||||
|
|
||||||
|
# Filter to England only (E-prefixed LSOA codes); the merge joins on the
|
||||||
|
# English postcode universe and the LSOA coverage check is England-wide.
|
||||||
|
df = df.filter(pl.col("GEOGRAPHY_CODE").str.starts_with("E"))
|
||||||
|
|
||||||
|
wide = _tenure_percentages(df)
|
||||||
|
|
||||||
|
print(f"England LSOAs: {wide.height}")
|
||||||
|
if wide.height != ENGLAND_LSOA_COUNT_2021:
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected {ENGLAND_LSOA_COUNT_2021} England LSOAs, "
|
||||||
|
f"got {wide.height}: truncated NOMIS download?"
|
||||||
|
)
|
||||||
|
print(f"Columns: {wide.columns}")
|
||||||
|
for bucket in OUTPUT_BUCKETS:
|
||||||
|
col = wide[f"% {bucket}"]
|
||||||
|
print(f"% {bucket}: {col.min()}% - {col.max()}% (mean {col.mean():.1f}%)")
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
wide.write_parquet(output_path, compression="zstd")
|
||||||
|
print(f"Saved to {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download Census 2021 household tenure (TS054) by LSOA"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
download_and_convert(args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
165
pipeline/download/test_development_sites.py
Normal file
165
pipeline/download/test_development_sites.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.download.development_sites import (
|
||||||
|
SCHEMA,
|
||||||
|
_has_dwellings,
|
||||||
|
_is_residential,
|
||||||
|
_point_lonlat,
|
||||||
|
_valid_lonlat,
|
||||||
|
brownfield_to_frame,
|
||||||
|
homes_england_to_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_lonlat_parses_wkt():
|
||||||
|
assert _point_lonlat({"point": "POINT(-0.1278 51.5074)"}) == (-0.1278, 51.5074)
|
||||||
|
# Tolerates extra spacing and uppercase.
|
||||||
|
assert _point_lonlat({"point": "POINT ( -1.5 53.8 )"}) == (-1.5, 53.8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_lonlat_falls_back_to_columns():
|
||||||
|
assert _point_lonlat({"longitude": "-2.0", "latitude": "52.4"}) == (-2.0, 52.4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_lonlat_rejects_out_of_range():
|
||||||
|
# A WKT written "lat lon" would place lat (51.5) into the lon slot -> rejected.
|
||||||
|
assert _point_lonlat({"point": "POINT(51.5 -0.12)"}) is None
|
||||||
|
assert _point_lonlat({"point": "garbage"}) is None
|
||||||
|
assert _point_lonlat({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_lonlat_bounds():
|
||||||
|
assert _valid_lonlat(-0.1, 51.5)
|
||||||
|
assert not _valid_lonlat(2.5, 51.5) # lon east of GB
|
||||||
|
assert not _valid_lonlat(-0.1, 40.0) # lat south of GB
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_dwellings():
|
||||||
|
assert _has_dwellings(0, 5)
|
||||||
|
assert _has_dwellings(3, None)
|
||||||
|
assert not _has_dwellings(None, None)
|
||||||
|
assert not _has_dwellings(0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_residential():
|
||||||
|
assert _is_residential("Residential", None)
|
||||||
|
assert _is_residential("Mixed Use", None)
|
||||||
|
assert _is_residential(None, 12)
|
||||||
|
assert not _is_residential("Employment", None)
|
||||||
|
assert not _is_residential(None, None)
|
||||||
|
assert not _is_residential("Industrial", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_brownfield_to_frame_normalises_and_filters():
|
||||||
|
raw = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"point": [
|
||||||
|
"POINT(-0.1 51.5)", # kept
|
||||||
|
"POINT(-1.2 52.6)", # dropped: no dwellings
|
||||||
|
"POINT(-2.0 53.0)", # dropped: end-date set (built out)
|
||||||
|
"POINT(-3.0 54.0)", # kept
|
||||||
|
],
|
||||||
|
"site-address": ["10 High St", "Old Yard", "Mill Site", "Dock Road"],
|
||||||
|
"minimum-net-dwellings": ["5", "0", "10", None],
|
||||||
|
"maximum-net-dwellings": ["15", "0", "10", "40"],
|
||||||
|
"planning-permission-status": [
|
||||||
|
"permissioned",
|
||||||
|
"not-permissioned",
|
||||||
|
"permissioned",
|
||||||
|
"pending-decision",
|
||||||
|
],
|
||||||
|
"planning-permission-type": ["full", "", "outline", None],
|
||||||
|
"planning-permission-date": ["2023-01-01", None, "2022-05-01", None],
|
||||||
|
"hectares": ["0.5", "0.2", "1.0", "2.0"],
|
||||||
|
"end-date": [None, None, "2021-06-01", ""],
|
||||||
|
"entity": [1700000, 1700001, 1700002, 1700003],
|
||||||
|
"organisation": [
|
||||||
|
"London Borough of Lewisham",
|
||||||
|
"Org B",
|
||||||
|
"Org C",
|
||||||
|
"London Borough of Newham",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
frame = brownfield_to_frame(raw)
|
||||||
|
|
||||||
|
assert frame.columns == list(SCHEMA.keys())
|
||||||
|
assert frame.schema["min_dwellings"] == pl.Int32
|
||||||
|
assert frame.schema["lat"] == pl.Float64
|
||||||
|
# Only the first and last rows survive (dwellings present, not built out).
|
||||||
|
assert frame.height == 2
|
||||||
|
assert frame["source"].to_list() == ["brownfield", "brownfield"]
|
||||||
|
first = frame.row(0, named=True)
|
||||||
|
assert first["lat"] == 51.5
|
||||||
|
assert first["lon"] == -0.1
|
||||||
|
assert first["min_dwellings"] == 5
|
||||||
|
assert first["max_dwellings"] == 15
|
||||||
|
assert first["name"] == "10 High St"
|
||||||
|
assert first["local_authority"] == "London Borough of Lewisham"
|
||||||
|
# Per-site Planning Data entity page, not the unreliable site-plan-url.
|
||||||
|
assert first["url"] == "https://www.planning.data.gov.uk/entity/1700000"
|
||||||
|
last = frame.row(1, named=True)
|
||||||
|
assert last["min_dwellings"] is None
|
||||||
|
assert last["max_dwellings"] == 40
|
||||||
|
assert last["url"] == "https://www.planning.data.gov.uk/entity/1700003"
|
||||||
|
|
||||||
|
|
||||||
|
def test_brownfield_empty_input_yields_typed_empty_frame():
|
||||||
|
raw = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"point": [],
|
||||||
|
"minimum-net-dwellings": [],
|
||||||
|
"maximum-net-dwellings": [],
|
||||||
|
"end-date": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
frame = brownfield_to_frame(raw)
|
||||||
|
assert frame.height == 0
|
||||||
|
assert frame.columns == list(SCHEMA.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def test_homes_england_to_frame_uses_centroid_and_capacity():
|
||||||
|
features = [
|
||||||
|
{
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-0.2, 51.4], [0.0, 51.4], [0.0, 51.6], [-0.2, 51.6], [-0.2, 51.4]]],
|
||||||
|
},
|
||||||
|
# Real Land Hub field names (Housing_Capacity arrives as a float string).
|
||||||
|
"properties": {
|
||||||
|
"Parcel_Name": "South West Of Old Park Mound, Telford, TF3 4AW",
|
||||||
|
"Proposed_Use": "Residential",
|
||||||
|
"Housing_Capacity": "67.0",
|
||||||
|
"Planning_Status": "Detailed Application Submitted",
|
||||||
|
"Local_Authority": "Telford and Wrekin",
|
||||||
|
"Gross_Area__Acres_": "2.8602",
|
||||||
|
"Site_Reference": "1494",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Non-residential, no capacity -> dropped.
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-1.0, 52.0], [-0.9, 52.0], [-0.9, 52.1], [-1.0, 52.1], [-1.0, 52.0]]],
|
||||||
|
},
|
||||||
|
"properties": {"Proposed_Use": "Employment"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
frame = homes_england_to_frame(features)
|
||||||
|
|
||||||
|
assert frame.height == 1
|
||||||
|
row = frame.row(0, named=True)
|
||||||
|
assert row["source"] == "homes-england"
|
||||||
|
# Real name, not the bare Site_Reference fallback.
|
||||||
|
assert row["name"] == "South West Of Old Park Mound, Telford, TF3 4AW"
|
||||||
|
assert row["max_dwellings"] == 67
|
||||||
|
assert row["min_dwellings"] is None
|
||||||
|
assert row["local_authority"] == "Telford and Wrekin"
|
||||||
|
# Acres converted to hectares: 2.8602 * 0.404686 ≈ 1.1575.
|
||||||
|
assert row["hectares"] == 1.1575
|
||||||
|
# No stable per-site URL on the Land Hub.
|
||||||
|
assert row["url"] is None
|
||||||
|
# Centroid of the unit-ish box.
|
||||||
|
assert abs(row["lon"] - (-0.1)) < 1e-6
|
||||||
|
assert abs(row["lat"] - 51.5) < 1e-6
|
||||||
|
assert frame.columns == list(SCHEMA.keys())
|
||||||
107
pipeline/download/test_education.py
Normal file
107
pipeline/download/test_education.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pipeline.download.education import (
|
||||||
|
BAND_MAP,
|
||||||
|
OUTPUT_BUCKETS,
|
||||||
|
_qualification_percentages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _long_rows(geo: str, counts: dict[str, int]) -> list[dict]:
|
||||||
|
"""Build NOMIS-shaped long rows for one LSOA from {band_label: count}.
|
||||||
|
|
||||||
|
NOMIS emits a 0-count row when an LSOA has none in a band, so bands not
|
||||||
|
given default to 0 to mirror that.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"GEOGRAPHY_CODE": geo,
|
||||||
|
"C2021_HIQUAL_8_NAME": label,
|
||||||
|
"OBS_VALUE": counts.get(label, 0),
|
||||||
|
}
|
||||||
|
for label in BAND_MAP
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_qualification_percentages_keyed_by_lsoa_with_seven_buckets():
|
||||||
|
df = pl.DataFrame(
|
||||||
|
_long_rows(
|
||||||
|
"E01000001",
|
||||||
|
{
|
||||||
|
"No qualifications": 10,
|
||||||
|
"Level 2 qualifications": 20,
|
||||||
|
"Level 3 qualifications": 20,
|
||||||
|
"Level 4 qualifications or above": 50,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _qualification_percentages(df)
|
||||||
|
|
||||||
|
assert result.columns[0] == "lsoa21"
|
||||||
|
assert set(result.columns) == {"lsoa21", *(f"% {b}" for b in OUTPUT_BUCKETS)}
|
||||||
|
row = result.filter(pl.col("lsoa21") == "E01000001").to_dicts()[0]
|
||||||
|
assert row["% Degree or higher"] == 50.0
|
||||||
|
assert row["% No qualifications"] == 10.0
|
||||||
|
assert row["% Good GCSEs"] == 20.0
|
||||||
|
assert row["% A-levels"] == 20.0
|
||||||
|
# Percentages always sum to exactly 100 (largest-remainder rounding).
|
||||||
|
assert round(sum(row[f"% {b}"] for b in OUTPUT_BUCKETS), 1) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_qualification_colloquial_band_mapping():
|
||||||
|
"""ONS jargon bands fold into the colloquial buckets, not 'Level N' names."""
|
||||||
|
df = pl.DataFrame(
|
||||||
|
_long_rows(
|
||||||
|
"E01000002",
|
||||||
|
{
|
||||||
|
"Level 1 and entry level qualifications": 40,
|
||||||
|
"Apprenticeship": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
row = _qualification_percentages(df).to_dicts()[0]
|
||||||
|
assert row["% Some GCSEs"] == 40.0
|
||||||
|
assert row["% Apprenticeship"] == 60.0
|
||||||
|
# No raw ONS "Level N" column names leak through.
|
||||||
|
assert not any("Level" in c for c in row)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qualification_percentages_independent_per_lsoa():
|
||||||
|
df = pl.concat(
|
||||||
|
[
|
||||||
|
pl.DataFrame(_long_rows("E01000010", {"Level 4 qualifications or above": 100})),
|
||||||
|
pl.DataFrame(_long_rows("E01000011", {"No qualifications": 100})),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _qualification_percentages(df).sort("lsoa21")
|
||||||
|
assert result["% Degree or higher"].to_list() == [100.0, 0.0]
|
||||||
|
assert result["% No qualifications"].to_list() == [0.0, 100.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_qualification_percentages_rejects_unexpected_band():
|
||||||
|
rows = _long_rows("E01000003", {"Level 4 qualifications or above": 10})
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"GEOGRAPHY_CODE": "E01000003",
|
||||||
|
"C2021_HIQUAL_8_NAME": "Level 5 brand new census band",
|
||||||
|
"OBS_VALUE": 5,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="do not match the expected"):
|
||||||
|
_qualification_percentages(pl.DataFrame(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def test_qualification_percentages_rejects_missing_band():
|
||||||
|
rows = [
|
||||||
|
r
|
||||||
|
for r in _long_rows("E01000004", {"Level 4 qualifications or above": 10})
|
||||||
|
if r["C2021_HIQUAL_8_NAME"] != "Apprenticeship"
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="missing"):
|
||||||
|
_qualification_percentages(pl.DataFrame(rows))
|
||||||
121
pipeline/download/test_tenure.py
Normal file
121
pipeline/download/test_tenure.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pipeline.download.tenure import OUTPUT_BUCKETS, TENURE_MAP, _tenure_percentages
|
||||||
|
|
||||||
|
|
||||||
|
def _long_rows(geo: str, counts: dict[str, int]) -> list[dict]:
|
||||||
|
"""Build NOMIS-shaped long rows for one LSOA from {leaf_label: count}.
|
||||||
|
|
||||||
|
Every one of the 8 leaf categories must be present in the download (NOMIS
|
||||||
|
emits a 0-count row when an LSOA has none), so categories not given default
|
||||||
|
to 0 to mirror that.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"GEOGRAPHY_CODE": geo,
|
||||||
|
"C2021_TENURE_9_NAME": label,
|
||||||
|
"OBS_VALUE": counts.get(label, 0),
|
||||||
|
}
|
||||||
|
for label in TENURE_MAP
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenure_percentages_keyed_by_lsoa_with_three_buckets():
|
||||||
|
df = pl.DataFrame(
|
||||||
|
_long_rows(
|
||||||
|
"E01000001",
|
||||||
|
{
|
||||||
|
"Owned: Owns outright": 40,
|
||||||
|
"Owned: Owns with a mortgage or loan": 15,
|
||||||
|
"Shared ownership: Shared ownership": 5,
|
||||||
|
"Social rented: Rents from council or Local Authority": 15,
|
||||||
|
"Social rented: Other social rented": 5,
|
||||||
|
"Private rented: Private landlord or letting agency": 18,
|
||||||
|
"Private rented: Other private rented": 1,
|
||||||
|
"Lives rent free": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _tenure_percentages(df)
|
||||||
|
|
||||||
|
assert result.columns[0] == "lsoa21"
|
||||||
|
assert set(result.columns) == {"lsoa21", *(f"% {b}" for b in OUTPUT_BUCKETS)}
|
||||||
|
row = result.filter(pl.col("lsoa21") == "E01000001").to_dicts()[0]
|
||||||
|
# Owner occupied = outright + mortgage + shared ownership = 60.
|
||||||
|
assert row["% Owner occupied"] == 60.0
|
||||||
|
# Social rent = council + other social = 20.
|
||||||
|
assert row["% Social rent"] == 20.0
|
||||||
|
# Private rent = private landlord + other private + lives rent free = 20.
|
||||||
|
assert row["% Private rent"] == 20.0
|
||||||
|
# Percentages always sum to exactly 100 (largest-remainder rounding).
|
||||||
|
assert round(sum(row[f"% {b}"] for b in OUTPUT_BUCKETS), 1) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenure_shared_ownership_rolls_into_owner_occupied():
|
||||||
|
"""Shared ownership is part-owned, so it counts as owner-occupied, not rent."""
|
||||||
|
df = pl.DataFrame(_long_rows("E01000002", {"Shared ownership: Shared ownership": 100}))
|
||||||
|
|
||||||
|
row = _tenure_percentages(df).to_dicts()[0]
|
||||||
|
|
||||||
|
assert row["% Owner occupied"] == 100.0
|
||||||
|
assert row["% Social rent"] == 0.0
|
||||||
|
assert row["% Private rent"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenure_lives_rent_free_rolls_into_private_rent():
|
||||||
|
"""'Lives rent free' folds into private rent (ONS 'private rented or rent free')."""
|
||||||
|
df = pl.DataFrame(_long_rows("E01000003", {"Lives rent free": 100}))
|
||||||
|
|
||||||
|
row = _tenure_percentages(df).to_dicts()[0]
|
||||||
|
|
||||||
|
assert row["% Private rent"] == 100.0
|
||||||
|
assert row["% Owner occupied"] == 0.0
|
||||||
|
assert row["% Social rent"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenure_percentages_independent_per_lsoa():
|
||||||
|
"""Two LSOAs get independent profiles — the LSOA granularity is the point."""
|
||||||
|
df = pl.concat(
|
||||||
|
[
|
||||||
|
pl.DataFrame(_long_rows("E01000010", {"Owned: Owns outright": 100})),
|
||||||
|
pl.DataFrame(
|
||||||
|
_long_rows(
|
||||||
|
"E01000011",
|
||||||
|
{"Social rented: Rents from council or Local Authority": 100},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _tenure_percentages(df).sort("lsoa21")
|
||||||
|
|
||||||
|
assert result["% Owner occupied"].to_list() == [100.0, 0.0]
|
||||||
|
assert result["% Social rent"].to_list() == [0.0, 100.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenure_percentages_rejects_unexpected_category():
|
||||||
|
rows = _long_rows("E01000004", {"Owned: Owns outright": 10})
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"GEOGRAPHY_CODE": "E01000004",
|
||||||
|
"C2021_TENURE_9_NAME": "A Brand New Census Tenure Category",
|
||||||
|
"OBS_VALUE": 5,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="do not match the expected"):
|
||||||
|
_tenure_percentages(pl.DataFrame(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenure_percentages_rejects_missing_category():
|
||||||
|
# Drop one leaf entirely: its households would vanish from the denominator.
|
||||||
|
rows = [
|
||||||
|
r
|
||||||
|
for r in _long_rows("E01000005", {"Owned: Owns outright": 10})
|
||||||
|
if r["C2021_TENURE_9_NAME"] != "Lives rent free"
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="missing"):
|
||||||
|
_tenure_percentages(pl.DataFrame(rows))
|
||||||
|
|
@ -53,12 +53,13 @@ def _write_geojsonseq(csvs: list[Path], output_path: Path) -> tuple[int, int]:
|
||||||
to a shared "map point" anchor, so many incidents land on the exact same
|
to a shared "map point" anchor, so many incidents land on the exact same
|
||||||
coordinate. Collapsing them into one feature carrying ``count`` (the number
|
coordinate. Collapsing them into one feature carrying ``count`` (the number
|
||||||
of incidents) keeps the per-crime-type and per-month filters intact while
|
of incidents) keeps the per-crime-type and per-month filters intact while
|
||||||
turning each hotspot into a single high-weight point. That matters because
|
turning each hotspot into a single high-weight point. That matters for the
|
||||||
tippecanoe's ``--drop-densest-as-needed`` thins *feature density*, not
|
heatmap weight: each anchor becomes one high-weight point, and tippecanoe's
|
||||||
weight: with one feature per row the busiest streets were silently deleted;
|
``--cluster-densest-as-needed`` (with ``--accumulate-attribute=count:sum``)
|
||||||
with one weighted feature per anchor those hotspots survive and the dropped
|
merges any still-too-dense low-zoom features by *summing* their counts
|
||||||
detail is only redundant duplicate points. The heatmap reads ``count`` as
|
rather than dropping them, so the total heat weight is conserved across zoom
|
||||||
its weight.
|
levels and the surface no longer jumps at tile-zoom boundaries. The heatmap
|
||||||
|
reads ``count`` as its weight.
|
||||||
"""
|
"""
|
||||||
grouped = (
|
grouped = (
|
||||||
pl.scan_csv(
|
pl.scan_csv(
|
||||||
|
|
@ -144,7 +145,18 @@ def build_crime_hotspot_tiles(
|
||||||
str(min_zoom),
|
str(min_zoom),
|
||||||
"--maximum-zoom",
|
"--maximum-zoom",
|
||||||
str(max_zoom),
|
str(max_zoom),
|
||||||
"--drop-densest-as-needed",
|
# Merge (don't delete) the densest features at low zoom and sum
|
||||||
|
# their incident counts into the surviving point, so total heat
|
||||||
|
# weight is conserved across zoom levels. With --drop-densest the
|
||||||
|
# z14 tile lost hotspots that z15 kept, so the heatmap visibly
|
||||||
|
# collapsed into smaller spots when crossing the z14<->z15 tile
|
||||||
|
# boundary. Clustering is spatial only (it can merge different
|
||||||
|
# crime_types into one representative point), so per-type
|
||||||
|
# filtering is slightly approximate in the densest z14 tiles;
|
||||||
|
# the all-types surface and every zoom >= 15 stay accurate.
|
||||||
|
"--cluster-densest-as-needed",
|
||||||
|
"--accumulate-attribute=count:sum",
|
||||||
|
"--accumulate-attribute=weight:sum",
|
||||||
"--extend-zooms-if-still-dropping",
|
"--extend-zooms-if-still-dropping",
|
||||||
"--temporary-directory",
|
"--temporary-directory",
|
||||||
tmp,
|
tmp,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,19 @@ _AREA_COLUMNS = [
|
||||||
"Outstanding secondary school catchments",
|
"Outstanding secondary school catchments",
|
||||||
# Demographics
|
# Demographics
|
||||||
"Median age",
|
"Median age",
|
||||||
|
# Education (Census 2021 TS067, % of residents 16+ by highest qualification)
|
||||||
|
"% No qualifications",
|
||||||
|
"% Some GCSEs",
|
||||||
|
"% Good GCSEs",
|
||||||
|
"% Apprenticeship",
|
||||||
|
"% A-levels",
|
||||||
|
"% Degree or higher",
|
||||||
|
"% Other qualifications",
|
||||||
|
# Tenure (Census 2021 TS054, % of households by tenure) — unlike ethnicity &
|
||||||
|
# education these three percentages are user-filterable, not display-only.
|
||||||
|
"% Owner occupied",
|
||||||
|
"% Social rent",
|
||||||
|
"% Private rent",
|
||||||
# Politics
|
# Politics
|
||||||
"Voter turnout (%)",
|
"Voter turnout (%)",
|
||||||
"% Labour",
|
"% Labour",
|
||||||
|
|
@ -725,27 +738,30 @@ def _tree_density_by_postcode(tree_density_postcodes_path: Path) -> pl.LazyFrame
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_lsoa_source_coverage(iod_path: Path, ethnicity_path: Path) -> None:
|
def _validate_lsoa_source_coverage(
|
||||||
"""Fail if ethnicity (now LSOA-keyed) misses any IoD LSOA.
|
iod_path: Path, lsoa_sources: dict[str, Path]
|
||||||
|
) -> None:
|
||||||
|
"""Fail if any LSOA-keyed side table misses an IoD LSOA.
|
||||||
|
|
||||||
Ethnicity is sourced from Census 2021 TS021 at LSOA, then joined on `lsoa21`
|
Ethnicity (TS021) and education (TS067) are sourced from Census 2021 at LSOA,
|
||||||
like median age and IoD. The IoD table defines the LSOA universe every
|
then joined on `lsoa21` like median age and IoD. The IoD table defines the
|
||||||
postcode resolves into, so a missing LSOA would silently null the ethnicity
|
LSOA universe every postcode resolves into, so a missing LSOA would silently
|
||||||
columns for those postcodes; require full coverage instead.
|
null those columns for the affected postcodes; require full coverage instead.
|
||||||
|
`lsoa_sources` maps a human label (used in the error message) to a parquet
|
||||||
|
that must carry an `lsoa21` column.
|
||||||
"""
|
"""
|
||||||
iod_lsoas = pl.read_parquet(iod_path, columns=["LSOA code (2021)"]).rename(
|
iod_lsoas = pl.read_parquet(iod_path, columns=["LSOA code (2021)"]).rename(
|
||||||
{"LSOA code (2021)": "lsoa21"}
|
{"LSOA code (2021)": "lsoa21"}
|
||||||
)
|
)
|
||||||
|
|
||||||
ethnicity_lsoas = pl.read_parquet(ethnicity_path, columns=["lsoa21"])
|
for label, source_path in lsoa_sources.items():
|
||||||
missing_ethnicity = iod_lsoas.join(ethnicity_lsoas, on="lsoa21", how="anti").sort(
|
source_lsoas = pl.read_parquet(source_path, columns=["lsoa21"])
|
||||||
"lsoa21"
|
missing = iod_lsoas.join(source_lsoas, on="lsoa21", how="anti").sort("lsoa21")
|
||||||
)
|
if missing.height > 0:
|
||||||
if missing_ethnicity.height > 0:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Ethnicity data is missing LSOA coverage: "
|
f"{label} data is missing LSOA coverage: "
|
||||||
f"{missing_ethnicity.height} LSOAs, e.g. "
|
f"{missing.height} LSOAs, e.g. "
|
||||||
f"{missing_ethnicity.head(10).to_dicts()}"
|
f"{missing.head(10).to_dicts()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -883,6 +899,8 @@ def _join_area_side_tables(
|
||||||
*,
|
*,
|
||||||
iod: pl.LazyFrame,
|
iod: pl.LazyFrame,
|
||||||
ethnicity: pl.LazyFrame,
|
ethnicity: pl.LazyFrame,
|
||||||
|
education: pl.LazyFrame,
|
||||||
|
tenure: pl.LazyFrame,
|
||||||
crime: pl.LazyFrame,
|
crime: pl.LazyFrame,
|
||||||
median_age: pl.LazyFrame,
|
median_age: pl.LazyFrame,
|
||||||
election: pl.LazyFrame,
|
election: pl.LazyFrame,
|
||||||
|
|
@ -898,6 +916,12 @@ def _join_area_side_tables(
|
||||||
# `lsoa21` key as median age and IoD — a ~100x granularity gain over the old
|
# `lsoa21` key as median age and IoD — a ~100x granularity gain over the old
|
||||||
# Local-Authority broadcast, with no change to the 6-bucket output schema.
|
# Local-Authority broadcast, with no change to the 6-bucket output schema.
|
||||||
base = base.join(ethnicity, on="lsoa21", how="left")
|
base = base.join(ethnicity, on="lsoa21", how="left")
|
||||||
|
# Education (Census 2021 TS067 "highest level of qualification") is sourced at
|
||||||
|
# LSOA and joined on the same `lsoa21` key as ethnicity, IoD, and median age.
|
||||||
|
base = base.join(education, on="lsoa21", how="left")
|
||||||
|
# Tenure (Census 2021 TS054 "tenure of household") is sourced at LSOA and
|
||||||
|
# joined on the same `lsoa21` key as ethnicity, education, IoD, and median age.
|
||||||
|
base = base.join(tenure, on="lsoa21", how="left")
|
||||||
|
|
||||||
# Crime is counted spatially per postcode (incidents within 50m of the
|
# Crime is counted spatially per postcode (incidents within 50m of the
|
||||||
# postcode boundary), so it joins on postcode rather than LSOA. crime_spatial
|
# postcode boundary), so it joins on postcode rather than LSOA. crime_spatial
|
||||||
|
|
@ -2323,6 +2347,8 @@ def _build(
|
||||||
iod_path: Path,
|
iod_path: Path,
|
||||||
poi_proximity_path: Path,
|
poi_proximity_path: Path,
|
||||||
ethnicity_path: Path,
|
ethnicity_path: Path,
|
||||||
|
education_path: Path,
|
||||||
|
tenure_path: Path,
|
||||||
crime_path: Path,
|
crime_path: Path,
|
||||||
noise_path: Path,
|
noise_path: Path,
|
||||||
school_catchments_path: Path,
|
school_catchments_path: Path,
|
||||||
|
|
@ -2350,7 +2376,14 @@ def _build(
|
||||||
"""
|
"""
|
||||||
if mode == "listings" and actual_listings_path is None:
|
if mode == "listings" and actual_listings_path is None:
|
||||||
raise ValueError("listings mode requires actual_listings_path")
|
raise ValueError("listings mode requires actual_listings_path")
|
||||||
_validate_lsoa_source_coverage(iod_path, ethnicity_path)
|
_validate_lsoa_source_coverage(
|
||||||
|
iod_path,
|
||||||
|
{
|
||||||
|
"Ethnicity": ethnicity_path,
|
||||||
|
"Education": education_path,
|
||||||
|
"Tenure": tenure_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
_validate_lad_source_coverage(iod_path, rental_prices_path)
|
_validate_lad_source_coverage(iod_path, rental_prices_path)
|
||||||
|
|
||||||
wide = pl.scan_parquet(epc_pp_path).filter(
|
wide = pl.scan_parquet(epc_pp_path).filter(
|
||||||
|
|
@ -2424,6 +2457,8 @@ def _build(
|
||||||
*(_less_deprived_percentile_expr(c) for c in _IOD_PERCENTILE_COLUMNS)
|
*(_less_deprived_percentile_expr(c) for c in _IOD_PERCENTILE_COLUMNS)
|
||||||
)
|
)
|
||||||
ethnicity = pl.scan_parquet(ethnicity_path)
|
ethnicity = pl.scan_parquet(ethnicity_path)
|
||||||
|
education = pl.scan_parquet(education_path)
|
||||||
|
tenure = pl.scan_parquet(tenure_path)
|
||||||
crime = pl.scan_parquet(crime_path)
|
crime = pl.scan_parquet(crime_path)
|
||||||
median_age = pl.scan_parquet(median_age_path)
|
median_age = pl.scan_parquet(median_age_path)
|
||||||
election = pl.scan_parquet(election_results_path)
|
election = pl.scan_parquet(election_results_path)
|
||||||
|
|
@ -2475,6 +2510,8 @@ def _build(
|
||||||
area_side_tables = {
|
area_side_tables = {
|
||||||
"iod": iod,
|
"iod": iod,
|
||||||
"ethnicity": ethnicity,
|
"ethnicity": ethnicity,
|
||||||
|
"education": education,
|
||||||
|
"tenure": tenure,
|
||||||
"crime": crime,
|
"crime": crime,
|
||||||
"median_age": median_age,
|
"median_age": median_age,
|
||||||
"election": election,
|
"election": election,
|
||||||
|
|
@ -2617,6 +2654,18 @@ def main():
|
||||||
required=True,
|
required=True,
|
||||||
help="Census 2021 ethnic group (TS021) by LSOA parquet file",
|
help="Census 2021 ethnic group (TS021) by LSOA parquet file",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--education",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="Census 2021 highest qualification (TS067) by LSOA parquet file",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tenure",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="Census 2021 household tenure (TS054) by LSOA parquet file",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--crime",
|
"--crime",
|
||||||
type=Path,
|
type=Path,
|
||||||
|
|
@ -2734,6 +2783,8 @@ def main():
|
||||||
iod_path=args.iod,
|
iod_path=args.iod,
|
||||||
poi_proximity_path=args.poi_proximity,
|
poi_proximity_path=args.poi_proximity,
|
||||||
ethnicity_path=args.ethnicity,
|
ethnicity_path=args.ethnicity,
|
||||||
|
education_path=args.education,
|
||||||
|
tenure_path=args.tenure,
|
||||||
crime_path=args.crime,
|
crime_path=args.crime,
|
||||||
noise_path=args.noise,
|
noise_path=args.noise,
|
||||||
school_catchments_path=args.school_catchments,
|
school_catchments_path=args.school_catchments,
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,8 @@ def test_join_area_side_tables_does_not_fan_out_on_unique_keys() -> None:
|
||||||
base,
|
base,
|
||||||
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
|
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
|
||||||
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
|
education=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
|
tenure=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
crime=crime,
|
crime=crime,
|
||||||
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),
|
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),
|
||||||
|
|
@ -358,6 +360,8 @@ def test_join_area_side_tables_normalizes_broadband_postcode_key() -> None:
|
||||||
base,
|
base,
|
||||||
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
|
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
|
||||||
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
|
education=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
|
tenure=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
crime=crime,
|
crime=crime,
|
||||||
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),
|
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),
|
||||||
|
|
@ -586,7 +590,7 @@ def test_validate_lsoa_source_coverage_allows_full_ethnicity_coverage(
|
||||||
ethnicity_path
|
ethnicity_path
|
||||||
)
|
)
|
||||||
|
|
||||||
_validate_lsoa_source_coverage(iod_path, ethnicity_path)
|
_validate_lsoa_source_coverage(iod_path, {"Ethnicity": ethnicity_path})
|
||||||
|
|
||||||
|
|
||||||
def test_validate_lsoa_source_coverage_rejects_missing_lsoa(tmp_path) -> None:
|
def test_validate_lsoa_source_coverage_rejects_missing_lsoa(tmp_path) -> None:
|
||||||
|
|
@ -598,7 +602,7 @@ def test_validate_lsoa_source_coverage_rejects_missing_lsoa(tmp_path) -> None:
|
||||||
pl.DataFrame({"lsoa21": ["E01000001"]}).write_parquet(ethnicity_path)
|
pl.DataFrame({"lsoa21": ["E01000001"]}).write_parquet(ethnicity_path)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Ethnicity data is missing LSOA coverage"):
|
with pytest.raises(ValueError, match="Ethnicity data is missing LSOA coverage"):
|
||||||
_validate_lsoa_source_coverage(iod_path, ethnicity_path)
|
_validate_lsoa_source_coverage(iod_path, {"Ethnicity": ethnicity_path})
|
||||||
|
|
||||||
|
|
||||||
def test_tree_density_by_postcode_aliases_radius_percentile(tmp_path) -> None:
|
def test_tree_density_by_postcode_aliases_radius_percentile(tmp_path) -> None:
|
||||||
|
|
@ -1339,6 +1343,8 @@ def test_join_area_side_tables_preserves_missing_crime_as_null() -> None:
|
||||||
base,
|
base,
|
||||||
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
|
iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}),
|
||||||
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
ethnicity=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
|
education=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
|
tenure=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
crime=crime,
|
crime=crime,
|
||||||
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}),
|
||||||
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),
|
election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
mod actual_listings;
|
mod actual_listings;
|
||||||
|
pub mod area_crime_averages;
|
||||||
pub mod crime_by_year;
|
pub mod crime_by_year;
|
||||||
|
mod developments;
|
||||||
mod places;
|
mod places;
|
||||||
mod poi;
|
mod poi;
|
||||||
|
pub mod postcode_population;
|
||||||
mod postcodes;
|
mod postcodes;
|
||||||
mod property;
|
mod property;
|
||||||
pub mod spill;
|
pub mod spill;
|
||||||
|
|
@ -60,11 +63,14 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use actual_listings::{ActualListing, ActualListingData};
|
pub use actual_listings::{ActualListing, ActualListingData};
|
||||||
|
pub use area_crime_averages::AreaCrimeAverages;
|
||||||
pub use crime_by_year::CrimeByYearData;
|
pub use crime_by_year::CrimeByYearData;
|
||||||
|
pub use developments::{DevelopmentData, DevelopmentSite};
|
||||||
pub use places::{
|
pub use places::{
|
||||||
compute_trigrams, normalize_search_text, place_alias_tokens, trigram_similarity, PlaceData,
|
compute_trigrams, normalize_search_text, place_alias_tokens, trigram_similarity, PlaceData,
|
||||||
};
|
};
|
||||||
pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMetadata};
|
pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMetadata};
|
||||||
|
pub use postcode_population::PostcodePopulation;
|
||||||
pub use postcodes::{OutcodeData, PostcodeData};
|
pub use postcodes::{OutcodeData, PostcodeData};
|
||||||
pub use property::{
|
pub use property::{
|
||||||
precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData,
|
precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData,
|
||||||
|
|
|
||||||
47
server-rs/src/data/area_crime_averages.rs
Normal file
47
server-rs/src/data/area_crime_averages.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
//! Precomputed per-outcode and per-postcode-sector average crime rates.
|
||||||
|
//!
|
||||||
|
//! The right pane shows each crime metric's national average (the global
|
||||||
|
//! feature-histogram mean). To let users see how an area compares with its
|
||||||
|
//! immediate surroundings, we also precompute the mean headline crime rate
|
||||||
|
//! (`"X (avg/yr)"`) across every property in the selection's outcode (e.g.
|
||||||
|
//! `"E14"`) and postcode sector (e.g. `"E14 2"`).
|
||||||
|
//!
|
||||||
|
//! Crime figures are constant within a postcode (the pipeline merges them on
|
||||||
|
//! the postcode key), so each postcode's value is read once — from its first
|
||||||
|
//! row — and property-weighted by the postcode's row count. That keeps these
|
||||||
|
//! averages on the same property-weighted basis as the national average, so the
|
||||||
|
//! four numbers (this area / sector / outcode / nation) are directly comparable.
|
||||||
|
|
||||||
|
use rustc_hash::FxHashMap;
|
||||||
|
|
||||||
|
/// Crime-feature name suffix that marks an annualised headline-rate column
|
||||||
|
/// (e.g. `"Burglary (avg/yr)"`). Stripped to derive the bare type name.
|
||||||
|
pub const AVG_YR_SUFFIX: &str = " (avg/yr)";
|
||||||
|
|
||||||
|
pub struct AreaCrimeAverages {
|
||||||
|
/// Bare crime-type names (suffix stripped, e.g. `"Burglary"`), index-aligned
|
||||||
|
/// with the per-area mean vectors. Matches `CrimeYearStats.name`.
|
||||||
|
pub crime_types: Vec<String>,
|
||||||
|
/// National mean headline rate per crime type (index-aligned with
|
||||||
|
/// `crime_types`). An EXACT property-weighted mean over every postcode, so it
|
||||||
|
/// shares a basis with `by_outcode`/`by_sector` and the per-selection mean —
|
||||||
|
/// unlike the histogram-bin national average, which is biased upward for the
|
||||||
|
/// right-skewed crime densities. `NaN` where no postcode has data.
|
||||||
|
pub national: Vec<f32>,
|
||||||
|
/// Outcode (e.g. `"E14"`) → mean headline rate per crime type. `NaN` where
|
||||||
|
/// the outcode has no data for that type.
|
||||||
|
pub by_outcode: FxHashMap<String, Vec<f32>>,
|
||||||
|
/// Postcode sector (e.g. `"E14 2"`) → mean headline rate per crime type.
|
||||||
|
pub by_sector: FxHashMap<String, Vec<f32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AreaCrimeAverages {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
crime_types: Vec::new(),
|
||||||
|
national: Vec::new(),
|
||||||
|
by_outcode: FxHashMap::default(),
|
||||||
|
by_sector: FxHashMap::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
343
server-rs/src/data/developments.rs
Normal file
343
server-rs/src/data/developments.rs
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use polars::lazy::frame::LazyFrame;
|
||||||
|
use polars::prelude::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::utils::GridIndex;
|
||||||
|
|
||||||
|
const GRID_CELL_SIZE: f32 = 0.01;
|
||||||
|
|
||||||
|
/// A single planned/pipeline development site (one brownfield-register entry or
|
||||||
|
/// one Homes England land-disposal site). These are *sites*, not properties:
|
||||||
|
/// they carry a coordinate, an estimate of the number of new dwellings, and the
|
||||||
|
/// planning status — the forward-looking "where new homes are coming" signal.
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct DevelopmentSite {
|
||||||
|
pub lat: f32,
|
||||||
|
pub lon: f32,
|
||||||
|
/// Data source: "brownfield" (MHCLG Brownfield Land register) or
|
||||||
|
/// "homes-england" (Homes England Land Hub).
|
||||||
|
pub source: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub min_dwellings: Option<i32>,
|
||||||
|
pub max_dwellings: Option<i32>,
|
||||||
|
pub planning_status: Option<String>,
|
||||||
|
pub permission_type: Option<String>,
|
||||||
|
pub permission_date: Option<String>,
|
||||||
|
pub hectares: Option<f32>,
|
||||||
|
pub local_authority: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Columnar in-memory store of development sites with a spatial grid index for
|
||||||
|
/// viewport queries. Small enough (~40k rows nationally) to keep on the heap; no
|
||||||
|
/// quantization or spill needed.
|
||||||
|
pub struct DevelopmentData {
|
||||||
|
pub lat: Vec<f32>,
|
||||||
|
pub lon: Vec<f32>,
|
||||||
|
source: Vec<String>,
|
||||||
|
name: Vec<Option<String>>,
|
||||||
|
min_dwellings: Vec<Option<i32>>,
|
||||||
|
max_dwellings: Vec<Option<i32>>,
|
||||||
|
planning_status: Vec<Option<String>>,
|
||||||
|
permission_type: Vec<Option<String>>,
|
||||||
|
permission_date: Vec<Option<String>>,
|
||||||
|
hectares: Vec<Option<f32>>,
|
||||||
|
local_authority: Vec<Option<String>>,
|
||||||
|
url: Vec<Option<String>>,
|
||||||
|
grid: GridIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DevelopmentData {
|
||||||
|
/// Empty store, used only by tests (the `--developments` parquet is required
|
||||||
|
/// in production).
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
lat: Vec::new(),
|
||||||
|
lon: Vec::new(),
|
||||||
|
source: Vec::new(),
|
||||||
|
name: Vec::new(),
|
||||||
|
min_dwellings: Vec::new(),
|
||||||
|
max_dwellings: Vec::new(),
|
||||||
|
planning_status: Vec::new(),
|
||||||
|
permission_type: Vec::new(),
|
||||||
|
permission_date: Vec::new(),
|
||||||
|
hectares: Vec::new(),
|
||||||
|
local_authority: Vec::new(),
|
||||||
|
url: Vec::new(),
|
||||||
|
grid: GridIndex::build(&[], &[], GRID_CELL_SIZE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(parquet_path: &Path) -> Result<Self> {
|
||||||
|
super::run_polars_io(|| Self::load_inner(parquet_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_inner(parquet_path: &Path) -> Result<Self> {
|
||||||
|
info!("Loading development sites from {:?}", parquet_path);
|
||||||
|
let pl_path = PlRefPath::try_from_path(parquet_path)
|
||||||
|
.context("Failed to normalize development sites parquet path")?;
|
||||||
|
let df = LazyFrame::scan_parquet(pl_path, Default::default())
|
||||||
|
.context("Failed to scan development sites parquet")?
|
||||||
|
.collect()
|
||||||
|
.context("Failed to read development sites parquet")?;
|
||||||
|
|
||||||
|
let lat = extract_f32(&df, "lat")?;
|
||||||
|
let lon = extract_f32(&df, "lon")?;
|
||||||
|
let source = extract_str(&df, "source")?;
|
||||||
|
let name = extract_opt_str(&df, "name")?;
|
||||||
|
let min_dwellings = extract_opt_i32(&df, "min_dwellings")?;
|
||||||
|
let max_dwellings = extract_opt_i32(&df, "max_dwellings")?;
|
||||||
|
let planning_status = extract_opt_str(&df, "planning_status")?;
|
||||||
|
let permission_type = extract_opt_str(&df, "permission_type")?;
|
||||||
|
let permission_date = extract_opt_str(&df, "permission_date")?;
|
||||||
|
let hectares = extract_opt_f32(&df, "hectares")?;
|
||||||
|
let local_authority = extract_opt_str(&df, "local_authority")?;
|
||||||
|
let url = extract_opt_str(&df, "url")?;
|
||||||
|
|
||||||
|
let grid = GridIndex::build(&lat, &lon, GRID_CELL_SIZE);
|
||||||
|
|
||||||
|
info!(rows = lat.len(), "Development sites loaded");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
source,
|
||||||
|
name,
|
||||||
|
min_dwellings,
|
||||||
|
max_dwellings,
|
||||||
|
planning_status,
|
||||||
|
permission_type,
|
||||||
|
permission_date,
|
||||||
|
hectares,
|
||||||
|
local_authority,
|
||||||
|
url,
|
||||||
|
grid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn site_at(&self, row: usize) -> DevelopmentSite {
|
||||||
|
DevelopmentSite {
|
||||||
|
lat: self.lat[row],
|
||||||
|
lon: self.lon[row],
|
||||||
|
source: self.source[row].clone(),
|
||||||
|
name: self.name[row].clone(),
|
||||||
|
min_dwellings: self.min_dwellings[row],
|
||||||
|
max_dwellings: self.max_dwellings[row],
|
||||||
|
planning_status: self.planning_status[row].clone(),
|
||||||
|
permission_type: self.permission_type[row].clone(),
|
||||||
|
permission_date: self.permission_date[row].clone(),
|
||||||
|
hectares: self.hectares[row],
|
||||||
|
local_authority: self.local_authority[row].clone(),
|
||||||
|
url: self.url[row].clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return every site whose coordinate falls within the bounds, sorted by the
|
||||||
|
/// largest dwelling estimate first (so the biggest schemes survive the cap),
|
||||||
|
/// capped at `limit`. The bool is true when the result was truncated.
|
||||||
|
pub fn query_bounds(
|
||||||
|
&self,
|
||||||
|
south: f64,
|
||||||
|
west: f64,
|
||||||
|
north: f64,
|
||||||
|
east: f64,
|
||||||
|
limit: usize,
|
||||||
|
) -> (Vec<DevelopmentSite>, usize, bool) {
|
||||||
|
let mut rows: Vec<usize> = self
|
||||||
|
.grid
|
||||||
|
.query(south, west, north, east)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row_idx| {
|
||||||
|
let row = row_idx as usize;
|
||||||
|
row_within_bounds(self.lat[row], self.lon[row], south, west, north, east)
|
||||||
|
.then_some(row)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total = rows.len();
|
||||||
|
|
||||||
|
// Biggest schemes first: rank on the upper dwelling estimate, falling back
|
||||||
|
// to the lower estimate, so a viewport that exceeds the cap still surfaces
|
||||||
|
// the most significant developments.
|
||||||
|
rows.sort_by(|&a, &b| {
|
||||||
|
let key = |row: usize| {
|
||||||
|
self.max_dwellings[row]
|
||||||
|
.or(self.min_dwellings[row])
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
key(b).cmp(&key(a))
|
||||||
|
});
|
||||||
|
|
||||||
|
let truncated = total > limit;
|
||||||
|
let sites = rows
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(|row| self.site_at(row))
|
||||||
|
.collect();
|
||||||
|
(sites, total, truncated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_within_bounds(lat: f32, lon: f32, south: f64, west: f64, north: f64, east: f64) -> bool {
|
||||||
|
let lat = lat as f64;
|
||||||
|
let lon = lon as f64;
|
||||||
|
lat >= south && lat <= north && lon >= west && lon <= east
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_f32(df: &DataFrame, name: &str) -> Result<Vec<f32>> {
|
||||||
|
let cast = df
|
||||||
|
.column(name)
|
||||||
|
.with_context(|| format!("Missing column '{name}'"))?
|
||||||
|
.cast(&DataType::Float32)
|
||||||
|
.with_context(|| format!("Failed to cast '{name}' to Float32"))?;
|
||||||
|
let column = cast
|
||||||
|
.f32()
|
||||||
|
.with_context(|| format!("Column '{name}' is not Float32"))?;
|
||||||
|
column
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row, value)| value.with_context(|| format!("Column '{name}' has null at row {row}")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_str(df: &DataFrame, name: &str) -> Result<Vec<String>> {
|
||||||
|
let column = df
|
||||||
|
.column(name)
|
||||||
|
.with_context(|| format!("Missing column '{name}'"))?;
|
||||||
|
let strings = column
|
||||||
|
.str()
|
||||||
|
.with_context(|| format!("Column '{name}' is not a string column"))?;
|
||||||
|
strings
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row, value)| {
|
||||||
|
value
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.with_context(|| format!("Column '{name}' has null at row {row}"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_opt_str(df: &DataFrame, name: &str) -> Result<Vec<Option<String>>> {
|
||||||
|
let column = df
|
||||||
|
.column(name)
|
||||||
|
.with_context(|| format!("Missing column '{name}'"))?;
|
||||||
|
let strings = column
|
||||||
|
.str()
|
||||||
|
.with_context(|| format!("Column '{name}' is not a string column"))?;
|
||||||
|
Ok(strings
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.and_then(|text| (!text.trim().is_empty()).then(|| text.to_string())))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_opt_i32(df: &DataFrame, name: &str) -> Result<Vec<Option<i32>>> {
|
||||||
|
let cast = df
|
||||||
|
.column(name)
|
||||||
|
.with_context(|| format!("Missing column '{name}'"))?
|
||||||
|
.cast(&DataType::Int32)
|
||||||
|
.with_context(|| format!("Failed to cast '{name}' to Int32"))?;
|
||||||
|
let column = cast
|
||||||
|
.i32()
|
||||||
|
.with_context(|| format!("Column '{name}' is not Int32"))?;
|
||||||
|
Ok(column.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_opt_f32(df: &DataFrame, name: &str) -> Result<Vec<Option<f32>>> {
|
||||||
|
let cast = df
|
||||||
|
.column(name)
|
||||||
|
.with_context(|| format!("Missing column '{name}'"))?
|
||||||
|
.cast(&DataType::Float32)
|
||||||
|
.with_context(|| format!("Failed to cast '{name}' to Float32"))?;
|
||||||
|
let column = cast
|
||||||
|
.f32()
|
||||||
|
.with_context(|| format!("Column '{name}' is not Float32"))?;
|
||||||
|
Ok(column
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.filter(|v| v.is_finite()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn build(points: &[(f32, f32, Option<i32>)]) -> DevelopmentData {
|
||||||
|
let lat: Vec<f32> = points.iter().map(|p| p.0).collect();
|
||||||
|
let lon: Vec<f32> = points.iter().map(|p| p.1).collect();
|
||||||
|
let grid = GridIndex::build(&lat, &lon, GRID_CELL_SIZE);
|
||||||
|
DevelopmentData {
|
||||||
|
source: points.iter().map(|_| "brownfield".to_string()).collect(),
|
||||||
|
name: points.iter().map(|_| None).collect(),
|
||||||
|
min_dwellings: points.iter().map(|_| None).collect(),
|
||||||
|
max_dwellings: points.iter().map(|p| p.2).collect(),
|
||||||
|
planning_status: points.iter().map(|_| None).collect(),
|
||||||
|
permission_type: points.iter().map(|_| None).collect(),
|
||||||
|
permission_date: points.iter().map(|_| None).collect(),
|
||||||
|
hectares: points.iter().map(|_| None).collect(),
|
||||||
|
local_authority: points.iter().map(|_| None).collect(),
|
||||||
|
url: points.iter().map(|_| None).collect(),
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
grid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_returns_only_in_bounds_sites() {
|
||||||
|
let data = build(&[
|
||||||
|
(51.50, -0.10, Some(5)), // inside
|
||||||
|
(51.55, -0.05, Some(50)), // inside
|
||||||
|
(52.50, -1.00, Some(99)), // outside
|
||||||
|
]);
|
||||||
|
let (sites, total, truncated) = data.query_bounds(51.4, -0.2, 51.6, 0.0, 100);
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
assert!(!truncated);
|
||||||
|
// Sorted by max_dwellings desc: the 50-dwelling scheme comes first.
|
||||||
|
assert_eq!(sites[0].max_dwellings, Some(50));
|
||||||
|
assert_eq!(sites[1].max_dwellings, Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_caps_and_flags_truncation_keeping_biggest() {
|
||||||
|
let data = build(&[
|
||||||
|
(51.50, -0.10, Some(1)),
|
||||||
|
(51.51, -0.11, Some(900)),
|
||||||
|
(51.52, -0.12, Some(10)),
|
||||||
|
]);
|
||||||
|
let (sites, total, truncated) = data.query_bounds(51.4, -0.2, 51.6, 0.0, 1);
|
||||||
|
assert_eq!(total, 3);
|
||||||
|
assert!(truncated);
|
||||||
|
assert_eq!(sites.len(), 1);
|
||||||
|
assert_eq!(sites[0].max_dwellings, Some(900));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_store_is_empty() {
|
||||||
|
let data = DevelopmentData::empty();
|
||||||
|
assert!(data.lat.is_empty());
|
||||||
|
let (sites, total, truncated) = data.query_bounds(51.4, -0.2, 51.6, 0.0, 100);
|
||||||
|
assert!(sites.is_empty());
|
||||||
|
assert_eq!(total, 0);
|
||||||
|
assert!(!truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_sample_parquet_when_available() {
|
||||||
|
let path = PathBuf::from("../property-data/development_sites.parquet");
|
||||||
|
if !path.exists() {
|
||||||
|
eprintln!("sample development_sites.parquet not present; skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = DevelopmentData::load_inner(&path).expect("developments load");
|
||||||
|
assert!(!data.lat.is_empty());
|
||||||
|
assert_eq!(data.lat.len(), data.lon.len());
|
||||||
|
assert_eq!(data.lat.len(), data.source.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
125
server-rs/src/data/postcode_population.rs
Normal file
125
server-rs/src/data/postcode_population.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
//! Per-unit-postcode usual-resident headcounts (ONS Census 2021, table P001),
|
||||||
|
//! loaded from a side parquet and shown in the right pane. This is display-only
|
||||||
|
//! area data: it is never a filterable attribute and never enters the feature
|
||||||
|
//! matrix, mirroring the crime-by-year side table.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use polars::prelude::PlRefPath;
|
||||||
|
use polars::prelude::*;
|
||||||
|
use rustc_hash::FxHashMap;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::utils::normalize_postcode;
|
||||||
|
|
||||||
|
use super::run_polars_io;
|
||||||
|
|
||||||
|
pub struct PostcodePopulation {
|
||||||
|
/// Canonical spaced postcode (e.g. "AL1 1AG") → usual residents (Census 2021).
|
||||||
|
by_postcode: FxHashMap<String, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostcodePopulation {
|
||||||
|
/// Empty table — used in tests and when no --population-path is supplied.
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
by_postcode: FxHashMap::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
run_polars_io(|| Self::load_inner(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_inner(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
info!("Loading postcode population from {}", path.display());
|
||||||
|
let pl_path = PlRefPath::try_from_path(path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to normalize population parquet path {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let df = LazyFrame::scan_parquet(pl_path, Default::default())
|
||||||
|
.with_context(|| format!("Failed to scan population parquet at {}", path.display()))?
|
||||||
|
.collect()
|
||||||
|
.with_context(|| format!("Failed to read population parquet at {}", path.display()))?;
|
||||||
|
|
||||||
|
let postcode_col = df
|
||||||
|
.column("postcode")
|
||||||
|
.context("population parquet missing 'postcode' column")?
|
||||||
|
.str()
|
||||||
|
.context("'postcode' column is not a string")?;
|
||||||
|
// Accept whatever integer width the parquet writer used.
|
||||||
|
let population_cast = df
|
||||||
|
.column("population")
|
||||||
|
.context("population parquet missing 'population' column")?
|
||||||
|
.cast(&DataType::Int64)
|
||||||
|
.context("'population' column is not an integer")?;
|
||||||
|
let population_col = population_cast
|
||||||
|
.i64()
|
||||||
|
.context("failed to read 'population' as i64")?;
|
||||||
|
|
||||||
|
let mut by_postcode: FxHashMap<String, u32> = FxHashMap::default();
|
||||||
|
by_postcode.reserve(df.height());
|
||||||
|
for (postcode, population) in postcode_col.into_iter().zip(population_col.into_iter()) {
|
||||||
|
let (Some(postcode), Some(population)) = (postcode, population) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let trimmed = postcode.trim();
|
||||||
|
if trimmed.is_empty() || population <= 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Normalize to the exact canonical form the routes look up with, so
|
||||||
|
// a stray double-space or lowercase in the source can't miss.
|
||||||
|
by_postcode.insert(
|
||||||
|
normalize_postcode(trimmed),
|
||||||
|
population.min(u32::MAX as i64) as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if by_postcode.is_empty() {
|
||||||
|
bail!("population parquet at {} produced no rows", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
postcodes = by_postcode.len(),
|
||||||
|
"Postcode population loaded"
|
||||||
|
);
|
||||||
|
Ok(Self { by_postcode })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usual-resident count for a single canonical (spaced, upper-case) postcode.
|
||||||
|
pub fn for_postcode(&self, postcode: &str) -> Option<u32> {
|
||||||
|
self.by_postcode.get(postcode).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Integration smoke test against the real Census 2021 parquet. Skips when
|
||||||
|
/// the data file is absent (CI without a data build) so it never blocks.
|
||||||
|
#[test]
|
||||||
|
fn loads_real_census_parquet_if_present() {
|
||||||
|
let path = std::path::Path::new("../property-data/population_by_postcode.parquet");
|
||||||
|
if !path.exists() {
|
||||||
|
eprintln!("skipping: {} not present", path.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pop = PostcodePopulation::load(path).expect("population parquet should load");
|
||||||
|
|
||||||
|
// Covers the whole of England & Wales (~1.37M unit postcodes).
|
||||||
|
assert!(pop.by_postcode.len() > 1_000_000);
|
||||||
|
// A residential postcode has a positive headcount...
|
||||||
|
assert!(pop.for_postcode("AL1 1AG").is_some_and(|n| n > 0));
|
||||||
|
// ...reachable from non-canonical input via normalize-on-load.
|
||||||
|
assert_eq!(
|
||||||
|
pop.for_postcode(&normalize_postcode("al1 1ag")),
|
||||||
|
pop.for_postcode("AL1 1AG"),
|
||||||
|
);
|
||||||
|
// Postcodes with zero usual residents are absent from P001.
|
||||||
|
assert_eq!(pop.for_postcode("EC1A 1BB"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,9 @@ use rustc_hash::FxHashMap;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::consts::NAN_U16;
|
use crate::consts::NAN_U16;
|
||||||
|
use crate::data::area_crime_averages::{AreaCrimeAverages, AVG_YR_SUFFIX};
|
||||||
use crate::data::spill::SpillVec;
|
use crate::data::spill::SpillVec;
|
||||||
|
use crate::utils::{postcode_outcode, postcode_sector};
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct RenovationEvent {
|
pub struct RenovationEvent {
|
||||||
|
|
@ -224,6 +226,109 @@ impl PropertyData {
|
||||||
num_numeric: self.num_numeric,
|
num_numeric: self.num_numeric,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precompute mean headline crime rates nationally and per outcode / postcode
|
||||||
|
/// sector.
|
||||||
|
///
|
||||||
|
/// Crime values are identical for every property in a postcode (the pipeline
|
||||||
|
/// merges them on the postcode key), so each postcode is sampled once from
|
||||||
|
/// its first row and property-weighted by its row count. All three scopes use
|
||||||
|
/// the same exact property-weighted estimator over the same row universe as
|
||||||
|
/// the per-selection mean, so the four numbers shown in a crime row (this
|
||||||
|
/// selection / sector / outcode / nation) are directly comparable — without
|
||||||
|
/// the upward bias of the histogram-bin national average.
|
||||||
|
pub fn compute_area_crime_averages(&self) -> AreaCrimeAverages {
|
||||||
|
// Crime headline columns are exactly the " (avg/yr)" features.
|
||||||
|
let crime_indices: Vec<usize> = self
|
||||||
|
.feature_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, name)| name.ends_with(AVG_YR_SUFFIX))
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
.collect();
|
||||||
|
if crime_indices.is_empty() {
|
||||||
|
return AreaCrimeAverages::empty();
|
||||||
|
}
|
||||||
|
let crime_types: Vec<String> = crime_indices
|
||||||
|
.iter()
|
||||||
|
.map(|&idx| {
|
||||||
|
self.feature_names[idx]
|
||||||
|
.strip_suffix(AVG_YR_SUFFIX)
|
||||||
|
.unwrap_or(&self.feature_names[idx])
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let n = crime_indices.len();
|
||||||
|
|
||||||
|
// (weighted value sum, weight) accumulators per crime type.
|
||||||
|
let mut nat_sums = vec![0.0f64; n];
|
||||||
|
let mut nat_weights = vec![0u64; n];
|
||||||
|
let mut out_acc: FxHashMap<String, (Vec<f64>, Vec<u64>)> = FxHashMap::default();
|
||||||
|
let mut sec_acc: FxHashMap<String, (Vec<f64>, Vec<u64>)> = FxHashMap::default();
|
||||||
|
|
||||||
|
for (key, rows) in &self.postcode_row_index {
|
||||||
|
let Some(&first) = rows.first() else { continue };
|
||||||
|
let count = rows.len() as u64;
|
||||||
|
let postcode = self.postcode_interner.resolve(key);
|
||||||
|
let outcode = postcode_outcode(postcode);
|
||||||
|
let sector = postcode_sector(postcode);
|
||||||
|
|
||||||
|
for (j, &fi) in crime_indices.iter().enumerate() {
|
||||||
|
// A NaN value is "no crime data for this postcode" — skip it so
|
||||||
|
// it dilutes neither the sum nor the weight (a genuine gap, not
|
||||||
|
// a zero), exactly as the global histogram excludes it.
|
||||||
|
let value = self.get_feature(first as usize, fi);
|
||||||
|
if !value.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let weighted = value as f64 * count as f64;
|
||||||
|
// National counts every postcode (the population the global mean
|
||||||
|
// is built over); outcode/sector only when the postcode parses.
|
||||||
|
nat_sums[j] += weighted;
|
||||||
|
nat_weights[j] += count;
|
||||||
|
if let Some(outcode) = outcode {
|
||||||
|
let acc = out_acc
|
||||||
|
.entry(outcode.to_string())
|
||||||
|
.or_insert_with(|| (vec![0.0; n], vec![0; n]));
|
||||||
|
acc.0[j] += weighted;
|
||||||
|
acc.1[j] += count;
|
||||||
|
}
|
||||||
|
if let Some(sector) = sector {
|
||||||
|
let acc = sec_acc
|
||||||
|
.entry(sector.to_string())
|
||||||
|
.or_insert_with(|| (vec![0.0; n], vec![0; n]));
|
||||||
|
acc.0[j] += weighted;
|
||||||
|
acc.1[j] += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let means_of = |sums: &[f64], weights: &[u64]| -> Vec<f32> {
|
||||||
|
sums.iter()
|
||||||
|
.zip(weights.iter())
|
||||||
|
.map(|(&sum, &weight)| {
|
||||||
|
if weight == 0 {
|
||||||
|
f32::NAN
|
||||||
|
} else {
|
||||||
|
(sum / weight as f64) as f32
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let finalize =
|
||||||
|
|acc: FxHashMap<String, (Vec<f64>, Vec<u64>)>| -> FxHashMap<String, Vec<f32>> {
|
||||||
|
acc.into_iter()
|
||||||
|
.map(|(area, (sums, weights))| (area, means_of(&sums, &weights)))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
AreaCrimeAverages {
|
||||||
|
crime_types,
|
||||||
|
national: means_of(&nat_sums, &nat_weights),
|
||||||
|
by_outcode: finalize(out_acc),
|
||||||
|
by_sector: finalize(sec_acc),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -733,6 +733,138 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
raw: false,
|
raw: false,
|
||||||
absolute: false,
|
absolute: false,
|
||||||
}),
|
}),
|
||||||
|
// Education: Census 2021 TS067 highest-qualification breakdown. The
|
||||||
|
// seven bands sum to 100% per neighbourhood (LSOA) and render as a
|
||||||
|
// stacked composition (see STACKED_GROUPS["Neighbours"] in the
|
||||||
|
// frontend), like the ethnicity and vote-share bars. Colloquial
|
||||||
|
// labels stand in for the ONS "Level 1/2/3/4+" jargon.
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% No qualifications",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of residents (16+) with no qualifications",
|
||||||
|
detail: "From the 2021 Census (TS067). Percentage of usual residents aged 16 and over in the neighbourhood (LSOA) who hold no formal qualifications.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% Some GCSEs",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of residents (16+) whose highest qualification is roughly 1-4 GCSEs (Level 1)",
|
||||||
|
detail: "From the 2021 Census (TS067). Highest qualification is around 1 to 4 GCSEs at grades 9-4 (A*-C), or entry-level/foundation qualifications. The ONS calls this 'Level 1 and entry level'.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% Good GCSEs",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of residents (16+) whose highest qualification is 5+ GCSEs (Level 2)",
|
||||||
|
detail: "From the 2021 Census (TS067). Highest qualification is roughly 5 or more GCSEs at grades 9-4 (A*-C), an intermediate apprenticeship, or equivalent. The ONS calls this 'Level 2'.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% Apprenticeship",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of residents (16+) whose highest qualification is an apprenticeship",
|
||||||
|
detail: "From the 2021 Census (TS067). Highest qualification is an apprenticeship.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% A-levels",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of residents (16+) whose highest qualification is A-levels (Level 3)",
|
||||||
|
detail: "From the 2021 Census (TS067). Highest qualification is A-levels, AS-levels, T-levels, an advanced apprenticeship, or equivalent — typically studied after 16 and before a degree. The ONS calls this 'Level 3'.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% Degree or higher",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of residents (16+) with a degree-level or higher qualification",
|
||||||
|
detail: "From the 2021 Census (TS067). Highest qualification is degree level or above — a Bachelor's, Master's or PhD, foundation degree, HNC/HND, NVQ 4-5, or higher professional qualification. The census does not separate undergraduate from postgraduate degrees. The ONS calls this 'Level 4 or above'.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% Other qualifications",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of residents (16+) with other qualifications, including vocational or overseas ones",
|
||||||
|
detail: "From the 2021 Census (TS067). Highest qualification is classed as 'other' — vocational or professional qualifications not mapped to a UK level, and qualifications gained outside the UK.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
// Tenure: Census 2021 TS054 household-tenure breakdown. The three
|
||||||
|
// shares sum to ~100% per neighbourhood (LSOA) and render as a
|
||||||
|
// stacked composition (see STACKED_GROUPS["Neighbours"] in the
|
||||||
|
// frontend), like the ethnicity, qualifications and vote-share bars.
|
||||||
|
// Unlike those, the three shares are ALSO offered as individual
|
||||||
|
// filters (they are not added to the display-only skip-list in
|
||||||
|
// Filters.tsx), so users can target e.g. owner-occupier-heavy areas.
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% Owner occupied",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of households that own their home, outright or with a mortgage",
|
||||||
|
detail: "From the 2021 Census (TS054). Percentage of households in the neighbourhood (LSOA) that own their home outright, own it with a mortgage or loan, or hold it through shared ownership.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% Social rent",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of households renting from a council or housing association",
|
||||||
|
detail: "From the 2021 Census (TS054). Percentage of households in the neighbourhood (LSOA) renting from a local council or local authority, or from a housing association or other social landlord.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
|
Feature::Numeric(FeatureConfig {
|
||||||
|
name: "% Private rent",
|
||||||
|
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
|
||||||
|
step: 1.0,
|
||||||
|
description: "Share of households renting privately or living rent-free",
|
||||||
|
detail: "From the 2021 Census (TS054). Percentage of households in the neighbourhood (LSOA) renting from a private landlord or letting agency, plus the small share living rent-free.",
|
||||||
|
source: "census-2021",
|
||||||
|
prefix: "",
|
||||||
|
suffix: "%",
|
||||||
|
raw: false,
|
||||||
|
absolute: false,
|
||||||
|
}),
|
||||||
Feature::Numeric(FeatureConfig {
|
Feature::Numeric(FeatureConfig {
|
||||||
name: "% White",
|
name: "% White",
|
||||||
bounds: Bounds::Fixed {
|
bounds: Bounds::Fixed {
|
||||||
|
|
|
||||||
|
|
@ -325,10 +325,21 @@ struct Cli {
|
||||||
#[arg(long, env = "ACTUAL_LISTINGS_PATH")]
|
#[arg(long, env = "ACTUAL_LISTINGS_PATH")]
|
||||||
actual_listings_path: PathBuf,
|
actual_listings_path: PathBuf,
|
||||||
|
|
||||||
|
/// Path to a parquet of planned/pipeline development sites (MHCLG brownfield
|
||||||
|
/// register + Homes England Land Hub) for the "new developments" layer.
|
||||||
|
#[arg(long, env = "DEVELOPMENTS_PATH")]
|
||||||
|
developments_path: PathBuf,
|
||||||
|
|
||||||
/// Path to the per-LSOA per-year crime parquet (display-only side table for the right pane).
|
/// Path to the per-LSOA per-year crime parquet (display-only side table for the right pane).
|
||||||
#[arg(long, env = "CRIME_BY_YEAR_PATH")]
|
#[arg(long, env = "CRIME_BY_YEAR_PATH")]
|
||||||
crime_by_year_path: PathBuf,
|
crime_by_year_path: PathBuf,
|
||||||
|
|
||||||
|
/// Path to the per-unit-postcode population parquet (ONS Census 2021 usual
|
||||||
|
/// residents; display-only side table for the right pane). Optional: when
|
||||||
|
/// absent or missing, the area pane simply omits the population figure.
|
||||||
|
#[arg(long, env = "POPULATION_PATH")]
|
||||||
|
population_path: Option<PathBuf>,
|
||||||
|
|
||||||
/// Google Maps API key for Street View metadata lookups
|
/// Google Maps API key for Street View metadata lookups
|
||||||
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
|
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
|
||||||
google_maps_api_key: String,
|
google_maps_api_key: String,
|
||||||
|
|
@ -692,6 +703,18 @@ async fn main() -> anyhow::Result<()> {
|
||||||
Arc::new(listings)
|
Arc::new(listings)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let developments = {
|
||||||
|
let path = &cli.developments_path;
|
||||||
|
if !path.exists() {
|
||||||
|
bail!("Development sites parquet not found: {}", path.display());
|
||||||
|
}
|
||||||
|
info!("Loading development sites from {}", path.display());
|
||||||
|
let data = data::DevelopmentData::load(path)?;
|
||||||
|
trim_allocator("development sites load");
|
||||||
|
info!(rows = data.lat.len(), "Development sites loaded");
|
||||||
|
Arc::new(data)
|
||||||
|
};
|
||||||
|
|
||||||
let crime_by_year = {
|
let crime_by_year = {
|
||||||
let path = &cli.crime_by_year_path;
|
let path = &cli.crime_by_year_path;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|
@ -702,6 +725,34 @@ async fn main() -> anyhow::Result<()> {
|
||||||
Arc::new(data)
|
Arc::new(data)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let population = match &cli.population_path {
|
||||||
|
Some(path) if path.exists() => {
|
||||||
|
let data = data::PostcodePopulation::load(path)?;
|
||||||
|
trim_allocator("postcode population load");
|
||||||
|
Arc::new(data)
|
||||||
|
}
|
||||||
|
Some(path) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Population parquet not found at {}; area pane will omit population",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Arc::new(data::PostcodePopulation::empty())
|
||||||
|
}
|
||||||
|
None => Arc::new(data::PostcodePopulation::empty()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let area_crime_averages = {
|
||||||
|
let data = property_data.compute_area_crime_averages();
|
||||||
|
info!(
|
||||||
|
outcodes = data.by_outcode.len(),
|
||||||
|
sectors = data.by_sector.len(),
|
||||||
|
crime_types = data.crime_types.len(),
|
||||||
|
"Per-outcode/sector crime averages computed"
|
||||||
|
);
|
||||||
|
trim_allocator("area crime averages");
|
||||||
|
Arc::new(data)
|
||||||
|
};
|
||||||
|
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
data: property_data,
|
data: property_data,
|
||||||
grid,
|
grid,
|
||||||
|
|
@ -728,7 +779,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
gemini_model: cli.gemini_model,
|
gemini_model: cli.gemini_model,
|
||||||
travel_time_store,
|
travel_time_store,
|
||||||
actual_listings,
|
actual_listings,
|
||||||
|
developments,
|
||||||
crime_by_year,
|
crime_by_year,
|
||||||
|
population,
|
||||||
|
area_crime_averages,
|
||||||
token_cache,
|
token_cache,
|
||||||
superuser_token_cache,
|
superuser_token_cache,
|
||||||
share_cache,
|
share_cache,
|
||||||
|
|
@ -801,6 +855,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"/api/actual-listings",
|
"/api/actual-listings",
|
||||||
get(routes::get_actual_listings).layer(ConcurrencyLimitLayer::new(20)),
|
get(routes::get_actual_listings).layer(ConcurrencyLimitLayer::new(20)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/developments",
|
||||||
|
get(routes::get_developments).layer(ConcurrencyLimitLayer::new(20)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/poi-categories",
|
"/api/poi-categories",
|
||||||
get(routes::get_poi_categories).layer(ConcurrencyLimitLayer::new(20)),
|
get(routes::get_poi_categories).layer(ConcurrencyLimitLayer::new(20)),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
mod actual_listings;
|
mod actual_listings;
|
||||||
mod ai_filters;
|
mod ai_filters;
|
||||||
mod checkout;
|
mod checkout;
|
||||||
|
mod developments;
|
||||||
mod export;
|
mod export;
|
||||||
mod features;
|
mod features;
|
||||||
mod filter_counts;
|
mod filter_counts;
|
||||||
|
|
@ -34,6 +35,7 @@ pub(crate) mod travel_time;
|
||||||
pub use actual_listings::get_actual_listings;
|
pub use actual_listings::get_actual_listings;
|
||||||
pub use ai_filters::{build_system_prompt, post_ai_filters};
|
pub use ai_filters::{build_system_prompt, post_ai_filters};
|
||||||
pub use checkout::post_checkout;
|
pub use checkout::post_checkout;
|
||||||
|
pub use developments::get_developments;
|
||||||
pub use export::get_export;
|
pub use export::get_export;
|
||||||
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
|
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
|
||||||
pub use filter_counts::get_filter_counts;
|
pub use filter_counts::get_filter_counts;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue