Compare commits
No commits in common. "e2b85fe8196d9e394cdeb21d8bbec8cfd6c5578f" and "c2c4ed67086ed81535034bc8d45deaac9ccc5e7e" have entirely different histories.
e2b85fe819
...
c2c4ed6708
139 changed files with 3454 additions and 8735 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/data/crime_by_postcode_by_year.parquet", "--crime-records-path", "/app/data/crime_records.parquet", "--area-crime-averages-path", "/app/data/area_crime_averages.parquet", "--population-path", "/app/data/population_by_postcode.parquet", "--developments-path", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"]
|
CMD ["--properties", "/app/data/properties.parquet", "--postcode-features", "/app/data/postcode.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries", "--travel-times", "/app/data/travel-times", "--satellite-tiles", "/app/data/satellite.pmtiles", "--satellite-highres-tiles", "/app/data/satellite_highres.pmtiles", "--noise-overlay-tiles", "/app/data/noise_lden_10m.pmtiles", "--crime-hotspot-tiles", "/app/data/crime_hotspots.pmtiles", "--tree-overlay-tiles", "/app/data/trees_outside_woodlands.pmtiles", "--property-border-tiles", "/app/data/property_borders.pmtiles", "--crime-by-year-path", "/app/data/crime_by_postcode_by_year.parquet", "--area-crime-averages-path", "/app/data/area_crime_averages.parquet", "--population-path", "/app/data/population_by_postcode.parquet", "--developments-path", "/app/data/development_sites.parquet", "--dist", "/app/frontend/dist"]
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,7 @@ POSTCODES_RAW := $(DATA_DIR)/gb-postcodes-v5
|
||||||
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
|
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
|
||||||
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
|
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
|
||||||
MERGE_STAMP := $(DATA_DIR)/.merge_done
|
MERGE_STAMP := $(DATA_DIR)/.merge_done
|
||||||
PRICE_INPUTS := $(DATA_DIR)/price_inputs.parquet
|
|
||||||
POSTCODE_CENTROIDS := $(DATA_DIR)/postcode_centroids.parquet
|
|
||||||
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
|
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
|
||||||
PRICE_ESTIMATES := $(DATA_DIR)/price_estimates.parquet
|
|
||||||
PRICES_STAMP := $(DATA_DIR)/.prices_done
|
PRICES_STAMP := $(DATA_DIR)/.prices_done
|
||||||
EPC := $(MANUAL_DATA)/domestic-csv.zip
|
EPC := $(MANUAL_DATA)/domestic-csv.zip
|
||||||
ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet
|
ACTUAL_LISTINGS_RAW := $(FINDER_DATA)/online_listings_buy.parquet
|
||||||
|
|
@ -41,8 +38,6 @@ 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
|
||||||
CRIME_RECORDS := $(DATA_DIR)/crime_records.parquet
|
|
||||||
AREA_CRIME_AVERAGES := $(DATA_DIR)/area_crime_averages.parquet
|
|
||||||
POPULATION := $(DATA_DIR)/population_by_postcode.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
|
||||||
|
|
@ -96,11 +91,8 @@ VALIDATE_OUTPUTS := uv run python -m pipeline.validate_outputs
|
||||||
|
|
||||||
POI_PROXIMITY_DEPS := pipeline/transform/poi_proximity.py pipeline/utils/poi_counts.py
|
POI_PROXIMITY_DEPS := pipeline/transform/poi_proximity.py pipeline/utils/poi_counts.py
|
||||||
MERGE_DEPS := pipeline/transform/merge.py
|
MERGE_DEPS := pipeline/transform/merge.py
|
||||||
AREA_CRIME_AVERAGES_DEPS := pipeline/transform/area_crime_averages.py
|
|
||||||
PRICE_INDEX_DEPS := pipeline/transform/price_estimation/index.py pipeline/transform/price_estimation/shrinkage.py pipeline/transform/price_estimation/utils.py
|
PRICE_INDEX_DEPS := pipeline/transform/price_estimation/index.py pipeline/transform/price_estimation/shrinkage.py pipeline/transform/price_estimation/utils.py
|
||||||
PRICE_ESTIMATE_DEPS := pipeline/transform/price_estimation/estimate.py pipeline/transform/price_estimation/knn.py pipeline/transform/price_estimation/utils.py
|
PRICE_ESTIMATE_DEPS := pipeline/transform/price_estimation/estimate.py pipeline/transform/price_estimation/knn.py pipeline/transform/price_estimation/utils.py
|
||||||
PRICE_JOIN_DEPS := pipeline/transform/join_price_estimates.py pipeline/transform/price_estimation/utils.py
|
|
||||||
PRICE_INPUTS_DEPS := pipeline/transform/property_base.py pipeline/utils/postcode_mapping.py
|
|
||||||
TREE_DENSITY_DEPS := pipeline/transform/tree_density.py
|
TREE_DENSITY_DEPS := pipeline/transform/tree_density.py
|
||||||
PC_BOUNDARIES_DEPS := pipeline/transform/postcode_boundaries/__main__.py \
|
PC_BOUNDARIES_DEPS := pipeline/transform/postcode_boundaries/__main__.py \
|
||||||
pipeline/transform/postcode_boundaries/greenspace.py \
|
pipeline/transform/postcode_boundaries/greenspace.py \
|
||||||
|
|
@ -124,11 +116,11 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_
|
||||||
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-population 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-area-crime-averages \
|
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 download-development-sites
|
generate-postcode-boundaries generate-travel-times enrich-actual-listings download-development-sites
|
||||||
|
|
||||||
prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times $(DEVELOPMENT_SITES) $(POPULATION) $(CRIME_RECORDS) $(AREA_CRIME_AVERAGES) | $(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)"
|
||||||
|
|
@ -186,7 +178,6 @@ transform-pois: $(POIS_FILTERED)
|
||||||
transform-epc-pp: $(EPC_PP)
|
transform-epc-pp: $(EPC_PP)
|
||||||
transform-crime: $(CRIME)
|
transform-crime: $(CRIME)
|
||||||
transform-poi-proximity: $(POI_PROXIMITY)
|
transform-poi-proximity: $(POI_PROXIMITY)
|
||||||
transform-area-crime-averages: $(AREA_CRIME_AVERAGES)
|
|
||||||
transform-school-catchments: $(SCHOOL_CATCH)
|
transform-school-catchments: $(SCHOOL_CATCH)
|
||||||
transform-tree-density: $(TREE_DENSITY_PC)
|
transform-tree-density: $(TREE_DENSITY_PC)
|
||||||
generate-postcode-boundaries: $(PC_BOUNDARIES_STAMP)
|
generate-postcode-boundaries: $(PC_BOUNDARIES_STAMP)
|
||||||
|
|
@ -392,9 +383,9 @@ $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(OFSTE
|
||||||
$(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py
|
$(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py
|
||||||
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
||||||
|
|
||||||
$(CRIME) $(CRIME_BY_YEAR) $(CRIME_RECORDS) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py
|
$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py
|
||||||
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv"
|
$(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv"
|
||||||
uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) --output-records $(CRIME_RECORDS)
|
uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR)
|
||||||
|
|
||||||
$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DEPS)
|
$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DEPS)
|
||||||
uv run python -m pipeline.transform.poi_proximity --arcgis $(ARCGIS) --pois $(POIS_FILTERED) --greenspace $(OS_GREENSPACE) --output $@
|
uv run python -m pipeline.transform.poi_proximity --arcgis $(ARCGIS) --pois $(POIS_FILTERED) --greenspace $(OS_GREENSPACE) --output $@
|
||||||
|
|
@ -459,42 +450,16 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
||||||
$(POSTCODES_PQ) $(PROPERTIES_PQ) &: $(MERGE_STAMP)
|
$(POSTCODES_PQ) $(PROPERTIES_PQ) &: $(MERGE_STAMP)
|
||||||
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)"
|
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)"
|
||||||
|
|
||||||
# Slim price-estimation inputs, built straight from epc_pp + arcgis (NOT the
|
$(PRICE_INDEX): $(MERGE_STAMP) $(PRICE_INDEX_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ)
|
||||||
# merge outputs). This is what decouples the expensive index/kNN from merge:
|
uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@
|
||||||
# adding an area or property column to merge does not change these files, so the
|
|
||||||
# price index and estimates are reused and only the cheap join re-runs.
|
|
||||||
$(PRICE_INPUTS) $(POSTCODE_CENTROIDS) &: $(EPC_PP) $(ARCGIS) $(PRICE_INPUTS_DEPS)
|
|
||||||
uv run python -m pipeline.transform.property_base --epc-pp $(EPC_PP) --arcgis $(ARCGIS) --output $(PRICE_INPUTS) --centroids $(POSTCODE_CENTROIDS)
|
|
||||||
$(VALIDATE_OUTPUTS) --parquet $(PRICE_INPUTS) --parquet $(POSTCODE_CENTROIDS)
|
|
||||||
|
|
||||||
$(PRICE_INDEX): $(PRICE_INPUTS) $(POSTCODE_CENTROIDS) $(PRICE_INDEX_DEPS)
|
|
||||||
uv run python -m pipeline.transform.price_estimation.index --input $(PRICE_INPUTS) --postcodes $(POSTCODE_CENTROIDS) --output $@
|
|
||||||
$(VALIDATE_OUTPUTS) --parquet $@
|
$(VALIDATE_OUTPUTS) --parquet $@
|
||||||
|
|
||||||
# Estimate prices on the slim inputs and write a standalone price_estimates.parquet
|
$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ)
|
||||||
# (natural key + the two estimate columns). Never touches properties.parquet.
|
|
||||||
$(PRICE_ESTIMATES): $(PRICE_INPUTS) $(POSTCODE_CENTROIDS) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS)
|
|
||||||
uv run python -m pipeline.transform.price_estimation.estimate --input $(PRICE_INPUTS) --postcodes $(POSTCODE_CENTROIDS) --index $(PRICE_INDEX) --output $@
|
|
||||||
$(VALIDATE_OUTPUTS) --parquet $@
|
|
||||||
|
|
||||||
# Join the estimate columns back onto properties.parquet by natural key
|
|
||||||
# (idempotent, atomic). Re-runs whenever merge or the estimates change.
|
|
||||||
$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_ESTIMATES) $(PRICE_JOIN_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) $(PRICE_INDEX)
|
|
||||||
@rm -f $@
|
@rm -f $@
|
||||||
uv run python -m pipeline.transform.join_price_estimates --properties $(PROPERTIES_PQ) --estimates $(PRICE_ESTIMATES)
|
uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX)
|
||||||
$(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX)
|
$(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX)
|
||||||
@touch $@
|
@touch $@
|
||||||
|
|
||||||
# ── Area crime averages (post-merge) ────────────────────
|
|
||||||
# National / per-outcode / per-sector property-weighted mean headline crime
|
|
||||||
# rates for the right pane. Depends on $(PRICES_STAMP) so it reads the
|
|
||||||
# finalised properties.parquet (the price-estimate join rewrites it); only the
|
|
||||||
# per-postcode property counts and the crime values from postcode.parquet are
|
|
||||||
# used, both unaffected by that join.
|
|
||||||
$(AREA_CRIME_AVERAGES): $(PRICES_STAMP) $(AREA_CRIME_AVERAGES_DEPS) | $(POSTCODES_PQ) $(PROPERTIES_PQ)
|
|
||||||
uv run python -m pipeline.transform.area_crime_averages --postcodes $(POSTCODES_PQ) --properties $(PROPERTIES_PQ) --output $@
|
|
||||||
$(VALIDATE_OUTPUTS) --parquet $@
|
|
||||||
|
|
||||||
$(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \
|
$(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \
|
||||||
$(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
$(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
||||||
$(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \
|
$(ETHNICITY) $(EDUCATION) $(TENURE) $(CRIME) $(NOISE) $(SCHOOL_CATCH) $(BROADBAND) \
|
||||||
|
|
|
||||||
|
|
@ -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 --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --crime-records-path /app/property-data/crime_records.parquet --area-crime-averages-path /app/property-data/area_crime_averages.parquet --population-path /app/property-data/population_by_postcode.parquet'
|
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"
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 24 24" width="512" height="512"><defs><clipPath id="_dlr_clip"><rect width="24" height="24"/></clipPath></defs><g clip-path="url(#_dlr_clip)"><circle vector-effect="non-scaling-stroke" cx="12" cy="12" r="9.71875" fill="rgb(0,164,167)"/><circle vector-effect="non-scaling-stroke" cx="12" cy="12" r="6.375" fill="rgb(255,255,255)"/><rect x="0.031" y="10.219" width="23.938" height="3.563" transform="matrix(1,0,0,1,0,0)" fill="rgb(0,164,167)"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 628 B |
Binary file not shown.
|
Before Width: | Height: | Size: 760 B |
|
|
@ -214,41 +214,16 @@ function readServerFeatureNames() {
|
||||||
return [...new Set(names)];
|
return [...new Set(names)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Names of the Enum/Numeric feature *configs* (each needs a description + detail
|
|
||||||
// translation). We take the FIRST `name:` field after every Feature::Enum( /
|
|
||||||
// Feature::Numeric( opening. This deliberately skips macro-generated configs
|
|
||||||
// whose name is a `concat!(...)` expression (the crime rates — handled via
|
|
||||||
// deriveLegacyCrimeKeys instead) and stops the lazy match from running past such
|
|
||||||
// a config into an unrelated FeatureGroup `name:` (which previously made the
|
|
||||||
// group name "Properties" look like a required feature).
|
|
||||||
function readServerFeatureConfigNames() {
|
function readServerFeatureConfigNames() {
|
||||||
const src = readFileSync(FEATURES_RS, 'utf8');
|
const src = readFileSync(FEATURES_RS, 'utf8');
|
||||||
const names = [];
|
const names = [];
|
||||||
const re = /Feature::(?:Enum|Numeric)\(/g;
|
const re = /Feature::(?:Enum|Numeric)\([^]*?name:\s*"((?:\\.|[^"\\])*)"/g;
|
||||||
let m;
|
for (const match of src.matchAll(re)) {
|
||||||
while ((m = re.exec(src))) {
|
names.push(JSON.parse(`"${match[1]}"`));
|
||||||
const after = src.slice(m.index + m[0].length);
|
|
||||||
const nm = after.match(/\bname:\s*(?:"((?:\\.|[^"\\])*)"|concat!)/);
|
|
||||||
if (nm && nm[1] !== undefined) names.push(JSON.parse(`"${nm[1]}"`));
|
|
||||||
}
|
}
|
||||||
return [...new Set(names)];
|
return [...new Set(names)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The crime-rate features are generated by the crime_rate_features! macro and
|
|
||||||
// named "<type> (/yr, 7y|2y)". Their description/detail translations are shared
|
|
||||||
// across both windows under a single legacy "<type> (avg/yr)" key. This mirror
|
|
||||||
// of legacyCrimeFeatureKey() in descriptions.ts MUST use the same suffix regex,
|
|
||||||
// derived straight from the server keys so the checker and the runtime lookup
|
|
||||||
// stay in lock-step.
|
|
||||||
function deriveLegacyCrimeKeys(serverKeys) {
|
|
||||||
const legacy = new Set();
|
|
||||||
for (const key of serverKeys) {
|
|
||||||
const match = key.match(/^(.*) \(\/yr, \d+y\)$/);
|
|
||||||
if (match) legacy.add(`${match[1]} (avg/yr)`);
|
|
||||||
}
|
|
||||||
return legacy;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readNamedRecord(file, varName) {
|
function readNamedRecord(file, varName) {
|
||||||
const sf = parseFile(join(I18N_DIR, file));
|
const sf = parseFile(join(I18N_DIR, file));
|
||||||
const init = findVarInitializer(sf, varName);
|
const init = findVarInitializer(sf, varName);
|
||||||
|
|
@ -380,7 +355,7 @@ function checkLocales(supportedCodes) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys, legacyCrimeKeys) {
|
function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys) {
|
||||||
const record = readNamedRecord(file, varName);
|
const record = readNamedRecord(file, varName);
|
||||||
const expected = supportedCodes.filter((c) => c !== 'en');
|
const expected = supportedCodes.filter((c) => c !== 'en');
|
||||||
const present = Object.keys(record);
|
const present = Object.keys(record);
|
||||||
|
|
@ -422,12 +397,10 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys, required
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every key here must also be a translatable feature name in en.ts > server,
|
// Every key here must also be a translatable feature name in en.ts > server.
|
||||||
// or a legacy crime key that maps onto the per-window server keys (see
|
// Otherwise the description is unreachable — ts() looks up server.${name}.
|
||||||
// deriveLegacyCrimeKeys / legacyCrimeFeatureKey). Otherwise the description is
|
|
||||||
// unreachable — ts() looks up server.${name}.
|
|
||||||
for (const key of union) {
|
for (const key of union) {
|
||||||
if (!serverKeys.has(key) && !legacyCrimeKeys.has(key)) {
|
if (!serverKeys.has(key)) {
|
||||||
fail(`${file}: key "${key}" has no matching entry in en.ts > server`);
|
fail(`${file}: key "${key}" has no matching entry in en.ts > server`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -505,25 +478,16 @@ function main() {
|
||||||
checkLocaleLoaders(supportedCodes);
|
checkLocaleLoaders(supportedCodes);
|
||||||
const en = readLocale('en');
|
const en = readLocale('en');
|
||||||
const serverKeys = new Set(Object.keys(en.server ?? {}));
|
const serverKeys = new Set(Object.keys(en.server ?? {}));
|
||||||
const legacyCrimeKeys = deriveLegacyCrimeKeys(serverKeys);
|
const sourceFeatureKeys = readServerFeatureConfigNames();
|
||||||
const requiredKeys = [...readServerFeatureConfigNames(), ...legacyCrimeKeys];
|
|
||||||
checkServerSourceCoverage(serverKeys);
|
checkServerSourceCoverage(serverKeys);
|
||||||
checkRecordCoverage(
|
checkRecordCoverage(
|
||||||
'descriptions.ts',
|
'descriptions.ts',
|
||||||
'descriptions',
|
'descriptions',
|
||||||
supportedCodes,
|
supportedCodes,
|
||||||
serverKeys,
|
serverKeys,
|
||||||
requiredKeys,
|
sourceFeatureKeys
|
||||||
legacyCrimeKeys
|
|
||||||
);
|
|
||||||
checkRecordCoverage(
|
|
||||||
'details.ts',
|
|
||||||
'details',
|
|
||||||
supportedCodes,
|
|
||||||
serverKeys,
|
|
||||||
requiredKeys,
|
|
||||||
legacyCrimeKeys
|
|
||||||
);
|
);
|
||||||
|
checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys, sourceFeatureKeys);
|
||||||
checkForbiddenVisibleStrings();
|
checkForbiddenVisibleStrings();
|
||||||
|
|
||||||
for (const w of warnings) console.warn(`warn: ${w}`);
|
for (const w of warnings) console.warn(`warn: ${w}`);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from './lib/seoRoutes';
|
} from './lib/seoRoutes';
|
||||||
import Header, { type Page } from './components/ui/Header';
|
import Header, { type Page } from './components/ui/Header';
|
||||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||||
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
import { fetchWithRetry, apiUrl, logNonAbortError, readDemoChoice, setDemoCenter } from './lib/api';
|
||||||
import { trackEvent } from './lib/analytics';
|
import { trackEvent } from './lib/analytics';
|
||||||
import { parseUrlState } from './lib/url-state';
|
import { parseUrlState } from './lib/url-state';
|
||||||
import pb from './lib/pocketbase';
|
import pb from './lib/pocketbase';
|
||||||
|
|
@ -209,6 +209,34 @@ export default function App() {
|
||||||
}, []);
|
}, []);
|
||||||
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
||||||
const [mapUrlState, setMapUrlState] = useState(urlState);
|
const [mapUrlState, setMapUrlState] = useState(urlState);
|
||||||
|
// Demo location: a fresh visitor (no explicit URL view) is offered the "check
|
||||||
|
// your area" prompt (in MapPage). A choice made earlier this session is reused —
|
||||||
|
// GPS coords re-centre the map and are sent to the server (via lib/api), while
|
||||||
|
// 'declined' keeps Canary Wharf. Resolve it once, before children fetch, so the
|
||||||
|
// very first data request already carries the centre.
|
||||||
|
// Read license synchronously from the persisted session so we never apply the
|
||||||
|
// demo location for a returning licensed user (they bypass the free zone, and we
|
||||||
|
// don't want their location in request URLs).
|
||||||
|
const licensedAtMount = useMemo(
|
||||||
|
() =>
|
||||||
|
pb.authStore.isValid &&
|
||||||
|
(pb.authStore.record?.subscription === 'licensed' || pb.authStore.record?.is_admin === true),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const initialDemoChoice = useMemo(() => {
|
||||||
|
const choice = readDemoChoice();
|
||||||
|
if (choice && choice !== 'declined' && !licensedAtMount) setDemoCenter(choice);
|
||||||
|
return choice;
|
||||||
|
}, [licensedAtMount]);
|
||||||
|
const [demoView] = useState<typeof INITIAL_VIEW_STATE | null>(() =>
|
||||||
|
initialDemoChoice && initialDemoChoice !== 'declined' && !licensedAtMount
|
||||||
|
? {
|
||||||
|
...INITIAL_VIEW_STATE,
|
||||||
|
latitude: initialDemoChoice.lat,
|
||||||
|
longitude: initialDemoChoice.lon,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
|
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
|
||||||
window.location.pathname === '/dashboard' ? window.location.search : ''
|
window.location.pathname === '/dashboard' ? window.location.search : ''
|
||||||
);
|
);
|
||||||
|
|
@ -220,8 +248,8 @@ export default function App() {
|
||||||
);
|
);
|
||||||
const activePageRef = useRef<Page>('home');
|
const activePageRef = useRef<Page>('home');
|
||||||
const initialViewState = useMemo(
|
const initialViewState = useMemo(
|
||||||
() => (mapUrlState.hasExplicitView ? mapUrlState.viewState : INITIAL_VIEW_STATE),
|
() => (mapUrlState.hasExplicitView ? mapUrlState.viewState : (demoView ?? INITIAL_VIEW_STATE)),
|
||||||
[mapUrlState.hasExplicitView, mapUrlState.viewState]
|
[mapUrlState.hasExplicitView, mapUrlState.viewState, demoView]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isScreenshotMode = useMemo(() => {
|
const isScreenshotMode = useMemo(() => {
|
||||||
|
|
@ -274,6 +302,16 @@ export default function App() {
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
clearError,
|
clearError,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
// Only offer the "check your area" demo prompt to non-licensed visitors on a
|
||||||
|
// fresh visit. `user` is hydrated synchronously from the persisted PocketBase
|
||||||
|
// session, so a returning premium user is recognised at mount (no flash).
|
||||||
|
const offerDemoLocation =
|
||||||
|
!urlState.hasExplicitView && initialDemoChoice == null && !hasFullAccess(user);
|
||||||
|
// Stop transmitting the demo location once the user is licensed (in-session
|
||||||
|
// upgrade, cross-tab sign-in, or a stale session that refreshed to licensed).
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasFullAccess(user)) setDemoCenter(null);
|
||||||
|
}, [user]);
|
||||||
const { startCheckout: startPostAuthCheckout } = useLicense();
|
const { startCheckout: startPostAuthCheckout } = useLicense();
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
||||||
|
|
@ -313,8 +351,8 @@ export default function App() {
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
setPostAuthIntent(null);
|
setPostAuthIntent(null);
|
||||||
postAuthCheckoutReturnPathRef.current = null;
|
postAuthCheckoutReturnPathRef.current = null;
|
||||||
// Only protected pages bounce home; the dashboard stays open when the
|
// Only protected pages bounce home; the dashboard stays open in demo
|
||||||
// modal is dismissed.
|
// mode (server-enforced free zone) when the modal is dismissed.
|
||||||
if (isProtectedPage(activePageRef.current)) {
|
if (isProtectedPage(activePageRef.current)) {
|
||||||
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
||||||
setRouteHash('');
|
setRouteHash('');
|
||||||
|
|
@ -573,8 +611,9 @@ export default function App() {
|
||||||
|
|
||||||
const isProtectedPageActive = isProtectedPage(activePage);
|
const isProtectedPageActive = isProtectedPage(activePage);
|
||||||
// Only protected pages (account/saved) prompt for login on entry. The
|
// Only protected pages (account/saved) prompt for login on entry. The
|
||||||
// dashboard opens straight away so visitors can try it without logging in;
|
// dashboard opens straight into demo mode (server-enforced free zone) so
|
||||||
// the upgrade modal appears when they hit the free filter cap.
|
// visitors can try it without logging in; the upgrade modal still appears
|
||||||
|
// when they pan outside the free zone.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (isProtectedPageActive && !user) {
|
if (isProtectedPageActive && !user) {
|
||||||
|
|
@ -729,6 +768,7 @@ export default function App() {
|
||||||
poiCategoryGroups={poiCategoryGroups}
|
poiCategoryGroups={poiCategoryGroups}
|
||||||
initialFilters={mapUrlState.filters}
|
initialFilters={mapUrlState.filters}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
|
offerDemoLocation={offerDemoLocation}
|
||||||
initialPOICategories={mapUrlState.poiCategories}
|
initialPOICategories={mapUrlState.poiCategories}
|
||||||
initialOverlays={mapUrlState.overlays}
|
initialOverlays={mapUrlState.overlays}
|
||||||
initialCrimeTypes={mapUrlState.crimeTypes}
|
initialCrimeTypes={mapUrlState.crimeTypes}
|
||||||
|
|
|
||||||
|
|
@ -383,9 +383,7 @@ export function SavedPage({
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setShareLinksError(
|
setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links');
|
||||||
err instanceof Error ? err.message : t('accountPage.fetchShareLinksError')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -395,7 +393,7 @@ export function SavedPage({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
const tabClass = (tab: string) =>
|
const tabClass = (tab: string) =>
|
||||||
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
|
@ -740,7 +738,7 @@ function InviteSection({ user }: { user: AuthUser }) {
|
||||||
setInviteUrl((prev) => ({ ...prev, [type]: data.url }));
|
setInviteUrl((prev) => ({ ...prev, [type]: data.url }));
|
||||||
fetchInviteHistory();
|
fetchInviteHistory();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : t('invitesPage.createInviteError');
|
const msg = err instanceof Error ? err.message : 'Failed to create invite';
|
||||||
setInviteError((prev) => ({ ...prev, [type]: msg }));
|
setInviteError((prev) => ({ ...prev, [type]: msg }));
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingInvite((prev) => ({ ...prev, [type]: false }));
|
setCreatingInvite((prev) => ({ ...prev, [type]: false }));
|
||||||
|
|
@ -933,8 +931,7 @@ export default function AccountPage({
|
||||||
assertOk(res, 'Update newsletter');
|
assertOk(res, 'Update newsletter');
|
||||||
await onRefreshAuth();
|
await onRefreshAuth();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg =
|
const msg = err instanceof Error ? err.message : 'Failed to update newsletter';
|
||||||
err instanceof Error ? err.message : t('accountPage.updateNewsletterError');
|
|
||||||
setNewsletterError(msg);
|
setNewsletterError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setNewsletterSaving(false);
|
setNewsletterSaving(false);
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ function ProductDemoVideo() {
|
||||||
<track
|
<track
|
||||||
kind="captions"
|
kind="captions"
|
||||||
srcLang={(i18n.language ?? 'en').split('-')[0]}
|
srcLang={(i18n.language ?? 'en').split('-')[0]}
|
||||||
label={t('common.captions')}
|
label="Captions"
|
||||||
src={`/video/${productDemoSlug}.vtt`}
|
src={`/video/${productDemoSlug}.vtt`}
|
||||||
/>
|
/>
|
||||||
</video>
|
</video>
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
|
||||||
prefix: '£',
|
prefix: '£',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Serious crime (/yr, 7y)',
|
name: 'Serious crime (avg/yr)',
|
||||||
type: 'numeric',
|
type: 'numeric',
|
||||||
group: 'Crime',
|
group: 'Crime',
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|
@ -763,7 +763,7 @@ function RightPaneOnlyScreen({
|
||||||
<span className="truncate">{t('home.showcasePoliticalVoteShare')}</span>
|
<span className="truncate">{t('home.showcasePoliticalVoteShare')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
|
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
|
||||||
{t('home.showcaseGe2024')}
|
2024 GE
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<StackedBarChart
|
<StackedBarChart
|
||||||
|
|
|
||||||
|
|
@ -155,11 +155,11 @@ export default function InvitePage({
|
||||||
window.location.href = data.checkout_url;
|
window.location.href = data.checkout_url;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('invitePage.redeemFailed'));
|
setError(err instanceof Error ? err.message : 'Failed to redeem invite');
|
||||||
} finally {
|
} finally {
|
||||||
setRedeeming(false);
|
setRedeeming(false);
|
||||||
}
|
}
|
||||||
}, [code, user, onLicenseGranted, t]);
|
}, [code, user, onLicenseGranted]);
|
||||||
|
|
||||||
if (screenshotMode && loading) {
|
if (screenshotMode && loading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -262,12 +262,7 @@ function SocialVideoCard({
|
||||||
onPause={() => setIsPlaying(false)}
|
onPause={() => setIsPlaying(false)}
|
||||||
onEnded={() => setIsPlaying(false)}
|
onEnded={() => setIsPlaying(false)}
|
||||||
>
|
>
|
||||||
<track
|
<track kind="captions" srcLang="en" label="Captions" src={`/video/${slug}.vtt`} />
|
||||||
kind="captions"
|
|
||||||
srcLang="en"
|
|
||||||
label={t('common.captions')}
|
|
||||||
src={`/video/${slug}.vtt`}
|
|
||||||
/>
|
|
||||||
</video>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,14 @@ export const TERMS: LegalDoc = {
|
||||||
{
|
{
|
||||||
heading: '2. Accounts',
|
heading: '2. Accounts',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
'You can browse the map without an account. To save searches or buy lifetime access you need an account. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
|
'You need an account to use the service beyond the free demo area. Provide a valid email address, keep your credentials secure, and do not share your account. Accounts are for one person each.',
|
||||||
'We may suspend or close accounts that breach these terms, abuse the service, or attempt to circumvent access restrictions. If we close your account without cause, we will refund the price you paid.',
|
'We may suspend or close accounts that breach these terms, abuse the service, or attempt to circumvent access restrictions. If we close your account without cause, we will refund the price you paid.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: '3. Free access and lifetime access',
|
heading: '3. Free demo and lifetime access',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
'Free users can explore the full map of England with every feature, applying up to three filters at a time. Lifetime access is a one-time payment that removes the filter limit and gives your account ongoing access — every postcode, every filter, unlimited — for as long as the service runs. It is not a subscription, and routine data updates are included.',
|
'Free accounts can explore all features within the demo area (inner London). Lifetime access is a one-time payment that gives your account ongoing access to the paid map — every postcode, every filter — for as long as the service runs. It is not a subscription, and routine data updates are included.',
|
||||||
'Lifetime access is personal and non-transferable, and is for personal, non-commercial property research. If you would like to use Perfect Postcode commercially (for example in lettings, relocation or research services), contact us first.',
|
'Lifetime access is personal and non-transferable, and is for personal, non-commercial property research. If you would like to use Perfect Postcode commercially (for example in lettings, relocation or research services), contact us first.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
import { useCallback, 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 {
|
||||||
CrimeRecordsResponse,
|
|
||||||
FeatureFilters,
|
FeatureFilters,
|
||||||
FeatureGroup,
|
FeatureGroup,
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
|
|
@ -11,11 +18,7 @@ import type {
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
import type { HexagonLocation } from '../../lib/external-search';
|
import type { HexagonLocation } from '../../lib/external-search';
|
||||||
import {
|
import { formatStationDistance, type NearbyStation } from '../../lib/nearby-stations';
|
||||||
formatStationDistance,
|
|
||||||
stationDisplayName,
|
|
||||||
type NearbyStation,
|
|
||||||
} from '../../lib/nearby-stations';
|
|
||||||
import {
|
import {
|
||||||
formatValue,
|
formatValue,
|
||||||
formatFilterValue,
|
formatFilterValue,
|
||||||
|
|
@ -36,14 +39,12 @@ import {
|
||||||
} from '../../lib/consts';
|
} from '../../lib/consts';
|
||||||
import { useNearbyStations } from '../../hooks/useNearbyStations';
|
import { useNearbyStations } from '../../hooks/useNearbyStations';
|
||||||
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
|
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
|
||||||
import { useRevealOnExpand } from '../../hooks/useRevealOnExpand';
|
|
||||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||||
import EnumBarChart from './EnumBarChart';
|
import EnumBarChart from './EnumBarChart';
|
||||||
import StackedBarChart from './StackedBarChart';
|
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 CrimeGroupBody from './CrimeGroupBody';
|
|
||||||
import NumberLine, { type NumberLinePoint } from './NumberLine';
|
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';
|
||||||
|
|
@ -71,7 +72,6 @@ interface AreaPaneProps {
|
||||||
shareCode?: string;
|
shareCode?: string;
|
||||||
isGroupExpanded: (name: string) => boolean;
|
isGroupExpanded: (name: string) => boolean;
|
||||||
onToggleGroup: (name: string) => void;
|
onToggleGroup: (name: string) => void;
|
||||||
onLoadCrimeRecords?: (offset: number) => Promise<CrimeRecordsResponse>;
|
|
||||||
scrollTopRef?: MutableRefObject<number>;
|
scrollTopRef?: MutableRefObject<number>;
|
||||||
scrollRestoreKey?: string | null;
|
scrollRestoreKey?: string | null;
|
||||||
scrollSaveDisabled?: boolean;
|
scrollSaveDisabled?: boolean;
|
||||||
|
|
@ -215,7 +215,7 @@ function NearbyStationRow({ station }: { station: NearbyStation }) {
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium text-warm-900 dark:text-warm-100">
|
<div className="truncate text-sm font-medium text-warm-900 dark:text-warm-100">
|
||||||
{stationDisplayName(station.name)}
|
{station.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-warm-500 dark:text-warm-400">{ts(station.category)}</div>
|
<div className="text-xs text-warm-500 dark:text-warm-400">{ts(station.category)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -242,7 +242,6 @@ export default function AreaPane({
|
||||||
shareCode,
|
shareCode,
|
||||||
isGroupExpanded,
|
isGroupExpanded,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onLoadCrimeRecords,
|
|
||||||
scrollTopRef,
|
scrollTopRef,
|
||||||
scrollRestoreKey,
|
scrollRestoreKey,
|
||||||
scrollSaveDisabled,
|
scrollSaveDisabled,
|
||||||
|
|
@ -298,22 +297,61 @@ export default function AreaPane({
|
||||||
// When the user expands a group, scroll the bottom of the newly opened content
|
// 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
|
// into view so the whole group is revealed without manual scrolling. The group
|
||||||
// header stays pinned at the top via its sticky positioning.
|
// header stays pinned at the top via its sticky positioning.
|
||||||
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const setScrollNode = useCallback(
|
const setScrollNode = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
setContainer(node);
|
scrollContainerRef.current = node;
|
||||||
scrollRef(node);
|
scrollRef(node);
|
||||||
},
|
},
|
||||||
[setContainer, scrollRef]
|
[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(
|
const handleToggleGroup = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
const willExpand = !isGroupExpanded(name);
|
const willExpand = !isGroupExpanded(name);
|
||||||
onToggleGroup(name);
|
onToggleGroup(name);
|
||||||
revealGroupOnToggle(name, willExpand);
|
setGroupToReveal(willExpand ? { name } : null);
|
||||||
},
|
},
|
||||||
[isGroupExpanded, onToggleGroup, revealGroupOnToggle]
|
[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();
|
||||||
|
|
@ -325,21 +363,26 @@ export default function AreaPane({
|
||||||
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
|
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
|
||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
|
||||||
// Crime-by-year series, keyed by the bare crime type (e.g. "Burglary").
|
// Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary").
|
||||||
const crimeByYearByType = useMemo(() => {
|
// We also index by the configured feature name (with " (avg/yr)" suffix) so the
|
||||||
|
// metric-row renderer can look it up using the feature name it already has.
|
||||||
|
const crimeByYearByFeatureName = useMemo(() => {
|
||||||
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_by_year']>[number]>();
|
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_by_year']>[number]>();
|
||||||
for (const entry of stats?.crime_by_year ?? []) {
|
for (const entry of stats?.crime_by_year ?? []) {
|
||||||
map.set(entry.name, entry);
|
map.set(entry.name, entry);
|
||||||
|
map.set(`${entry.name} (avg/yr)`, entry);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
|
||||||
// Per-rate-feature outcode/sector averages, keyed by the FULL rate-feature name
|
// Per-crime-type outcode/sector averages, keyed by both the bare crime type
|
||||||
// (e.g. "Burglary (/yr, 7y)") the comparison is computed against.
|
// 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 crimeAreaAvgByName = useMemo(() => {
|
||||||
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_area_averages']>[number]>();
|
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_area_averages']>[number]>();
|
||||||
for (const entry of stats?.crime_area_averages ?? []) {
|
for (const entry of stats?.crime_area_averages ?? []) {
|
||||||
map.set(entry.name, entry);
|
map.set(entry.name, entry);
|
||||||
|
map.set(`${entry.name} (avg/yr)`, entry);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
|
@ -397,11 +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
|
<div ref={setScrollNode} onScroll={onScroll} className="flex-1 overflow-y-auto">
|
||||||
ref={setScrollNode}
|
|
||||||
onScroll={onScroll}
|
|
||||||
className="flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]"
|
|
||||||
>
|
|
||||||
<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">
|
||||||
|
|
@ -576,7 +615,13 @@ export default function AreaPane({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.name} ref={registerGroup(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}
|
||||||
|
|
@ -586,385 +631,367 @@ export default function AreaPane({
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="divide-y divide-warm-100 px-3 py-1 dark:divide-navy-800">
|
<div className="divide-y divide-warm-100 px-3 py-1 dark:divide-navy-800">
|
||||||
{showNearbyStations && <NearbyStationsCard location={hexagonLocation} />}
|
{showNearbyStations && <NearbyStationsCard location={hexagonLocation} />}
|
||||||
{group.name === 'Crime' ? (
|
{stackedCharts?.map((chart) => {
|
||||||
<CrimeGroupBody
|
const segments = chart.components
|
||||||
stats={stats}
|
.map((name) => ({
|
||||||
numericByName={numericByName}
|
name,
|
||||||
crimeAreaAvgByName={crimeAreaAvgByName}
|
value: numericByName.get(name)?.mean ?? 0,
|
||||||
crimeByYearByType={crimeByYearByType}
|
}))
|
||||||
globalFeatureByName={globalFeatureByName}
|
.filter((s) => s.value > 0);
|
||||||
onShowInfo={setInfoFeature}
|
|
||||||
onLoadCrimeRecords={onLoadCrimeRecords}
|
|
||||||
selectionKey={`${isPostcode ? 'postcode' : 'hexagon'}:${hexagonId}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{stackedCharts?.map((chart) => {
|
|
||||||
const segments = chart.components
|
|
||||||
.map((name) => ({
|
|
||||||
name,
|
|
||||||
value: numericByName.get(name)?.mean ?? 0,
|
|
||||||
}))
|
|
||||||
.filter((s) => s.value > 0);
|
|
||||||
|
|
||||||
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
||||||
const displaySegments = isPercentageComposition
|
const displaySegments = isPercentageComposition
|
||||||
? normalizePercentageSegments(segments)
|
? normalizePercentageSegments(segments)
|
||||||
: segments;
|
: segments;
|
||||||
|
|
||||||
const aggregateStats = chart.feature
|
const aggregateStats = chart.feature
|
||||||
? numericByName.get(chart.feature)
|
? numericByName.get(chart.feature)
|
||||||
: undefined;
|
: undefined;
|
||||||
const total = aggregateStats
|
const total = aggregateStats
|
||||||
? aggregateStats.mean
|
? aggregateStats.mean
|
||||||
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
||||||
|
|
||||||
// Use rateFeature (e.g. a percentage) for display if available
|
// Use rateFeature (e.g. per-1k) for display if available
|
||||||
const rateStats = chart.rateFeature
|
const rateStats = chart.rateFeature
|
||||||
? numericByName.get(chart.rateFeature)
|
? numericByName.get(chart.rateFeature)
|
||||||
: undefined;
|
: undefined;
|
||||||
const displayValue = isPercentageComposition
|
const displayValue = isPercentageComposition
|
||||||
? 100
|
? 100
|
||||||
: rateStats
|
: rateStats
|
||||||
? rateStats.mean
|
? rateStats.mean
|
||||||
: total;
|
: total;
|
||||||
|
|
||||||
// Use rateFeature for info popup and national average when available
|
// Use rateFeature for info popup and national average when available
|
||||||
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
||||||
const featureMeta = infoFeatureName
|
const featureMeta = infoFeatureName
|
||||||
? globalFeatureByName.get(infoFeatureName)
|
? globalFeatureByName.get(infoFeatureName)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const globalMean = featureMeta?.histogram
|
const globalMean = featureMeta?.histogram
|
||||||
? calculateHistogramMean(featureMeta.histogram)
|
? calculateHistogramMean(featureMeta.histogram)
|
||||||
: undefined;
|
: undefined;
|
||||||
const crimeAreaAvg = infoFeatureName
|
const crimeAreaAvg = infoFeatureName
|
||||||
? crimeAreaAvgByName.get(infoFeatureName)
|
? crimeAreaAvgByName.get(infoFeatureName)
|
||||||
: undefined;
|
: undefined;
|
||||||
// For crime, prefer the exact national mean so it shares
|
// For crime, prefer the exact national mean so it shares
|
||||||
// one estimator with the outcode/sector/selection values.
|
// one estimator with the outcode/sector/selection values.
|
||||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||||
|
|
||||||
// Crime metrics get a number line comparing this area to
|
// Crime metrics get a number line comparing this area to
|
||||||
// its sector / outcode / nation instead of a flat list.
|
// its sector / outcode / nation instead of a flat list.
|
||||||
const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
|
const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
|
||||||
? (
|
? (
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kind: 'area',
|
kind: 'area',
|
||||||
label: t('areaPane.thisArea'),
|
label: t('areaPane.thisArea'),
|
||||||
value: displayValue,
|
value: displayValue,
|
||||||
},
|
},
|
||||||
nationalAvg != null
|
nationalAvg != null
|
||||||
? {
|
? {
|
||||||
kind: 'national',
|
kind: 'national',
|
||||||
label: t('areaPane.national'),
|
label: t('areaPane.national'),
|
||||||
value: nationalAvg,
|
value: nationalAvg,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
crimeAreaAvg.outcode != null
|
crimeAreaAvg.outcode != null
|
||||||
? {
|
? {
|
||||||
kind: 'outcode',
|
kind: 'outcode',
|
||||||
label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'),
|
label: stats?.crime_outcode ?? t('areaPane.outcodeAvg'),
|
||||||
value: crimeAreaAvg.outcode,
|
value: crimeAreaAvg.outcode,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
crimeAreaAvg.sector != null
|
crimeAreaAvg.sector != null
|
||||||
? {
|
? {
|
||||||
kind: 'sector',
|
kind: 'sector',
|
||||||
label: stats?.crime_sector ?? t('areaPane.sectorAvg'),
|
label: stats?.crime_sector ?? t('areaPane.sectorAvg'),
|
||||||
value: crimeAreaAvg.sector,
|
value: crimeAreaAvg.sector,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
] as (NumberLinePoint | null)[]
|
] as (NumberLinePoint | null)[]
|
||||||
).filter((p): p is NumberLinePoint => p !== null)
|
).filter((p): p is NumberLinePoint => p !== null)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
|
|
||||||
const crimeSeries = chart.feature
|
const crimeSeries = chart.feature
|
||||||
? crimeByYearByType.get(chart.feature)
|
? crimeByYearByFeatureName.get(chart.feature)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ts(chart.label)}
|
key={ts(chart.label)}
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-baseline mb-1.5">
|
<div className="flex justify-between items-baseline mb-1.5">
|
||||||
{featureMeta ? (
|
{featureMeta ? (
|
||||||
<FeatureLabel
|
<FeatureLabel
|
||||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||||
onShowInfo={setInfoFeature}
|
onShowInfo={setInfoFeature}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
wrap
|
wrap
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
||||||
{ts(chart.label)}
|
{ts(chart.label)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0">
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
{formatValue(displayValue)}
|
{formatValue(displayValue)}
|
||||||
{chart.unit ? ` ${chart.unit}` : ''}
|
{chart.unit ? ` ${chart.unit}` : ''}
|
||||||
</span>
|
</span>
|
||||||
{/* Crime shows its national/outcode/sector
|
{/* Crime shows its national/outcode/sector
|
||||||
comparison on the number line below; other
|
comparison on the number line below; other
|
||||||
stacked metrics keep the inline national avg. */}
|
stacked metrics keep the inline national avg. */}
|
||||||
{!crimeAreaAvg && nationalAvg != null && (
|
{!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(nationalAvg)}
|
{t('areaPane.nationalAvg')}: {formatValue(nationalAvg)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StackedBarChart
|
|
||||||
segments={displaySegments}
|
|
||||||
total={total}
|
|
||||||
colorMap={
|
|
||||||
chart.label === 'Political vote share'
|
|
||||||
? PARTY_FEATURE_COLORS
|
|
||||||
: STACKED_SEGMENT_COLORS
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{numberLinePoints.length >= 2 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<NumberLine points={numberLinePoints} format={formatValue} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{crimeSeries && crimeSeries.points.length > 1 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<CrimeYearChart
|
|
||||||
points={crimeSeries.points}
|
|
||||||
latestAvailableYear={stats?.crime_latest_year}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
<StackedBarChart
|
||||||
{(() => {
|
segments={displaySegments}
|
||||||
const stackedFeatureNames = new Set<string>(
|
total={total}
|
||||||
stackedCharts?.flatMap((c) =>
|
colorMap={
|
||||||
[c.feature, c.rateFeature, ...c.components].filter(
|
chart.label === 'Political vote share'
|
||||||
(s): s is string => Boolean(s)
|
? PARTY_FEATURE_COLORS
|
||||||
)
|
: STACKED_SEGMENT_COLORS
|
||||||
) ?? []
|
}
|
||||||
);
|
/>
|
||||||
return group.features
|
{numberLinePoints.length >= 2 && (
|
||||||
.filter(
|
<div className="mt-2">
|
||||||
(f) =>
|
<NumberLine points={numberLinePoints} format={formatValue} />
|
||||||
!stackedFeatureNames.has(f.name) &&
|
</div>
|
||||||
!stackedEnumFeatureNames.has(f.name)
|
)}
|
||||||
)
|
{crimeSeries && crimeSeries.points.length > 1 && (
|
||||||
.map((feature) => {
|
<div className="mt-2">
|
||||||
const numericStats = numericByName.get(feature.name);
|
<CrimeYearChart
|
||||||
const enumStats = enumByName.get(feature.name);
|
points={crimeSeries.points}
|
||||||
|
latestAvailableYear={stats?.crime_latest_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(() => {
|
||||||
|
const stackedFeatureNames = new Set<string>(
|
||||||
|
stackedCharts?.flatMap((c) =>
|
||||||
|
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
|
||||||
|
Boolean(s)
|
||||||
|
)
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
return group.features
|
||||||
|
.filter(
|
||||||
|
(f) =>
|
||||||
|
!stackedFeatureNames.has(f.name) &&
|
||||||
|
!stackedEnumFeatureNames.has(f.name)
|
||||||
|
)
|
||||||
|
.map((feature) => {
|
||||||
|
const numericStats = numericByName.get(feature.name);
|
||||||
|
const enumStats = enumByName.get(feature.name);
|
||||||
|
|
||||||
if (numericStats) {
|
if (numericStats) {
|
||||||
const globalFeature = globalFeatureByName.get(feature.name);
|
const globalFeature = globalFeatureByName.get(feature.name);
|
||||||
const globalHistogram = globalFeature?.histogram;
|
const globalHistogram = globalFeature?.histogram;
|
||||||
const globalMean = globalHistogram
|
const globalMean = globalHistogram
|
||||||
? calculateHistogramMean(globalHistogram)
|
? calculateHistogramMean(globalHistogram)
|
||||||
: undefined;
|
: undefined;
|
||||||
const crimeSeries = crimeByYearByType.get(feature.name);
|
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
|
||||||
const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
|
const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
|
||||||
// National avg is shown for every metric here as a
|
// National avg is shown for every metric here as a
|
||||||
// tooltip; for crime metrics the outcode and sector
|
// tooltip; for crime metrics the outcode and sector
|
||||||
// averages join it on their own lines, and the exact
|
// averages join it on their own lines, and the exact
|
||||||
// national mean is preferred over the histogram one.
|
// national mean is preferred over the histogram one.
|
||||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
||||||
const valueTitle =
|
const valueTitle =
|
||||||
[
|
[
|
||||||
nationalAvg != null
|
nationalAvg != null
|
||||||
? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
|
? `${t('areaPane.nationalAvg')}: ${formatValue(nationalAvg)}`
|
||||||
: null,
|
: null,
|
||||||
crimeAreaAvg?.outcode != null
|
crimeAreaAvg?.outcode != null
|
||||||
? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
|
? `${t('areaPane.outcodeAvg')}: ${formatValue(crimeAreaAvg.outcode)}`
|
||||||
: null,
|
: null,
|
||||||
crimeAreaAvg?.sector != null
|
crimeAreaAvg?.sector != null
|
||||||
? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
|
? `${t('areaPane.sectorAvg')}: ${formatValue(crimeAreaAvg.sector)}`
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n') || undefined;
|
.join('\n') || undefined;
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricRow
|
|
||||||
key={feature.name}
|
|
||||||
label={
|
|
||||||
<MetricFeatureLabel
|
|
||||||
feature={feature}
|
|
||||||
onShowInfo={setInfoFeature}
|
|
||||||
aboutLabel={t('filters.aboutData')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
chart={
|
|
||||||
crimeSeries && crimeSeries.points.length > 1 ? (
|
|
||||||
<CrimeYearChart
|
|
||||||
points={crimeSeries.points}
|
|
||||||
latestAvailableYear={stats?.crime_latest_year}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
numericStats.histogram &&
|
|
||||||
(globalHistogram ? (
|
|
||||||
<DualHistogram
|
|
||||||
localCounts={numericStats.histogram.counts}
|
|
||||||
globalCounts={globalHistogram.counts}
|
|
||||||
p1={numericStats.histogram.p1}
|
|
||||||
p99={numericStats.histogram.p99}
|
|
||||||
globalMean={globalMean}
|
|
||||||
meanLabel={t('areaPane.nationalAvg')}
|
|
||||||
formatLabel={(v) =>
|
|
||||||
formatFilterValue(
|
|
||||||
v,
|
|
||||||
feature.suffix === '%'
|
|
||||||
? { raw: feature.raw, suffix: feature.suffix }
|
|
||||||
: feature.raw
|
|
||||||
)
|
|
||||||
}
|
|
||||||
integerAxisLabels={feature.step === 1}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DualHistogram
|
|
||||||
localCounts={numericStats.histogram.counts}
|
|
||||||
globalCounts={numericStats.histogram.counts}
|
|
||||||
p1={numericStats.histogram.p1}
|
|
||||||
p99={numericStats.histogram.p99}
|
|
||||||
formatLabel={(v) =>
|
|
||||||
formatFilterValue(
|
|
||||||
v,
|
|
||||||
feature.suffix === '%'
|
|
||||||
? { raw: feature.raw, suffix: feature.suffix }
|
|
||||||
: feature.raw
|
|
||||||
)
|
|
||||||
}
|
|
||||||
integerAxisLabels={feature.step === 1}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
value={formatValue(numericStats.mean, feature)}
|
|
||||||
valueTitle={valueTitle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enumStats) {
|
|
||||||
const globalFeature = globalFeatureByName.get(feature.name);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feature.name}
|
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
||||||
>
|
|
||||||
<FeatureLabel
|
|
||||||
feature={feature}
|
|
||||||
onShowInfo={setInfoFeature}
|
|
||||||
wrap
|
|
||||||
/>
|
|
||||||
<EnumBarChart
|
|
||||||
counts={enumStats.counts}
|
|
||||||
globalCounts={globalFeature?.counts}
|
|
||||||
featureName={feature.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
{stackedEnumCharts?.map((chart) => {
|
|
||||||
const featureMeta = chart.feature
|
|
||||||
? globalFeatureByName.get(chart.feature)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (chart.components.length === 1) {
|
|
||||||
const stats = enumByName.get(chart.components[0]);
|
|
||||||
if (!stats) return null;
|
|
||||||
|
|
||||||
const segments = chart.valueOrder
|
|
||||||
.map((value) => ({
|
|
||||||
name: value,
|
|
||||||
value: stats.counts[value] ?? 0,
|
|
||||||
}))
|
|
||||||
.filter((s) => s.value > 0);
|
|
||||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
|
||||||
if (total === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<MetricRow
|
||||||
key={ts(chart.label)}
|
key={feature.name}
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
label={
|
||||||
>
|
<MetricFeatureLabel
|
||||||
<div className="flex justify-between items-baseline mb-1.5">
|
feature={feature}
|
||||||
{featureMeta ? (
|
onShowInfo={setInfoFeature}
|
||||||
<FeatureLabel
|
aboutLabel={t('filters.aboutData')}
|
||||||
feature={featureMeta}
|
/>
|
||||||
onShowInfo={setInfoFeature}
|
}
|
||||||
className="mr-2"
|
chart={
|
||||||
wrap
|
crimeSeries && crimeSeries.points.length > 1 ? (
|
||||||
|
<CrimeYearChart
|
||||||
|
points={crimeSeries.points}
|
||||||
|
latestAvailableYear={stats?.crime_latest_year}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
numericStats.histogram &&
|
||||||
{ts(chart.label)}
|
(globalHistogram ? (
|
||||||
</span>
|
<DualHistogram
|
||||||
)}
|
localCounts={numericStats.histogram.counts}
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
globalCounts={globalHistogram.counts}
|
||||||
{total.toLocaleString()}
|
p1={numericStats.histogram.p1}
|
||||||
</span>
|
p99={numericStats.histogram.p99}
|
||||||
</div>
|
globalMean={globalMean}
|
||||||
<StackedBarChart
|
meanLabel={t('areaPane.nationalAvg')}
|
||||||
segments={segments}
|
formatLabel={(v) =>
|
||||||
total={total}
|
formatFilterValue(
|
||||||
colorMap={Object.fromEntries(
|
v,
|
||||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
feature.suffix === '%'
|
||||||
)}
|
? { raw: feature.raw, suffix: feature.suffix }
|
||||||
|
: feature.raw
|
||||||
|
)
|
||||||
|
}
|
||||||
|
integerAxisLabels={feature.step === 1}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DualHistogram
|
||||||
|
localCounts={numericStats.histogram.counts}
|
||||||
|
globalCounts={numericStats.histogram.counts}
|
||||||
|
p1={numericStats.histogram.p1}
|
||||||
|
p99={numericStats.histogram.p99}
|
||||||
|
formatLabel={(v) =>
|
||||||
|
formatFilterValue(
|
||||||
|
v,
|
||||||
|
feature.suffix === '%'
|
||||||
|
? { raw: feature.raw, suffix: feature.suffix }
|
||||||
|
: feature.raw
|
||||||
|
)
|
||||||
|
}
|
||||||
|
integerAxisLabels={feature.step === 1}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={formatValue(numericStats.mean, feature)}
|
||||||
|
valueTitle={valueTitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enumStats) {
|
||||||
|
const globalFeature = globalFeatureByName.get(feature.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.name}
|
||||||
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
|
>
|
||||||
|
<FeatureLabel
|
||||||
|
feature={feature}
|
||||||
|
onShowInfo={setInfoFeature}
|
||||||
|
wrap
|
||||||
|
/>
|
||||||
|
<EnumBarChart
|
||||||
|
counts={enumStats.counts}
|
||||||
|
globalCounts={globalFeature?.counts}
|
||||||
|
featureName={feature.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const components = chart.components
|
return null;
|
||||||
.map((name) => {
|
});
|
||||||
const stats = enumByName.get(name);
|
})()}
|
||||||
return stats ? { label: name, stats } : null;
|
{stackedEnumCharts?.map((chart) => {
|
||||||
})
|
const featureMeta = chart.feature
|
||||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
? globalFeatureByName.get(chart.feature)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (components.length === 0) return null;
|
if (chart.components.length === 1) {
|
||||||
|
const stats = enumByName.get(chart.components[0]);
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
return (
|
const segments = chart.valueOrder
|
||||||
<div
|
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||||
key={ts(chart.label)}
|
.filter((s) => s.value > 0);
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||||
>
|
if (total === 0) return null;
|
||||||
<div className="mb-1.5">
|
|
||||||
{featureMeta ? (
|
return (
|
||||||
<FeatureLabel
|
<div
|
||||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
key={ts(chart.label)}
|
||||||
onShowInfo={setInfoFeature}
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
wrap
|
>
|
||||||
/>
|
<div className="flex justify-between items-baseline mb-1.5">
|
||||||
) : (
|
{featureMeta ? (
|
||||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
<FeatureLabel
|
||||||
{ts(chart.label)}
|
feature={featureMeta}
|
||||||
</span>
|
onShowInfo={setInfoFeature}
|
||||||
)}
|
className="mr-2"
|
||||||
</div>
|
wrap
|
||||||
<StackedEnumChart
|
/>
|
||||||
components={components}
|
) : (
|
||||||
valueOrder={chart.valueOrder}
|
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
||||||
valueColors={chart.valueColors}
|
{ts(chart.label)}
|
||||||
/>
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
<StackedBarChart
|
||||||
})}
|
segments={segments}
|
||||||
</>
|
total={total}
|
||||||
)}
|
colorMap={Object.fromEntries(
|
||||||
|
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = chart.components
|
||||||
|
.map((name) => {
|
||||||
|
const stats = enumByName.get(name);
|
||||||
|
return stats ? { label: name, stats } : null;
|
||||||
|
})
|
||||||
|
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||||
|
|
||||||
|
if (components.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ts(chart.label)}
|
||||||
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
|
>
|
||||||
|
<div className="mb-1.5">
|
||||||
|
{featureMeta ? (
|
||||||
|
<FeatureLabel
|
||||||
|
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||||
|
onShowInfo={setInfoFeature}
|
||||||
|
wrap
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||||
|
{ts(chart.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StackedEnumChart
|
||||||
|
components={components}
|
||||||
|
valueOrder={chart.valueOrder}
|
||||||
|
valueColors={chart.valueColors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ts } from '../../i18n/server';
|
|
||||||
import type {
|
|
||||||
CrimeAreaAverage,
|
|
||||||
CrimeRecordsResponse,
|
|
||||||
CrimeYearStats,
|
|
||||||
FeatureMeta,
|
|
||||||
HexagonStatsResponse,
|
|
||||||
NumericFeatureStats,
|
|
||||||
} from '../../types';
|
|
||||||
import { STACKED_GROUPS, STACKED_SEGMENT_COLORS } from '../../lib/consts';
|
|
||||||
import { formatValue, calculateHistogramMean } from '../../lib/format';
|
|
||||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
|
||||||
import StackedBarChart from './StackedBarChart';
|
|
||||||
import NumberLine, { type NumberLinePoint } from './NumberLine';
|
|
||||||
import CrimeYearChart from './CrimeYearChart';
|
|
||||||
import CrimeRecordsSection from './CrimeRecordsSection';
|
|
||||||
|
|
||||||
type CrimeWindow = '7y' | '2y';
|
|
||||||
|
|
||||||
/** Strip the " (/yr, 7y|2y)" suffix to the bare crime type. */
|
|
||||||
function bareType(featureName: string): string {
|
|
||||||
return featureName.replace(/ \(\/yr, \dy\)$/, '');
|
|
||||||
}
|
|
||||||
/** Re-target a crime-feature name at the chosen averaging window. */
|
|
||||||
function toWindow(featureName: string, window: CrimeWindow): string {
|
|
||||||
return featureName.replace(/(\/yr, )\dy/, `$1${window}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Segment colours keyed by the BARE crime type. The stacked bar then labels each
|
|
||||||
* segment with the clean crime name (e.g. "Burglary") and keeps one stable colour
|
|
||||||
* regardless of whether the 7-year or 2-year window is selected.
|
|
||||||
*/
|
|
||||||
const SEGMENT_COLOR_BY_BARE: Record<string, string> = Object.fromEntries(
|
|
||||||
Object.entries(STACKED_SEGMENT_COLORS).map(([name, color]) => [bareType(name), color])
|
|
||||||
);
|
|
||||||
|
|
||||||
interface CrimeGroupBodyProps {
|
|
||||||
stats: HexagonStatsResponse;
|
|
||||||
numericByName: Map<string, NumericFeatureStats>;
|
|
||||||
/** Keyed by the FULL crime-feature name (e.g. "Burglary (/yr, 7y)"). */
|
|
||||||
crimeAreaAvgByName: Map<string, CrimeAreaAverage>;
|
|
||||||
/** Keyed by the bare crime type (e.g. "Burglary"). */
|
|
||||||
crimeByYearByType: Map<string, CrimeYearStats>;
|
|
||||||
globalFeatureByName: Map<string, FeatureMeta>;
|
|
||||||
onShowInfo: (feature: FeatureMeta) => void;
|
|
||||||
onLoadCrimeRecords?: (offset: number) => Promise<CrimeRecordsResponse>;
|
|
||||||
selectionKey: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CrimeGroupBody({
|
|
||||||
stats,
|
|
||||||
numericByName,
|
|
||||||
crimeAreaAvgByName,
|
|
||||||
crimeByYearByType,
|
|
||||||
globalFeatureByName,
|
|
||||||
onShowInfo,
|
|
||||||
onLoadCrimeRecords,
|
|
||||||
selectionKey,
|
|
||||||
}: CrimeGroupBodyProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// One selector drives every value and comparison in this group.
|
|
||||||
const [crimeWindow, setCrimeWindow] = useState<CrimeWindow>('7y');
|
|
||||||
|
|
||||||
const fmtCount = (value?: number) => (value == null ? '—' : formatValue(value));
|
|
||||||
|
|
||||||
const rollupCards = STACKED_GROUPS.Crime ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between gap-2 py-2">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
|
||||||
{t('areaPane.crimeWindowLabel')}
|
|
||||||
</span>
|
|
||||||
<div className="grid grid-cols-2 gap-0.5 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
|
||||||
{(['7y', '2y'] as const).map((w) => {
|
|
||||||
const active = crimeWindow === w;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={w}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={active}
|
|
||||||
onClick={() => setCrimeWindow(w)}
|
|
||||||
className={`rounded px-3 py-1 text-xs font-medium ${
|
|
||||||
active
|
|
||||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
|
||||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(w === '7y' ? 'areaPane.crimeWindow7y' : 'areaPane.crimeWindow2y')}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rollupCards.map((card) => {
|
|
||||||
if (!card.feature) return null;
|
|
||||||
const bare = bareType(card.feature);
|
|
||||||
const windowFeature = toWindow(card.feature, crimeWindow);
|
|
||||||
const rate = numericByName.get(windowFeature)?.mean;
|
|
||||||
|
|
||||||
const segments = card.components
|
|
||||||
.map((name) => ({
|
|
||||||
name: bareType(name),
|
|
||||||
value: numericByName.get(toWindow(name, crimeWindow))?.mean ?? 0,
|
|
||||||
}))
|
|
||||||
.filter((s) => s.value > 0);
|
|
||||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
|
||||||
if (rate == null && total === 0) return null;
|
|
||||||
|
|
||||||
const displayValue = rate ?? total;
|
|
||||||
// Info popup / icon stay anchored to the 7-year feature so the metric
|
|
||||||
// definition is stable across windows; only the numbers change.
|
|
||||||
const featureMeta = globalFeatureByName.get(card.feature);
|
|
||||||
const globalMean = featureMeta?.histogram
|
|
||||||
? calculateHistogramMean(featureMeta.histogram)
|
|
||||||
: undefined;
|
|
||||||
const crimeAreaAvg = crimeAreaAvgByName.get(windowFeature);
|
|
||||||
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
|
|
||||||
const cardTitle = t('areaPane.crimeCardTitle', { name: ts(card.label) });
|
|
||||||
|
|
||||||
const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
|
|
||||||
? (
|
|
||||||
[
|
|
||||||
{ kind: 'area', label: t('areaPane.thisArea'), value: displayValue },
|
|
||||||
nationalAvg != null
|
|
||||||
? { kind: 'national', label: t('areaPane.national'), value: nationalAvg }
|
|
||||||
: null,
|
|
||||||
crimeAreaAvg.outcode != null
|
|
||||||
? {
|
|
||||||
kind: 'outcode',
|
|
||||||
label: stats.crime_outcode ?? t('areaPane.outcodeAvg'),
|
|
||||||
value: crimeAreaAvg.outcode,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
crimeAreaAvg.sector != null
|
|
||||||
? {
|
|
||||||
kind: 'sector',
|
|
||||||
label: stats.crime_sector ?? t('areaPane.sectorAvg'),
|
|
||||||
value: crimeAreaAvg.sector,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
] as (NumberLinePoint | null)[]
|
|
||||||
).filter((p): p is NumberLinePoint => p !== null)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const series = crimeByYearByType.get(bare);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={card.label} className="rounded bg-warm-50 p-2 dark:bg-warm-800">
|
|
||||||
<div className="mb-1.5 flex items-baseline justify-between gap-2">
|
|
||||||
{featureMeta ? (
|
|
||||||
<FeatureLabel
|
|
||||||
feature={{ ...featureMeta, name: ts(card.label) }}
|
|
||||||
label={cardTitle}
|
|
||||||
onShowInfo={onShowInfo}
|
|
||||||
className="mr-2"
|
|
||||||
wrap
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
|
||||||
{cardTitle}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="shrink-0 text-right">
|
|
||||||
<span className="whitespace-nowrap text-xs font-semibold text-teal-700 dark:text-teal-400">
|
|
||||||
{fmtCount(displayValue)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{total > 0 && (
|
|
||||||
<StackedBarChart segments={segments} total={total} colorMap={SEGMENT_COLOR_BY_BARE} />
|
|
||||||
)}
|
|
||||||
{numberLinePoints.length >= 2 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<NumberLine points={numberLinePoints} format={formatValue} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{series && series.points.length > 1 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<CrimeYearChart
|
|
||||||
points={series.points}
|
|
||||||
latestAvailableYear={stats.crime_latest_year}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{onLoadCrimeRecords && (
|
|
||||||
<CrimeRecordsSection
|
|
||||||
selectionKey={selectionKey}
|
|
||||||
total={stats.crime_total_records}
|
|
||||||
onLoad={onLoadCrimeRecords}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ts } from '../../i18n/server';
|
|
||||||
import type { CrimeIncident, CrimeRecordsResponse } from '../../types';
|
|
||||||
import { isAbortError, logNonAbortError } from '../../lib/api';
|
|
||||||
|
|
||||||
function formatMonth(month: string, locale: string): string {
|
|
||||||
const [year, m] = month.split('-').map(Number);
|
|
||||||
if (!year || !m) return month;
|
|
||||||
return new Date(Date.UTC(year, m - 1, 1)).toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CrimeRecordsSectionProps {
|
|
||||||
/** Identity of the current selection; changing it resets the list. */
|
|
||||||
selectionKey: string | null;
|
|
||||||
/** Total records for the selection (from stats); the section hides when 0. */
|
|
||||||
total?: number;
|
|
||||||
onLoad: (offset: number) => Promise<CrimeRecordsResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CrimeRecordsSection({
|
|
||||||
selectionKey,
|
|
||||||
total,
|
|
||||||
onLoad,
|
|
||||||
}: CrimeRecordsSectionProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [records, setRecords] = useState<CrimeIncident[]>([]);
|
|
||||||
const [loadedTotal, setLoadedTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
// Bumped whenever the selection changes; in-flight responses for a previous
|
|
||||||
// selection are ignored when they resolve.
|
|
||||||
const selectionIdRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectionIdRef.current += 1;
|
|
||||||
setOpen(false);
|
|
||||||
setRecords([]);
|
|
||||||
setLoadedTotal(0);
|
|
||||||
setLoading(false);
|
|
||||||
setError(false);
|
|
||||||
}, [selectionKey]);
|
|
||||||
|
|
||||||
if (!total || total <= 0) return null;
|
|
||||||
|
|
||||||
const fetchPage = async (offset: number) => {
|
|
||||||
const myId = selectionIdRef.current;
|
|
||||||
setLoading(true);
|
|
||||||
setError(false);
|
|
||||||
try {
|
|
||||||
const data = await onLoad(offset);
|
|
||||||
if (myId !== selectionIdRef.current) return;
|
|
||||||
setRecords((prev) => (offset === 0 ? data.records : [...prev, ...data.records]));
|
|
||||||
setLoadedTotal(data.total);
|
|
||||||
} catch (err) {
|
|
||||||
if (isAbortError(err)) return;
|
|
||||||
logNonAbortError('Failed to fetch crime records', err);
|
|
||||||
if (myId === selectionIdRef.current) setError(true);
|
|
||||||
} finally {
|
|
||||||
if (myId === selectionIdRef.current) setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
const next = !open;
|
|
||||||
setOpen(next);
|
|
||||||
if (next && records.length === 0 && !loading) fetchPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shownTotal = loadedTotal || total;
|
|
||||||
const canLoadMore = records.length < loadedTotal;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleToggle}
|
|
||||||
className="flex w-full items-center justify-between gap-2 rounded px-1 py-1 text-left hover:bg-warm-100 dark:hover:bg-navy-800"
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-warm-900 dark:text-warm-100">
|
|
||||||
{open ? t('areaPane.crimeRecordsHide') : t('areaPane.crimeRecordsToggle')}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-warm-500 dark:text-warm-400">
|
|
||||||
{t('areaPane.crimeRecordsCount', { count: shownTotal })}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="mt-1.5">
|
|
||||||
{error ? (
|
|
||||||
<div className="py-2 text-sm text-rose-600 dark:text-rose-400">
|
|
||||||
{t('areaPane.crimeRecordsError')}
|
|
||||||
</div>
|
|
||||||
) : records.length === 0 && loading ? (
|
|
||||||
<div className="flex items-center gap-2 py-2 text-sm text-warm-500 dark:text-warm-400">
|
|
||||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent" />
|
|
||||||
{t('areaPane.crimeRecordsLoading')}
|
|
||||||
</div>
|
|
||||||
) : records.length === 0 ? (
|
|
||||||
<div className="py-2 text-sm text-warm-500 dark:text-warm-400">
|
|
||||||
{t('areaPane.crimeRecordsEmpty')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ol className="divide-y divide-warm-100 dark:divide-navy-800">
|
|
||||||
{records.map((record, index) => (
|
|
||||||
<li
|
|
||||||
key={`${record.month}-${record.lat}-${record.lon}-${index}`}
|
|
||||||
className="py-1.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="min-w-0 break-words text-[13px] font-medium text-warm-900 dark:text-warm-100">
|
|
||||||
{ts(record.type)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs tabular-nums text-warm-500 dark:text-warm-400">
|
|
||||||
{formatMonth(record.month, i18n.language)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs text-warm-500 dark:text-warm-400">
|
|
||||||
{record.location ?? t('areaPane.crimeNoLocation')}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-warm-400 dark:text-warm-500">
|
|
||||||
{record.outcome ?? t('areaPane.crimeOutcomeUnknown')}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
{canLoadMore && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => !loading && fetchPage(records.length)}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-2 w-full rounded border border-warm-200 px-2 py-1.5 text-xs font-medium text-warm-700 hover:bg-warm-100 disabled:opacity-50 dark:border-navy-700 dark:text-warm-200 dark:hover:bg-navy-800"
|
|
||||||
>
|
|
||||||
{loading ? t('areaPane.crimeRecordsLoading') : t('areaPane.crimeRecordsLoadMore')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -82,12 +82,7 @@ export default function CrimeYearChart({ points, latestAvailableYear }: CrimeYea
|
||||||
r={1.6}
|
r={1.6}
|
||||||
className="fill-rose-700 dark:fill-rose-300"
|
className="fill-rose-700 dark:fill-rose-300"
|
||||||
>
|
>
|
||||||
<title>
|
<title>{`${p.year}: ${p.count.toFixed(1)}/yr`}</title>
|
||||||
{t('areaPane.crimeYearPointTooltip', {
|
|
||||||
year: p.year,
|
|
||||||
rate: p.count.toFixed(1),
|
|
||||||
})}
|
|
||||||
</title>
|
|
||||||
</circle>
|
</circle>
|
||||||
))}
|
))}
|
||||||
<text
|
<text
|
||||||
|
|
|
||||||
71
frontend/src/components/map/DemoLocationPrompt.tsx
Normal file
71
frontend/src/components/map/DemoLocationPrompt.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||||
|
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||||
|
|
||||||
|
interface DemoLocationPromptProps {
|
||||||
|
/** True while a geolocation request is in flight. */
|
||||||
|
locating: boolean;
|
||||||
|
/** Error message to show (e.g. permission denied), or null. */
|
||||||
|
error: string | null;
|
||||||
|
onUseLocation: () => void;
|
||||||
|
onUseDefault: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-visit demo prompt: offers to centre the map on the visitor's GPS location
|
||||||
|
* (with consent) or start at the Canary Wharf default. Rendered over the map (which
|
||||||
|
* is already showing Canary Wharf), so declining is just a dismiss.
|
||||||
|
*/
|
||||||
|
export default function DemoLocationPrompt({
|
||||||
|
locating,
|
||||||
|
error,
|
||||||
|
onUseLocation,
|
||||||
|
onUseDefault,
|
||||||
|
}: DemoLocationPromptProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dialogRef = useModalA11y();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="demo-location-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden outline-none"
|
||||||
|
>
|
||||||
|
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-7 text-center">
|
||||||
|
<h2 id="demo-location-title" className="text-xl font-bold text-white mb-2">
|
||||||
|
{t('demoLocation.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-warm-300 text-sm">{t('demoLocation.body')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-6 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUseLocation}
|
||||||
|
disabled={locating}
|
||||||
|
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{locating && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||||
|
{locating ? t('demoLocation.locating') : t('demoLocation.useLocation')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUseDefault}
|
||||||
|
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||||
|
>
|
||||||
|
{t('demoLocation.useDefault')}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-center text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ export default function ExternalSearchLinks({
|
||||||
const radiusMiles = location.isPostcode
|
const radiusMiles = location.isPostcode
|
||||||
? POSTCODE_RADIUS_MILES
|
? POSTCODE_RADIUS_MILES
|
||||||
: (H3_RADIUS_MILES[location.resolution] ?? 1);
|
: (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||||
const label = t('externalSearch.radiusMi', { count: radiusMiles });
|
const label = `${radiusMiles}mi radius`;
|
||||||
|
|
||||||
if (!urls) return null;
|
if (!urls) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,6 @@ interface FeatureBrowserProps {
|
||||||
onClearOpenInfoFeature?: () => void;
|
onClearOpenInfoFeature?: () => void;
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
||||||
/** Registers a group wrapper so an expanded group can be scrolled into view. */
|
|
||||||
registerGroup?: (name: string) => (node: HTMLDivElement | null) => void;
|
|
||||||
/** Notifies the reveal-on-expand mechanism when a group is toggled. */
|
|
||||||
onGroupToggle?: (name: string, willExpand: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeatureBrowser({
|
export default function FeatureBrowser({
|
||||||
|
|
@ -50,8 +46,6 @@ export default function FeatureBrowser({
|
||||||
onClearOpenInfoFeature,
|
onClearOpenInfoFeature,
|
||||||
travelTimeEntries: _travelTimeEntries,
|
travelTimeEntries: _travelTimeEntries,
|
||||||
onAddTravelTimeEntry,
|
onAddTravelTimeEntry,
|
||||||
registerGroup,
|
|
||||||
onGroupToggle,
|
|
||||||
}: FeatureBrowserProps) {
|
}: FeatureBrowserProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const modes = useTranslatedModes();
|
const modes = useTranslatedModes();
|
||||||
|
|
@ -121,15 +115,11 @@ export default function FeatureBrowser({
|
||||||
{mergedGrouped.map((group) => {
|
{mergedGrouped.map((group) => {
|
||||||
const isExpanded = isSearching || isGroupExpanded(group.name);
|
const isExpanded = isSearching || isGroupExpanded(group.name);
|
||||||
return (
|
return (
|
||||||
<div key={group.name} className="shrink-0" ref={registerGroup?.(group.name)}>
|
<div key={group.name} className="shrink-0">
|
||||||
<CollapsibleGroupHeader
|
<CollapsibleGroupHeader
|
||||||
name={group.name}
|
name={group.name}
|
||||||
expanded={isExpanded}
|
expanded={isExpanded}
|
||||||
onToggle={() => {
|
onToggle={() => toggleGroup(group.name)}
|
||||||
const willExpand = !isExpanded;
|
|
||||||
toggleGroup(group.name);
|
|
||||||
onGroupToggle?.(group.name, willExpand);
|
|
||||||
}}
|
|
||||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,6 @@ import {
|
||||||
isSpecificCrimeFeatureName,
|
isSpecificCrimeFeatureName,
|
||||||
isSpecificCrimeFilterName,
|
isSpecificCrimeFilterName,
|
||||||
} from '../../lib/crime-filter';
|
} from '../../lib/crime-filter';
|
||||||
import {
|
|
||||||
CRIME_SEVERITY_FILTER_NAMES,
|
|
||||||
getCrimeSeverityFeatureName,
|
|
||||||
getCrimeSeverityFilterMeta,
|
|
||||||
getCrimeSeverityFilterName,
|
|
||||||
getDefaultCrimeSeverityFeatureName,
|
|
||||||
isCrimeSeverityFeatureName,
|
|
||||||
isCrimeSeverityFilterName,
|
|
||||||
type CrimeSeverityFilterName,
|
|
||||||
} from '../../lib/crime-severity-filter';
|
|
||||||
import {
|
import {
|
||||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||||
getDefaultElectionVoteShareFeatureName,
|
getDefaultElectionVoteShareFeatureName,
|
||||||
|
|
@ -47,22 +37,7 @@ import {
|
||||||
isEthnicityFeatureName,
|
isEthnicityFeatureName,
|
||||||
isEthnicityFilterName,
|
isEthnicityFilterName,
|
||||||
} from '../../lib/ethnicity-filter';
|
} from '../../lib/ethnicity-filter';
|
||||||
import {
|
import { isQualificationFeatureName } from '../../lib/qualification-filter';
|
||||||
QUALIFICATIONS_FILTER_NAME,
|
|
||||||
getDefaultQualificationFeatureName,
|
|
||||||
getQualificationFeatureName,
|
|
||||||
getQualificationFilterMeta,
|
|
||||||
isQualificationFeatureName,
|
|
||||||
isQualificationFilterName,
|
|
||||||
} from '../../lib/qualification-filter';
|
|
||||||
import {
|
|
||||||
TENURE_FILTER_NAME,
|
|
||||||
getDefaultTenureFeatureName,
|
|
||||||
getTenureFeatureName,
|
|
||||||
getTenureFilterMeta,
|
|
||||||
isTenureFeatureName,
|
|
||||||
isTenureFilterName,
|
|
||||||
} from '../../lib/tenure-filter';
|
|
||||||
import {
|
import {
|
||||||
SCHOOL_FILTER_NAME,
|
SCHOOL_FILTER_NAME,
|
||||||
getDefaultSchoolFeatureName,
|
getDefaultSchoolFeatureName,
|
||||||
|
|
@ -207,33 +182,6 @@ export default memo(function Filters({
|
||||||
[features]
|
[features]
|
||||||
);
|
);
|
||||||
const ethnicityMeta = useMemo(() => getEthnicityFilterMeta(features), [features]);
|
const ethnicityMeta = useMemo(() => getEthnicityFilterMeta(features), [features]);
|
||||||
const defaultQualificationFeatureName = useMemo(
|
|
||||||
() => getDefaultQualificationFeatureName(features),
|
|
||||||
[features]
|
|
||||||
);
|
|
||||||
const qualificationMeta = useMemo(() => getQualificationFilterMeta(features), [features]);
|
|
||||||
const defaultTenureFeatureName = useMemo(() => getDefaultTenureFeatureName(features), [features]);
|
|
||||||
const tenureMeta = useMemo(() => getTenureFilterMeta(features), [features]);
|
|
||||||
const crimeSeverityMetas = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.fromEntries(
|
|
||||||
CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [
|
|
||||||
filterName,
|
|
||||||
getCrimeSeverityFilterMeta(features, filterName),
|
|
||||||
])
|
|
||||||
) as Record<CrimeSeverityFilterName, FeatureMeta>,
|
|
||||||
[features]
|
|
||||||
);
|
|
||||||
const defaultCrimeSeverityFeatureNames = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.fromEntries(
|
|
||||||
CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [
|
|
||||||
filterName,
|
|
||||||
getDefaultCrimeSeverityFeatureName(features, filterName),
|
|
||||||
])
|
|
||||||
) as Record<CrimeSeverityFilterName, string | null>,
|
|
||||||
[features]
|
|
||||||
);
|
|
||||||
const defaultPoiDistanceFeatureName = useMemo(
|
const defaultPoiDistanceFeatureName = useMemo(
|
||||||
() => getDefaultPoiDistanceFeatureName(features),
|
() => getDefaultPoiDistanceFeatureName(features),
|
||||||
[features]
|
[features]
|
||||||
|
|
@ -308,18 +256,6 @@ export default memo(function Filters({
|
||||||
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
|
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
|
||||||
});
|
});
|
||||||
}, [filters, features, specificCrimeMeta]);
|
}, [filters, features, specificCrimeMeta]);
|
||||||
const crimeSeverityFilterItems = useMemo(() => {
|
|
||||||
return Object.keys(filters)
|
|
||||||
.filter(isCrimeSeverityFilterName)
|
|
||||||
.map((name) => {
|
|
||||||
const backendName = getCrimeSeverityFeatureName(name);
|
|
||||||
const filterName = getCrimeSeverityFilterName(name) ?? CRIME_SEVERITY_FILTER_NAMES[0];
|
|
||||||
const backendFeature = backendName
|
|
||||||
? features.find((feature) => feature.name === backendName)
|
|
||||||
: undefined;
|
|
||||||
return { ...(backendFeature ?? crimeSeverityMetas[filterName]), name, group: 'Crime' };
|
|
||||||
});
|
|
||||||
}, [filters, features, crimeSeverityMetas]);
|
|
||||||
const electionVoteShareFilterItems = useMemo(() => {
|
const electionVoteShareFilterItems = useMemo(() => {
|
||||||
return Object.keys(filters)
|
return Object.keys(filters)
|
||||||
.filter(isElectionVoteShareFilterName)
|
.filter(isElectionVoteShareFilterName)
|
||||||
|
|
@ -342,28 +278,6 @@ export default memo(function Filters({
|
||||||
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' };
|
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' };
|
||||||
});
|
});
|
||||||
}, [filters, features, ethnicityMeta]);
|
}, [filters, features, ethnicityMeta]);
|
||||||
const qualificationFilterItems = useMemo(() => {
|
|
||||||
return Object.keys(filters)
|
|
||||||
.filter(isQualificationFilterName)
|
|
||||||
.map((name) => {
|
|
||||||
const backendName = getQualificationFeatureName(name);
|
|
||||||
const backendFeature = backendName
|
|
||||||
? features.find((feature) => feature.name === backendName)
|
|
||||||
: undefined;
|
|
||||||
return { ...(backendFeature ?? qualificationMeta), name, group: 'Neighbours' };
|
|
||||||
});
|
|
||||||
}, [filters, features, qualificationMeta]);
|
|
||||||
const tenureFilterItems = useMemo(() => {
|
|
||||||
return Object.keys(filters)
|
|
||||||
.filter(isTenureFilterName)
|
|
||||||
.map((name) => {
|
|
||||||
const backendName = getTenureFeatureName(name);
|
|
||||||
const backendFeature = backendName
|
|
||||||
? features.find((feature) => feature.name === backendName)
|
|
||||||
: undefined;
|
|
||||||
return { ...(backendFeature ?? tenureMeta), name, group: 'Neighbours' };
|
|
||||||
});
|
|
||||||
}, [filters, features, tenureMeta]);
|
|
||||||
const poiDistanceFilterItems = useMemo(() => {
|
const poiDistanceFilterItems = useMemo(() => {
|
||||||
return Object.keys(filters)
|
return Object.keys(filters)
|
||||||
.filter(isPoiDistanceFilterName)
|
.filter(isPoiDistanceFilterName)
|
||||||
|
|
@ -386,8 +300,6 @@ export default memo(function Filters({
|
||||||
let insertedSpecificCrimeFilter = false;
|
let insertedSpecificCrimeFilter = false;
|
||||||
let insertedElectionVoteShareFilter = false;
|
let insertedElectionVoteShareFilter = false;
|
||||||
let insertedEthnicityFilter = false;
|
let insertedEthnicityFilter = false;
|
||||||
let insertedQualificationFilter = false;
|
|
||||||
let insertedTenureFilter = false;
|
|
||||||
const insertedPoiFilters = new Set<PoiFilterName>();
|
const insertedPoiFilters = new Set<PoiFilterName>();
|
||||||
const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => {
|
const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -399,17 +311,6 @@ export default memo(function Filters({
|
||||||
insertedPoiFilters.add(filterName);
|
insertedPoiFilters.add(filterName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const insertedCrimeSeverityFilters = new Set<CrimeSeverityFilterName>();
|
|
||||||
const maybeInsertCrimeSeverityFilter = (filterName: CrimeSeverityFilterName | null) => {
|
|
||||||
if (
|
|
||||||
filterName &&
|
|
||||||
defaultCrimeSeverityFeatureNames[filterName] &&
|
|
||||||
!insertedCrimeSeverityFilters.has(filterName)
|
|
||||||
) {
|
|
||||||
result.push(crimeSeverityMetas[filterName]);
|
|
||||||
insertedCrimeSeverityFilters.add(filterName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
if (feature.group === 'Transport') {
|
if (feature.group === 'Transport') {
|
||||||
|
|
@ -429,12 +330,6 @@ export default memo(function Filters({
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// "Serious crime" and "Minor crime" each fold their 7y/2y windows into one
|
|
||||||
// card with a period toggle (no variant dropdown — a single feature each).
|
|
||||||
if (isCrimeSeverityFeatureName(feature.name)) {
|
|
||||||
maybeInsertCrimeSeverityFilter(getCrimeSeverityFilterName(feature.name));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isElectionVoteShareFeatureName(feature.name)) {
|
if (isElectionVoteShareFeatureName(feature.name)) {
|
||||||
if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) {
|
if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) {
|
||||||
result.push(electionVoteShareMeta);
|
result.push(electionVoteShareMeta);
|
||||||
|
|
@ -449,28 +344,14 @@ export default memo(function Filters({
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// The seven qualification bands fold into one "Qualifications" filter
|
|
||||||
// whose dropdown picks a band, rather than seven separate sliders.
|
|
||||||
if (isQualificationFeatureName(feature.name)) {
|
|
||||||
if (defaultQualificationFeatureName && !insertedQualificationFilter) {
|
|
||||||
result.push(qualificationMeta);
|
|
||||||
insertedQualificationFilter = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// The three tenure categories fold into one "Tenure" filter
|
|
||||||
// whose dropdown picks a category, rather than three separate sliders.
|
|
||||||
if (isTenureFeatureName(feature.name)) {
|
|
||||||
if (defaultTenureFeatureName && !insertedTenureFilter) {
|
|
||||||
result.push(tenureMeta);
|
|
||||||
insertedTenureFilter = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isPoiFilterFeatureName(feature.name)) {
|
if (isPoiFilterFeatureName(feature.name)) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -482,16 +363,10 @@ export default memo(function Filters({
|
||||||
schoolMeta,
|
schoolMeta,
|
||||||
defaultSpecificCrimeFeatureName,
|
defaultSpecificCrimeFeatureName,
|
||||||
specificCrimeMeta,
|
specificCrimeMeta,
|
||||||
defaultCrimeSeverityFeatureNames,
|
|
||||||
crimeSeverityMetas,
|
|
||||||
defaultElectionVoteShareFeatureName,
|
defaultElectionVoteShareFeatureName,
|
||||||
electionVoteShareMeta,
|
electionVoteShareMeta,
|
||||||
defaultEthnicityFeatureName,
|
defaultEthnicityFeatureName,
|
||||||
ethnicityMeta,
|
ethnicityMeta,
|
||||||
defaultQualificationFeatureName,
|
|
||||||
qualificationMeta,
|
|
||||||
defaultTenureFeatureName,
|
|
||||||
tenureMeta,
|
|
||||||
defaultPoiFilterFeatureNames,
|
defaultPoiFilterFeatureNames,
|
||||||
poiFilterMetas,
|
poiFilterMetas,
|
||||||
]);
|
]);
|
||||||
|
|
@ -501,8 +376,6 @@ export default memo(function Filters({
|
||||||
let insertedSpecificCrimeFilters = false;
|
let insertedSpecificCrimeFilters = false;
|
||||||
let insertedElectionVoteShareFilters = false;
|
let insertedElectionVoteShareFilters = false;
|
||||||
let insertedEthnicityFilters = false;
|
let insertedEthnicityFilters = false;
|
||||||
let insertedQualificationFilters = false;
|
|
||||||
let insertedTenureFilters = false;
|
|
||||||
const insertedPoiFilters = new Set<PoiFilterName>();
|
const insertedPoiFilters = new Set<PoiFilterName>();
|
||||||
const insertPoiFilterItems = (filterName: PoiFilterName | null) => {
|
const insertPoiFilterItems = (filterName: PoiFilterName | null) => {
|
||||||
if (!filterName || insertedPoiFilters.has(filterName)) return;
|
if (!filterName || insertedPoiFilters.has(filterName)) return;
|
||||||
|
|
@ -511,16 +384,6 @@ export default memo(function Filters({
|
||||||
);
|
);
|
||||||
insertedPoiFilters.add(filterName);
|
insertedPoiFilters.add(filterName);
|
||||||
};
|
};
|
||||||
const insertedCrimeSeverityFilters = new Set<CrimeSeverityFilterName>();
|
|
||||||
const insertCrimeSeverityFilterItems = (filterName: CrimeSeverityFilterName | null) => {
|
|
||||||
if (!filterName || insertedCrimeSeverityFilters.has(filterName)) return;
|
|
||||||
result.push(
|
|
||||||
...crimeSeverityFilterItems.filter(
|
|
||||||
(item) => getCrimeSeverityFilterName(item.name) === filterName
|
|
||||||
)
|
|
||||||
);
|
|
||||||
insertedCrimeSeverityFilters.add(filterName);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
if (feature.group === 'Transport') {
|
if (feature.group === 'Transport') {
|
||||||
|
|
@ -540,10 +403,6 @@ export default memo(function Filters({
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isCrimeSeverityFeatureName(feature.name)) {
|
|
||||||
insertCrimeSeverityFilterItems(getCrimeSeverityFilterName(feature.name));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isElectionVoteShareFeatureName(feature.name)) {
|
if (isElectionVoteShareFeatureName(feature.name)) {
|
||||||
if (!insertedElectionVoteShareFilters) {
|
if (!insertedElectionVoteShareFilters) {
|
||||||
result.push(...electionVoteShareFilterItems);
|
result.push(...electionVoteShareFilterItems);
|
||||||
|
|
@ -558,20 +417,6 @@ export default memo(function Filters({
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isQualificationFeatureName(feature.name)) {
|
|
||||||
if (!insertedQualificationFilters) {
|
|
||||||
result.push(...qualificationFilterItems);
|
|
||||||
insertedQualificationFilters = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isTenureFeatureName(feature.name)) {
|
|
||||||
if (!insertedTenureFilters) {
|
|
||||||
result.push(...tenureFilterItems);
|
|
||||||
insertedTenureFilters = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isPoiFilterFeatureName(feature.name)) {
|
if (isPoiFilterFeatureName(feature.name)) {
|
||||||
insertPoiFilterItems(getPoiFilterName(feature.name));
|
insertPoiFilterItems(getPoiFilterName(feature.name));
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -585,11 +430,8 @@ export default memo(function Filters({
|
||||||
enabledFeatures,
|
enabledFeatures,
|
||||||
schoolFilterItems,
|
schoolFilterItems,
|
||||||
specificCrimeFilterItems,
|
specificCrimeFilterItems,
|
||||||
crimeSeverityFilterItems,
|
|
||||||
electionVoteShareFilterItems,
|
electionVoteShareFilterItems,
|
||||||
ethnicityFilterItems,
|
ethnicityFilterItems,
|
||||||
qualificationFilterItems,
|
|
||||||
tenureFilterItems,
|
|
||||||
poiDistanceFilterItems,
|
poiDistanceFilterItems,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -618,15 +460,10 @@ export default memo(function Filters({
|
||||||
(name: string): string | null => {
|
(name: string): string | null => {
|
||||||
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools';
|
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools';
|
||||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime';
|
if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime';
|
||||||
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
|
|
||||||
return crimeSeverityMetas[name as CrimeSeverityFilterName].group ?? 'Crime';
|
|
||||||
}
|
|
||||||
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
||||||
return electionVoteShareMeta.group ?? 'Neighbours';
|
return electionVoteShareMeta.group ?? 'Neighbours';
|
||||||
}
|
}
|
||||||
if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours';
|
if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours';
|
||||||
if (name === QUALIFICATIONS_FILTER_NAME) return qualificationMeta.group ?? 'Neighbours';
|
|
||||||
if (name === TENURE_FILTER_NAME) return tenureMeta.group ?? 'Neighbours';
|
|
||||||
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
||||||
return poiFilterMetas[name as PoiFilterName].group ?? null;
|
return poiFilterMetas[name as PoiFilterName].group ?? null;
|
||||||
}
|
}
|
||||||
|
|
@ -635,11 +472,8 @@ export default memo(function Filters({
|
||||||
[
|
[
|
||||||
electionVoteShareMeta.group,
|
electionVoteShareMeta.group,
|
||||||
ethnicityMeta.group,
|
ethnicityMeta.group,
|
||||||
qualificationMeta.group,
|
|
||||||
tenureMeta.group,
|
|
||||||
features,
|
features,
|
||||||
poiFilterMetas,
|
poiFilterMetas,
|
||||||
crimeSeverityMetas,
|
|
||||||
schoolMeta.group,
|
schoolMeta.group,
|
||||||
specificCrimeMeta.group,
|
specificCrimeMeta.group,
|
||||||
]
|
]
|
||||||
|
|
@ -680,28 +514,6 @@ export default memo(function Filters({
|
||||||
onAddFilter(ETHNICITIES_FILTER_NAME);
|
onAddFilter(ETHNICITIES_FILTER_NAME);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (name === QUALIFICATIONS_FILTER_NAME) {
|
|
||||||
if (!defaultQualificationFeatureName) return;
|
|
||||||
queueActiveFilterScroll(
|
|
||||||
QUALIFICATIONS_FILTER_NAME,
|
|
||||||
getAddFilterGroupName(QUALIFICATIONS_FILTER_NAME)
|
|
||||||
);
|
|
||||||
onAddFilter(QUALIFICATIONS_FILTER_NAME);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (name === TENURE_FILTER_NAME) {
|
|
||||||
if (!defaultTenureFeatureName) return;
|
|
||||||
queueActiveFilterScroll(TENURE_FILTER_NAME, getAddFilterGroupName(TENURE_FILTER_NAME));
|
|
||||||
onAddFilter(TENURE_FILTER_NAME);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
|
|
||||||
const severityFilterName = name as CrimeSeverityFilterName;
|
|
||||||
if (!defaultCrimeSeverityFeatureNames[severityFilterName]) return;
|
|
||||||
queueActiveFilterScroll(severityFilterName, getAddFilterGroupName(severityFilterName));
|
|
||||||
onAddFilter(severityFilterName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
||||||
const filterName = name as PoiFilterName;
|
const filterName = name as PoiFilterName;
|
||||||
if (!defaultPoiFilterFeatureNames[filterName]) return;
|
if (!defaultPoiFilterFeatureNames[filterName]) return;
|
||||||
|
|
@ -716,11 +528,8 @@ export default memo(function Filters({
|
||||||
[
|
[
|
||||||
defaultSchoolFeatureName,
|
defaultSchoolFeatureName,
|
||||||
defaultSpecificCrimeFeatureName,
|
defaultSpecificCrimeFeatureName,
|
||||||
defaultCrimeSeverityFeatureNames,
|
|
||||||
defaultElectionVoteShareFeatureName,
|
defaultElectionVoteShareFeatureName,
|
||||||
defaultEthnicityFeatureName,
|
defaultEthnicityFeatureName,
|
||||||
defaultQualificationFeatureName,
|
|
||||||
defaultTenureFeatureName,
|
|
||||||
defaultPoiFilterFeatureNames,
|
defaultPoiFilterFeatureNames,
|
||||||
getAddFilterGroupName,
|
getAddFilterGroupName,
|
||||||
onAddFilter,
|
onAddFilter,
|
||||||
|
|
@ -877,11 +686,8 @@ export default memo(function Filters({
|
||||||
...features,
|
...features,
|
||||||
schoolMeta,
|
schoolMeta,
|
||||||
specificCrimeMeta,
|
specificCrimeMeta,
|
||||||
...Object.values(crimeSeverityMetas),
|
|
||||||
electionVoteShareMeta,
|
electionVoteShareMeta,
|
||||||
ethnicityMeta,
|
ethnicityMeta,
|
||||||
qualificationMeta,
|
|
||||||
tenureMeta,
|
|
||||||
poiDistanceMeta,
|
poiDistanceMeta,
|
||||||
transportDistanceMeta,
|
transportDistanceMeta,
|
||||||
poiCount2KmMeta,
|
poiCount2KmMeta,
|
||||||
|
|
@ -890,11 +696,8 @@ export default memo(function Filters({
|
||||||
pinnedFeature={pinnedFeature}
|
pinnedFeature={pinnedFeature}
|
||||||
defaultSchoolFeatureName={defaultSchoolFeatureName}
|
defaultSchoolFeatureName={defaultSchoolFeatureName}
|
||||||
defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName}
|
defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName}
|
||||||
defaultCrimeSeverityFeatureNames={defaultCrimeSeverityFeatureNames}
|
|
||||||
defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName}
|
defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName}
|
||||||
defaultEthnicityFeatureName={defaultEthnicityFeatureName}
|
defaultEthnicityFeatureName={defaultEthnicityFeatureName}
|
||||||
defaultQualificationFeatureName={defaultQualificationFeatureName}
|
|
||||||
defaultTenureFeatureName={defaultTenureFeatureName}
|
|
||||||
defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames}
|
defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames}
|
||||||
openInfoFeature={openInfoFeature}
|
openInfoFeature={openInfoFeature}
|
||||||
travelTimeEntries={travelTimeEntries}
|
travelTimeEntries={travelTimeEntries}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,8 @@ import { formatValue } from '../../lib/format';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
|
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
|
||||||
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
|
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
|
||||||
import { getCrimeSeverityFeatureName } from '../../lib/crime-severity-filter';
|
|
||||||
import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
|
import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
|
||||||
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
|
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
|
||||||
import { getQualificationFeatureName } from '../../lib/qualification-filter';
|
|
||||||
import { getTenureFeatureName } from '../../lib/tenure-filter';
|
|
||||||
import {
|
import {
|
||||||
POI_DISTANCE_FILTER_NAME,
|
POI_DISTANCE_FILTER_NAME,
|
||||||
getPoiDistanceFeatureName,
|
getPoiDistanceFeatureName,
|
||||||
|
|
@ -55,20 +52,14 @@ export default memo(function HoverCard({
|
||||||
for (const name of activeFilterNames.slice(0, 4)) {
|
for (const name of activeFilterNames.slice(0, 4)) {
|
||||||
const schoolBackendName = getSchoolBackendFeatureName(name);
|
const schoolBackendName = getSchoolBackendFeatureName(name);
|
||||||
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
|
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
|
||||||
const crimeSeverityFeatureName = getCrimeSeverityFeatureName(name);
|
|
||||||
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
|
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
|
||||||
const ethnicityFeatureName = getEthnicityFeatureName(name);
|
const ethnicityFeatureName = getEthnicityFeatureName(name);
|
||||||
const qualificationFeatureName = getQualificationFeatureName(name);
|
|
||||||
const tenureFeatureName = getTenureFeatureName(name);
|
|
||||||
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
|
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
|
||||||
const backendName =
|
const backendName =
|
||||||
schoolBackendName ??
|
schoolBackendName ??
|
||||||
specificCrimeFeatureName ??
|
specificCrimeFeatureName ??
|
||||||
crimeSeverityFeatureName ??
|
|
||||||
electionVoteShareFeatureName ??
|
electionVoteShareFeatureName ??
|
||||||
ethnicityFeatureName ??
|
ethnicityFeatureName ??
|
||||||
qualificationFeatureName ??
|
|
||||||
tenureFeatureName ??
|
|
||||||
poiDistanceFeatureName ??
|
poiDistanceFeatureName ??
|
||||||
name;
|
name;
|
||||||
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
|
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ vi.mock('react-i18next', () => ({
|
||||||
if (key === 'travel.noBuses') return 'No buses';
|
if (key === 'travel.noBuses') return 'No buses';
|
||||||
if (key === 'areaPane.walk') return 'Walk';
|
if (key === 'areaPane.walk') return 'Walk';
|
||||||
if (key === 'areaPane.cycle') return 'Cycle';
|
if (key === 'areaPane.cycle') return 'Cycle';
|
||||||
if (key === 'journey.bus') return 'Bus';
|
|
||||||
if (key === 'journey.lineSuffix') return 'line';
|
|
||||||
if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps';
|
if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps';
|
||||||
if (key === 'areaPane.noJourneyData') return 'No journey data';
|
if (key === 'areaPane.noJourneyData') return 'No journey data';
|
||||||
return key;
|
return key;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TFunction } from 'i18next';
|
|
||||||
import type { JourneyLeg } from '../../types';
|
import type { JourneyLeg } from '../../types';
|
||||||
import { resolveTransitVariant, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
import { resolveTransitVariant, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
|
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
|
||||||
|
|
@ -107,25 +106,15 @@ function stripId(label: string): string {
|
||||||
return label.replace(/\s+\([A-Za-z0-9]+\)$/, '');
|
return label.replace(/\s+\([A-Za-z0-9]+\)$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRouteDisplay(
|
function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } {
|
||||||
mode: string,
|
|
||||||
t: TFunction
|
|
||||||
): { label: string; color: string; darkText: boolean } {
|
|
||||||
const clean = stripId(mode);
|
const clean = stripId(mode);
|
||||||
const known = ROUTE_COLORS[clean];
|
const known = ROUTE_COLORS[clean];
|
||||||
if (known) {
|
if (known) {
|
||||||
const label =
|
const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`;
|
||||||
NON_TUBE_NAMES.has(clean) || clean.includes('line')
|
|
||||||
? clean
|
|
||||||
: `${clean} ${t('journey.lineSuffix')}`;
|
|
||||||
return { label, color: known.color, darkText: !!known.darkText };
|
return { label, color: known.color, darkText: !!known.darkText };
|
||||||
}
|
}
|
||||||
if (/^\d+[A-Za-z]?$/.test(clean.trim())) {
|
if (/^\d+[A-Za-z]?$/.test(clean.trim())) {
|
||||||
return {
|
return { label: `Bus ${clean}`, color: '#0d9488', darkText: false };
|
||||||
label: `${t('journey.bus')} ${clean}`,
|
|
||||||
color: '#0d9488',
|
|
||||||
darkText: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { label: clean, color: '#6b7280', darkText: false };
|
return { label: clean, color: '#6b7280', darkText: false };
|
||||||
}
|
}
|
||||||
|
|
@ -214,8 +203,7 @@ function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteBadge({ mode }: { mode: string }) {
|
function RouteBadge({ mode }: { mode: string }) {
|
||||||
const { t } = useTranslation();
|
const { label, color, darkText } = getRouteDisplay(mode);
|
||||||
const { label, color, darkText } = getRouteDisplay(mode, t);
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center text-[10px] font-bold px-1.5 py-px rounded-sm leading-tight tracking-wide"
|
className="inline-flex items-center text-[10px] font-bold px-1.5 py-px rounded-sm leading-tight tracking-wide"
|
||||||
|
|
@ -254,7 +242,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { color } = getRouteDisplay(leg.mode, t);
|
const { color } = getRouteDisplay(leg.mode);
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex flex-col items-center w-4 mr-2">
|
<div className="flex flex-col items-center w-4 mr-2">
|
||||||
|
|
|
||||||
|
|
@ -19,21 +19,15 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n
|
||||||
|
|
||||||
export const ListingPopupSingleContent = memo(function ListingPopupSingleContent({
|
export const ListingPopupSingleContent = memo(function ListingPopupSingleContent({
|
||||||
listing,
|
listing,
|
||||||
clickedUrls,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
}: {
|
||||||
listing: ActualListing;
|
listing: ActualListing;
|
||||||
clickedUrls: Set<string>;
|
|
||||||
onOpen: (url: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const visited = clickedUrls.has(listing.listing_url);
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={listing.listing_url}
|
href={listing.listing_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => onOpen(listing.listing_url)}
|
|
||||||
className="block px-3 py-2"
|
className="block px-3 py-2"
|
||||||
>
|
>
|
||||||
{listing.asking_price != null && (
|
{listing.asking_price != null && (
|
||||||
|
|
@ -63,7 +57,7 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
|
||||||
)}
|
)}
|
||||||
{listing.floor_area_sqm != null && (
|
{listing.floor_area_sqm != null && (
|
||||||
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
|
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
|
||||||
{Math.round(listing.floor_area_sqm)} {t('common.sqm')}
|
{Math.round(listing.floor_area_sqm)} sqm
|
||||||
{listing.asking_price_per_sqm != null
|
{listing.asking_price_per_sqm != null
|
||||||
? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm`
|
? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm`
|
||||||
: ''}
|
: ''}
|
||||||
|
|
@ -78,11 +72,8 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
|
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
|
||||||
{visited && (
|
Open listing ↗
|
||||||
<span className="text-violet-600 dark:text-violet-400">✓ {t('listing.viewed')}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-teal-600 dark:text-teal-400">{t('listing.openListing')} ↗</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
@ -91,13 +82,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
|
||||||
export const ListingClusterPopupContent = memo(function ListingClusterPopupContent({
|
export const ListingClusterPopupContent = memo(function ListingClusterPopupContent({
|
||||||
count,
|
count,
|
||||||
listings,
|
listings,
|
||||||
clickedUrls,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
}: {
|
||||||
count: number;
|
count: number;
|
||||||
listings: ActualListing[];
|
listings: ActualListing[];
|
||||||
clickedUrls: Set<string>;
|
|
||||||
onOpen: (url: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const visibleCount = listings.length;
|
const visibleCount = listings.length;
|
||||||
|
|
@ -105,41 +92,32 @@ export const ListingClusterPopupContent = memo(function ListingClusterPopupConte
|
||||||
<div>
|
<div>
|
||||||
<div className="border-b border-warm-200 px-3 py-2 dark:border-warm-700">
|
<div className="border-b border-warm-200 px-3 py-2 dark:border-warm-700">
|
||||||
<div className="text-base font-bold text-red-600 dark:text-red-400">
|
<div className="text-base font-bold text-red-600 dark:text-red-400">
|
||||||
{count.toLocaleString()} {t('listing.listings')}
|
{count.toLocaleString()} listings
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-warm-500 dark:text-warm-400">
|
<div className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||||
{visibleCount > 0
|
{visibleCount > 0
|
||||||
? t('listing.showingOf', { visible: visibleCount, count })
|
? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}`
|
||||||
: t('listing.groupedNear')}
|
: 'Grouped near this map position'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{visibleCount > 0 && (
|
{visibleCount > 0 && (
|
||||||
<div className="max-h-80 overflow-y-auto py-1">
|
<div className="max-h-80 overflow-y-auto py-1">
|
||||||
{listings.map((listing, idx) => {
|
{listings.map((listing, idx) => {
|
||||||
const headline = formatListingHeadline(listing, t);
|
const headline = formatListingHeadline(listing, t);
|
||||||
const visited = clickedUrls.has(listing.listing_url);
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={`${listing.listing_url}-${idx}`}
|
key={`${listing.listing_url}-${idx}`}
|
||||||
href={listing.listing_url}
|
href={listing.listing_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => onOpen(listing.listing_url)}
|
|
||||||
className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60"
|
className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="text-sm font-semibold text-teal-700 dark:text-teal-300">
|
||||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-300">
|
{listing.asking_price != null
|
||||||
{listing.asking_price != null
|
? formatListingPrice(listing.asking_price)
|
||||||
? formatListingPrice(listing.asking_price)
|
: 'Listing'}
|
||||||
: t('listing.listing')}
|
|
||||||
</span>
|
|
||||||
{visited && (
|
|
||||||
<span className="shrink-0 text-[10px] font-medium text-violet-600 dark:text-violet-400">
|
|
||||||
✓ Viewed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{headline && (
|
{headline && (
|
||||||
<div className="mt-0.5 truncate text-xs text-warm-700 dark:text-warm-200">
|
<div className="mt-0.5 truncate text-xs text-warm-700 dark:text-warm-200">
|
||||||
|
|
|
||||||
|
|
@ -452,8 +452,6 @@ export default memo(function Map({
|
||||||
visiblePois,
|
visiblePois,
|
||||||
listingPopup,
|
listingPopup,
|
||||||
clearListingPopup,
|
clearListingPopup,
|
||||||
clickedListingUrls,
|
|
||||||
markListingClicked,
|
|
||||||
developmentPopup,
|
developmentPopup,
|
||||||
clearDevelopmentPopup,
|
clearDevelopmentPopup,
|
||||||
hoverPosition,
|
hoverPosition,
|
||||||
|
|
@ -698,17 +696,11 @@ export default memo(function Map({
|
||||||
<CloseIcon className="w-3 h-3" />
|
<CloseIcon className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{listingPopup.mode === 'single' ? (
|
{listingPopup.mode === 'single' ? (
|
||||||
<ListingPopupSingleContent
|
<ListingPopupSingleContent listing={listingPopup.listing} />
|
||||||
listing={listingPopup.listing}
|
|
||||||
clickedUrls={clickedListingUrls}
|
|
||||||
onOpen={markListingClicked}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ListingClusterPopupContent
|
<ListingClusterPopupContent
|
||||||
count={listingPopup.count}
|
count={listingPopup.count}
|
||||||
listings={listingPopup.listings}
|
listings={listingPopup.listings}
|
||||||
clickedUrls={clickedListingUrls}
|
|
||||||
onOpen={markListingClicked}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Component, Fragment, type ReactNode } from 'react';
|
import { Component, Fragment, type ReactNode } from 'react';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
|
|
||||||
import i18n from '../../i18n';
|
|
||||||
import { MapFallback } from './map-page/Fallbacks';
|
import { MapFallback } from './map-page/Fallbacks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -114,20 +113,17 @@ export class MapErrorBoundary extends Component<MapErrorBoundaryProps, MapErrorB
|
||||||
<div className="flex h-full w-full items-center justify-center bg-warm-100 px-6 text-center dark:bg-navy-950">
|
<div className="flex h-full w-full items-center justify-center bg-warm-100 px-6 text-center dark:bg-navy-950">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||||
{i18n.t('map.error.heading', { defaultValue: 'The map ran into a problem' })}
|
The map ran into a problem
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
||||||
{i18n.t('map.error.body', {
|
This can happen when your browser's graphics context is interrupted.
|
||||||
defaultValue:
|
|
||||||
'This can happen when your browser’s graphics context is interrupted.',
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={this.handleManualRetry}
|
onClick={this.handleManualRetry}
|
||||||
className="mt-4 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-400"
|
className="mt-4 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-400"
|
||||||
>
|
>
|
||||||
{i18n.t('map.error.reload', { defaultValue: 'Reload the map' })}
|
Reload the map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,21 @@ import {
|
||||||
resolveTransitVariant,
|
resolveTransitVariant,
|
||||||
travelFieldKey,
|
travelFieldKey,
|
||||||
useTravelTime,
|
useTravelTime,
|
||||||
type TransportMode,
|
|
||||||
} from '../../hooks/useTravelTime';
|
} from '../../hooks/useTravelTime';
|
||||||
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
import {
|
||||||
|
apiUrl,
|
||||||
|
authHeaders,
|
||||||
|
buildFilterString,
|
||||||
|
persistDemoChoice,
|
||||||
|
readDemoChoice,
|
||||||
|
setDemoCenter,
|
||||||
|
} from '../../lib/api';
|
||||||
|
import DemoLocationPrompt from './DemoLocationPrompt';
|
||||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import {
|
import {
|
||||||
DEMO_MAX_FILTERS,
|
DEMO_MAX_FILTERS,
|
||||||
REGISTERED_MAX_FILTERS,
|
DEV_LOCATION,
|
||||||
INITIAL_VIEW_STATE,
|
INITIAL_VIEW_STATE,
|
||||||
POSTCODE_ZOOM_THRESHOLD,
|
POSTCODE_ZOOM_THRESHOLD,
|
||||||
} from '../../lib/consts';
|
} from '../../lib/consts';
|
||||||
|
|
@ -78,6 +85,8 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
|
||||||
|
|
||||||
export type { ExportState } from './map-page/types';
|
export type { ExportState } from './map-page/types';
|
||||||
|
|
||||||
|
declare const __DEV__: boolean;
|
||||||
|
|
||||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||||
const EMPTY_POIS: POI[] = [];
|
const EMPTY_POIS: POI[] = [];
|
||||||
|
|
||||||
|
|
@ -86,6 +95,7 @@ export default function MapPage({
|
||||||
poiCategoryGroups,
|
poiCategoryGroups,
|
||||||
initialFilters,
|
initialFilters,
|
||||||
initialViewState,
|
initialViewState,
|
||||||
|
offerDemoLocation,
|
||||||
initialPOICategories,
|
initialPOICategories,
|
||||||
initialOverlays,
|
initialOverlays,
|
||||||
initialCrimeTypes,
|
initialCrimeTypes,
|
||||||
|
|
@ -145,50 +155,11 @@ export default function MapPage({
|
||||||
initialPostcode ?? null
|
initialPostcode ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter allowance scales with the account tier: anonymous → DEMO_MAX_FILTERS,
|
// Demo (unlicensed) users are limited to DEMO_MAX_FILTERS filters; hitting the
|
||||||
// registered free → REGISTERED_MAX_FILTERS, licensed/admin → unlimited. Hitting the
|
// cap opens the upgrade modal. Licensed users and admins are unlimited.
|
||||||
// cap opens the upgrade modal.
|
|
||||||
const isLoggedIn = !!user;
|
|
||||||
const filtersUnlimited = user?.subscription === 'licensed' || user?.isAdmin === true;
|
const filtersUnlimited = user?.subscription === 'licensed' || user?.isAdmin === true;
|
||||||
const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
|
|
||||||
const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
|
|
||||||
// On a shared link, non-paying users may view and adjust the shared filters but
|
|
||||||
// not add new ones — so a richer shared search can't become a way around the cap.
|
|
||||||
const lockFilterAdds = !filtersUnlimited && !!shareCode;
|
|
||||||
// Which upsell the modal frames: a shared-link block, or a plain filter-cap hit.
|
|
||||||
const upgradeReason: 'filters' | 'shared' = lockFilterAdds ? 'shared' : 'filters';
|
|
||||||
const [filterLimitHit, setFilterLimitHit] = useState(false);
|
const [filterLimitHit, setFilterLimitHit] = useState(false);
|
||||||
const handleFilterLimitReached = useCallback(() => {
|
const handleFilterLimitReached = useCallback(() => setFilterLimitHit(true), []);
|
||||||
trackEvent('Upgrade Modal Shown');
|
|
||||||
setFilterLimitHit(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Travel-time destinations count toward the same cap as feature filters (each one
|
|
||||||
// restricts which properties match). Cap an over-budget initial travel set (from a
|
|
||||||
// bookmarked/shared URL), preferring feature filters, so the first data request
|
|
||||||
// never exceeds the server cap.
|
|
||||||
const cappedInitialTravelTime = useMemo(() => {
|
|
||||||
const initialEntries = initialTravelTime?.entries ?? [];
|
|
||||||
if (filtersUnlimited || initialEntries.length === 0) return initialTravelTime;
|
|
||||||
const travelBudget = Math.max(0, effectiveFilterCap - Object.keys(initialFilters).length);
|
|
||||||
return initialEntries.length > travelBudget
|
|
||||||
? { ...initialTravelTime, entries: initialEntries.slice(0, travelBudget) }
|
|
||||||
: initialTravelTime;
|
|
||||||
}, [filtersUnlimited, effectiveFilterCap, initialFilters, initialTravelTime]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
entries,
|
|
||||||
activeEntries,
|
|
||||||
handleAddEntry,
|
|
||||||
handleRemoveEntry,
|
|
||||||
handleSetDestination,
|
|
||||||
handleSetEntries,
|
|
||||||
handleTimeRangeChange,
|
|
||||||
handleToggleBest,
|
|
||||||
handleToggleNoChange,
|
|
||||||
handleToggleOneChange,
|
|
||||||
handleToggleNoBuses,
|
|
||||||
} = useTravelTime(cappedInitialTravelTime);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
filters,
|
filters,
|
||||||
|
|
@ -212,10 +183,7 @@ export default function MapPage({
|
||||||
} = useFilters({
|
} = useFilters({
|
||||||
initialFilters,
|
initialFilters,
|
||||||
features,
|
features,
|
||||||
filterLimit,
|
filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS,
|
||||||
// Travel-time entries reserve slots from the same cap (see cappedInitialTravelTime).
|
|
||||||
reservedFilterSlots: filtersUnlimited ? 0 : entries.length,
|
|
||||||
lockAdds: lockFilterAdds,
|
|
||||||
onFilterLimitReached: handleFilterLimitReached,
|
onFilterLimitReached: handleFilterLimitReached,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -228,6 +196,20 @@ export default function MapPage({
|
||||||
summary: aiFilterSummary,
|
summary: aiFilterSummary,
|
||||||
} = useAiFilters();
|
} = useAiFilters();
|
||||||
|
|
||||||
|
const {
|
||||||
|
entries,
|
||||||
|
activeEntries,
|
||||||
|
handleAddEntry,
|
||||||
|
handleRemoveEntry,
|
||||||
|
handleSetDestination,
|
||||||
|
handleSetEntries,
|
||||||
|
handleTimeRangeChange,
|
||||||
|
handleToggleBest,
|
||||||
|
handleToggleNoChange,
|
||||||
|
handleToggleOneChange,
|
||||||
|
handleToggleNoBuses,
|
||||||
|
} = useTravelTime(initialTravelTime);
|
||||||
|
|
||||||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||||||
const areaPaneScrollTopRef = useRef(0);
|
const areaPaneScrollTopRef = useRef(0);
|
||||||
const propertiesPaneScrollTopRef = useRef(0);
|
const propertiesPaneScrollTopRef = useRef(0);
|
||||||
|
|
@ -280,22 +262,13 @@ export default function MapPage({
|
||||||
const result = await fetchAiFilters(query, hasContext ? context : undefined);
|
const result = await fetchAiFilters(query, hasContext ? context : undefined);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
// Non-paying users share one cap across feature filters and travel-time
|
handleSetFilters(result.filters);
|
||||||
// filters, so clamp the AI's combined result (features first, then travel).
|
|
||||||
const cappedFilters = filtersUnlimited
|
|
||||||
? result.filters
|
|
||||||
: Object.fromEntries(Object.entries(result.filters).slice(0, effectiveFilterCap));
|
|
||||||
handleSetFilters(cappedFilters);
|
|
||||||
// Filter out variants the UI cannot represent (e.g. transit-one-change*)
|
// Filter out variants the UI cannot represent (e.g. transit-one-change*)
|
||||||
// FIRST so the same filtered list drives both entry state and fly-to.
|
// FIRST so the same filtered list drives both entry state and fly-to.
|
||||||
// Otherwise we'd fly to a destination for a mode the user can't see.
|
// Otherwise we'd fly to a destination for a mode the user can't see.
|
||||||
const representableAll = result.travelTimeFilters
|
const representable = result.travelTimeFilters
|
||||||
.map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
|
.map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
|
||||||
.filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
|
.filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
|
||||||
const travelBudget = filtersUnlimited
|
|
||||||
? representableAll.length
|
|
||||||
: Math.max(0, effectiveFilterCap - Object.keys(cappedFilters).length);
|
|
||||||
const representable = representableAll.slice(0, travelBudget);
|
|
||||||
|
|
||||||
handleSetEntries(
|
handleSetEntries(
|
||||||
representable.map(({ tt, parsed }) => ({
|
representable.map(({ tt, parsed }) => ({
|
||||||
|
|
@ -350,26 +323,12 @@ export default function MapPage({
|
||||||
activeEntries,
|
activeEntries,
|
||||||
fetchAiFilters,
|
fetchAiFilters,
|
||||||
filters,
|
filters,
|
||||||
filtersUnlimited,
|
|
||||||
getMobileMapFlyToOptions,
|
getMobileMapFlyToOptions,
|
||||||
handleSetEntries,
|
handleSetEntries,
|
||||||
handleSetFilters,
|
handleSetFilters,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Travel-time entries share the non-paying user's filter cap, so block adding one
|
|
||||||
// when the combined feature + travel count is already at the limit.
|
|
||||||
const handleAddTravelTimeEntry = useCallback(
|
|
||||||
(mode: TransportMode) => {
|
|
||||||
if (!filtersUnlimited && Object.keys(filters).length + entries.length >= DEMO_MAX_FILTERS) {
|
|
||||||
handleFilterLimitReached();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleAddEntry(mode);
|
|
||||||
},
|
|
||||||
[filtersUnlimited, filters, entries, handleAddEntry, handleFilterLimitReached]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClearAll = useCallback(() => {
|
const handleClearAll = useCallback(() => {
|
||||||
handleSetFilters({});
|
handleSetFilters({});
|
||||||
handleCancelPin();
|
handleCancelPin();
|
||||||
|
|
@ -448,7 +407,6 @@ export default function MapPage({
|
||||||
handlePropertiesTabClick,
|
handlePropertiesTabClick,
|
||||||
handleLoadMoreProperties,
|
handleLoadMoreProperties,
|
||||||
handleCloseSelection,
|
handleCloseSelection,
|
||||||
loadCrimeRecords,
|
|
||||||
selectedPostcodeGeometry,
|
selectedPostcodeGeometry,
|
||||||
handleLocationSearch,
|
handleLocationSearch,
|
||||||
handleCurrentLocationSearch,
|
handleCurrentLocationSearch,
|
||||||
|
|
@ -518,6 +476,93 @@ export default function MapPage({
|
||||||
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
|
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
|
||||||
|
// Hide the upgrade modal as soon as the user dismisses it. We can't rely on
|
||||||
|
// the camera fly alone to close it: flying back to the free/shared zone only
|
||||||
|
// clears `licenseRequired` once the resulting refetch returns non-403, and
|
||||||
|
// when the fly target equals the current view (e.g. "back to shared area"
|
||||||
|
// while already at the shared view) `jumpTo` is a no-op, so no refetch fires
|
||||||
|
// and the modal would otherwise stay stuck open.
|
||||||
|
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
|
||||||
|
const handleZoomToFreeZone = useCallback(() => {
|
||||||
|
setUpgradeModalDismissed(true);
|
||||||
|
setFilterLimitHit(false);
|
||||||
|
// Fly back to the visitor's own free zone. Prefer the server-reported zone
|
||||||
|
// (reflects their chosen demo centre — including a GPS choice made this
|
||||||
|
// session), then a share-return view, then the initial centre.
|
||||||
|
const fz = mapData.freeZone;
|
||||||
|
const target =
|
||||||
|
shareReturnViewRef.current ??
|
||||||
|
(fz
|
||||||
|
? {
|
||||||
|
latitude: (fz.south + fz.north) / 2,
|
||||||
|
longitude: (fz.west + fz.east) / 2,
|
||||||
|
zoom: initialViewState.zoom,
|
||||||
|
}
|
||||||
|
: initialViewState);
|
||||||
|
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
|
||||||
|
}, [initialViewState, mapData.freeZone]);
|
||||||
|
|
||||||
|
// "Check your area" demo prompt: GPS recenters the map AND moves the free zone
|
||||||
|
// (the chosen centre is sent to the server via lib/api on each request);
|
||||||
|
// declining keeps Canary Wharf. Persisted per session so we don't re-ask.
|
||||||
|
const [demoPromptOpen, setDemoPromptOpen] = useState(
|
||||||
|
// Also check the persisted choice directly: MapPage remounts on navigation
|
||||||
|
// (route key change) while App's `offerDemoLocation` is frozen at mount, so a
|
||||||
|
// choice made earlier this session must still suppress a re-prompt.
|
||||||
|
() => !!offerDemoLocation && !screenshotMode && !ogMode && readDemoChoice() == null
|
||||||
|
);
|
||||||
|
const [demoLocating, setDemoLocating] = useState(false);
|
||||||
|
const [demoLocationError, setDemoLocationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleUseMyLocation = useCallback(() => {
|
||||||
|
const applyCenter = (center: { lat: number; lon: number }) => {
|
||||||
|
setDemoCenter(center);
|
||||||
|
persistDemoChoice(center);
|
||||||
|
setDemoLocating(false);
|
||||||
|
setDemoPromptOpen(false);
|
||||||
|
// handleFlyTo uses map.jumpTo (instant) — no flashing 403s through
|
||||||
|
// intermediate viewports; the move triggers a refetch carrying demoLat/Lon.
|
||||||
|
mapFlyToRef.current?.(
|
||||||
|
center.lat,
|
||||||
|
center.lon,
|
||||||
|
INITIAL_VIEW_STATE.zoom,
|
||||||
|
getMobileMapFlyToOptions()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||||
|
setDemoLocationError(t('locationSearch.geolocationUnsupported'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDemoLocating(true);
|
||||||
|
setDemoLocationError(null);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => applyCenter({ lat: position.coords.latitude, lon: position.coords.longitude }),
|
||||||
|
() => {
|
||||||
|
// In dev, geolocation is usually blocked (e.g. served over http), so fall
|
||||||
|
// back to 10 Downing Street to keep the "data follows you" flow testable.
|
||||||
|
if (__DEV__) {
|
||||||
|
applyCenter({ lat: DEV_LOCATION.latitude, lon: DEV_LOCATION.longitude });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDemoLocating(false);
|
||||||
|
setDemoLocationError(t('locationSearch.geolocationFailed'));
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
|
||||||
|
);
|
||||||
|
}, [t, getMobileMapFlyToOptions]);
|
||||||
|
|
||||||
|
const handleUseDefaultLocation = useCallback(() => {
|
||||||
|
persistDemoChoice('declined');
|
||||||
|
setDemoPromptOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Never show the demo prompt to licensed/admin users — e.g. if the session
|
||||||
|
// hydrated as free then refreshed to licensed, or they signed in in another tab.
|
||||||
|
useEffect(() => {
|
||||||
|
if (filtersUnlimited) setDemoPromptOpen(false);
|
||||||
|
}, [filtersUnlimited]);
|
||||||
|
|
||||||
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
|
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||||
// Disabling POIs (clearing every category) must remove all POI cards/markers
|
// 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
|
// immediately, not on the next fetch tick — gate on the selection itself so a
|
||||||
|
|
@ -585,6 +630,7 @@ export default function MapPage({
|
||||||
dataLength: mapData.data.length,
|
dataLength: mapData.data.length,
|
||||||
postcodeDataLength: mapData.postcodeData.length,
|
postcodeDataLength: mapData.postcodeData.length,
|
||||||
usePostcodeView: mapData.usePostcodeView,
|
usePostcodeView: mapData.usePostcodeView,
|
||||||
|
licenseRequired: mapData.licenseRequired,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMobileHexagonClick = useCallback(
|
const handleMobileHexagonClick = useCallback(
|
||||||
|
|
@ -603,7 +649,7 @@ export default function MapPage({
|
||||||
mapData.resolution,
|
mapData.resolution,
|
||||||
areaStats
|
areaStats
|
||||||
);
|
);
|
||||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
|
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
|
||||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||||
const densityLabel = t('mapLegend.historicalMatches');
|
const densityLabel = t('mapLegend.historicalMatches');
|
||||||
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
|
||||||
|
|
@ -678,7 +724,11 @@ export default function MapPage({
|
||||||
}, [dashboardParams, onDashboardParamsChange]);
|
}, [dashboardParams, onDashboardParamsChange]);
|
||||||
|
|
||||||
const dashboardReady =
|
const dashboardReady =
|
||||||
!initialLoading && !mapData.loading && mapData.bounds != null && mapData.currentView != null;
|
!initialLoading &&
|
||||||
|
!mapData.loading &&
|
||||||
|
!mapData.licenseRequired &&
|
||||||
|
mapData.bounds != null &&
|
||||||
|
mapData.currentView != null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDashboardReadyChange?.(dashboardReady);
|
onDashboardReadyChange?.(dashboardReady);
|
||||||
|
|
@ -690,6 +740,16 @@ export default function MapPage({
|
||||||
};
|
};
|
||||||
}, [onDashboardReadyChange]);
|
}, [onDashboardReadyChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapData.licenseRequired) {
|
||||||
|
trackEvent('Upgrade Modal Shown');
|
||||||
|
} else {
|
||||||
|
// Back in a viewable area — re-arm so the modal shows again the next time
|
||||||
|
// the user pans into a gated area.
|
||||||
|
setUpgradeModalDismissed(false);
|
||||||
|
}
|
||||||
|
}, [mapData.licenseRequired]);
|
||||||
|
|
||||||
// Clear the filter-cap upsell once it's no longer relevant: the user became
|
// Clear the filter-cap upsell once it's no longer relevant: the user became
|
||||||
// licensed/admin, or dropped back under the cap by removing filters. Prevents a
|
// licensed/admin, or dropped back under the cap by removing filters. Prevents a
|
||||||
// stale flag from resurfacing the modal spuriously.
|
// stale flag from resurfacing the modal spuriously.
|
||||||
|
|
@ -746,7 +806,6 @@ export default function MapPage({
|
||||||
shareCode={shareCode}
|
shareCode={shareCode}
|
||||||
isGroupExpanded={isAreaGroupExpanded}
|
isGroupExpanded={isAreaGroupExpanded}
|
||||||
onToggleGroup={toggleAreaGroup}
|
onToggleGroup={toggleAreaGroup}
|
||||||
onLoadCrimeRecords={loadCrimeRecords}
|
|
||||||
scrollTopRef={areaPaneScrollTopRef}
|
scrollTopRef={areaPaneScrollTopRef}
|
||||||
scrollRestoreKey={
|
scrollRestoreKey={
|
||||||
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
|
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
|
||||||
|
|
@ -764,7 +823,6 @@ export default function MapPage({
|
||||||
hexagonLocation,
|
hexagonLocation,
|
||||||
isAreaGroupExpanded,
|
isAreaGroupExpanded,
|
||||||
loadingAreaStats,
|
loadingAreaStats,
|
||||||
loadCrimeRecords,
|
|
||||||
selectedHexagon,
|
selectedHexagon,
|
||||||
setAreaStatsUseFilters,
|
setAreaStatsUseFilters,
|
||||||
shareCode,
|
shareCode,
|
||||||
|
|
@ -848,7 +906,7 @@ export default function MapPage({
|
||||||
openInfoFeature={pendingInfoFeature}
|
openInfoFeature={pendingInfoFeature}
|
||||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||||
travelTimeEntries={entries}
|
travelTimeEntries={entries}
|
||||||
onTravelTimeAddEntry={handleAddTravelTimeEntry}
|
onTravelTimeAddEntry={handleAddEntry}
|
||||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||||
onTravelTimeRangeChange={handleTimeRangeChange}
|
onTravelTimeRangeChange={handleTimeRangeChange}
|
||||||
|
|
@ -895,7 +953,7 @@ export default function MapPage({
|
||||||
features,
|
features,
|
||||||
filterCounts.impacts,
|
filterCounts.impacts,
|
||||||
filters,
|
filters,
|
||||||
handleAddTravelTimeEntry,
|
handleAddEntry,
|
||||||
handleAddFilter,
|
handleAddFilter,
|
||||||
handleAiFilterSubmit,
|
handleAiFilterSubmit,
|
||||||
handleClearAll,
|
handleClearAll,
|
||||||
|
|
@ -1021,25 +1079,51 @@ export default function MapPage({
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
// The upgrade modal is shown when a non-paying user hits the filter cap.
|
// The upgrade modal serves two cases: panning outside the free zone
|
||||||
// Dismissing it just closes the modal (the filters they have stay applied).
|
// (licenseRequired) and hitting the demo filter cap (filterLimitHit). The zone
|
||||||
const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited;
|
// case takes precedence; for the filter case, dismissing just closes the modal
|
||||||
const upgradeModal = showFilterUpgradeModal ? (
|
// (no camera move).
|
||||||
<Suspense fallback={null}>
|
const showZoneUpgradeModal = mapData.licenseRequired && !upgradeModalDismissed;
|
||||||
<UpgradeModal
|
const showFilterUpgradeModal = !showZoneUpgradeModal && filterLimitHit && !filtersUnlimited;
|
||||||
isLoggedIn={!!user}
|
const upgradeModal =
|
||||||
onLoginClick={() =>
|
showZoneUpgradeModal || showFilterUpgradeModal ? (
|
||||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
<Suspense fallback={null}>
|
||||||
}
|
<UpgradeModal
|
||||||
onRegisterClick={() =>
|
isLoggedIn={!!user}
|
||||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
onLoginClick={() =>
|
||||||
}
|
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
}
|
||||||
onClose={() => setFilterLimitHit(false)}
|
onRegisterClick={() =>
|
||||||
/>
|
onCheckoutRegisterClick
|
||||||
</Suspense>
|
? onCheckoutRegisterClick(checkoutReturnPath)
|
||||||
|
: onRegisterClick()
|
||||||
|
}
|
||||||
|
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||||
|
onZoomToFreeZone={
|
||||||
|
showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone
|
||||||
|
}
|
||||||
|
isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const demoLocationPrompt = demoPromptOpen ? (
|
||||||
|
<DemoLocationPrompt
|
||||||
|
locating={demoLocating}
|
||||||
|
error={demoLocationError}
|
||||||
|
onUseLocation={handleUseMyLocation}
|
||||||
|
onUseDefault={handleUseDefaultLocation}
|
||||||
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// Both overlays render where the page components place {upgradeModal}.
|
||||||
|
const overlayModals = (
|
||||||
|
<>
|
||||||
|
{upgradeModal}
|
||||||
|
{demoLocationPrompt}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<MobileMapPage
|
<MobileMapPage
|
||||||
|
|
@ -1091,7 +1175,7 @@ export default function MapPage({
|
||||||
renderAreaPane={renderAreaPane}
|
renderAreaPane={renderAreaPane}
|
||||||
renderPropertiesPane={renderPropertiesPane}
|
renderPropertiesPane={renderPropertiesPane}
|
||||||
toasts={toasts}
|
toasts={toasts}
|
||||||
upgradeModal={upgradeModal}
|
upgradeModal={overlayModals}
|
||||||
editingBar={editingBar}
|
editingBar={editingBar}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -1151,7 +1235,7 @@ export default function MapPage({
|
||||||
renderAreaPane={renderAreaPane}
|
renderAreaPane={renderAreaPane}
|
||||||
renderPropertiesPane={renderPropertiesPane}
|
renderPropertiesPane={renderPropertiesPane}
|
||||||
toasts={toasts}
|
toasts={toasts}
|
||||||
upgradeModal={upgradeModal}
|
upgradeModal={overlayModals}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,11 +105,7 @@ export default function MobileDrawer({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tutorial="right-pane"
|
data-tutorial="right-pane"
|
||||||
// Pin to the dynamic viewport (100dvh) anchored at the top instead of
|
className="pointer-events-none fixed inset-0 z-50 flex flex-col"
|
||||||
// `inset-0`. On iOS Safari a fixed element with `bottom: 0` is laid out
|
|
||||||
// against the large viewport, so when the bottom toolbar shows the panel's
|
|
||||||
// bottom (and the last collapsible group) hides behind it and can't be tapped.
|
|
||||||
className="pointer-events-none fixed inset-x-0 top-0 z-50 flex h-[100dvh] flex-col"
|
|
||||||
>
|
>
|
||||||
<div className="h-[10%] shrink-0" aria-hidden="true" />
|
<div className="h-[10%] shrink-0" aria-hidden="true" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { BASEMAPS, type BasemapId } from '../../lib/basemaps';
|
import { BASEMAPS, type BasemapId } from '../../lib/basemaps';
|
||||||
import { OVERLAYS, type OverlayDefinition, type OverlayId } from '../../lib/overlays';
|
import { OVERLAYS, type OverlayDefinition, type OverlayId } from '../../lib/overlays';
|
||||||
import { CRIME_TYPES, CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
import { CRIME_TYPES, CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
||||||
import { tDynamic } from '../../i18n';
|
|
||||||
import { PillToggle } from '../ui/PillToggle';
|
import { PillToggle } from '../ui/PillToggle';
|
||||||
import { IconButton } from '../ui/IconButton';
|
import { IconButton } from '../ui/IconButton';
|
||||||
import { Slider } from '../ui/Slider';
|
import { Slider } from '../ui/Slider';
|
||||||
|
|
@ -13,18 +12,6 @@ import { colorOpacityToPercent, normalizeColorOpacity } from '../../lib/color-op
|
||||||
|
|
||||||
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
||||||
|
|
||||||
// Maps the kebab-case overlay id to its camelCase i18n key under `overlays.*`.
|
|
||||||
const OVERLAY_I18N_KEY: Record<OverlayId, string> = {
|
|
||||||
noise: 'noise',
|
|
||||||
'crime-hotspots': 'crimeHotspots',
|
|
||||||
'trees-outside-woodlands': 'treesOutsideWoodlands',
|
|
||||||
'property-borders': 'propertyBorders',
|
|
||||||
'new-developments': 'newDevelopments',
|
|
||||||
};
|
|
||||||
|
|
||||||
const overlayLabel = (id: OverlayId) => tDynamic(`overlays.${OVERLAY_I18N_KEY[id]}.label`);
|
|
||||||
const overlayDetail = (id: OverlayId) => tDynamic(`overlays.${OVERLAY_I18N_KEY[id]}.detail`);
|
|
||||||
|
|
||||||
interface OverlayPaneProps {
|
interface OverlayPaneProps {
|
||||||
selectedOverlays: Set<OverlayId>;
|
selectedOverlays: Set<OverlayId>;
|
||||||
onOverlaysChange: (overlays: Set<OverlayId>) => void;
|
onOverlaysChange: (overlays: Set<OverlayId>) => void;
|
||||||
|
|
@ -91,7 +78,7 @@ export default function OverlayPane({
|
||||||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||||
{t('overlays.heading')}
|
Overlays
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||||
{selectedOverlays.size}/{OVERLAYS.length}
|
{selectedOverlays.size}/{OVERLAYS.length}
|
||||||
|
|
@ -101,13 +88,13 @@ export default function OverlayPane({
|
||||||
onClick={selectNone}
|
onClick={selectNone}
|
||||||
className="rounded border border-warm-300 px-2 py-0.5 text-xs text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
className="rounded border border-warm-300 px-2 py-0.5 text-xs text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
||||||
>
|
>
|
||||||
{t('common.none')}
|
None
|
||||||
</button>
|
</button>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||||
title={t('common.close')}
|
title="Close"
|
||||||
>
|
>
|
||||||
<CloseIcon className="h-4 w-4" />
|
<CloseIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -119,7 +106,8 @@ export default function OverlayPane({
|
||||||
role="alert"
|
role="alert"
|
||||||
className="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-xs text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/30 dark:text-amber-200"
|
className="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-xs text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/30 dark:text-amber-200"
|
||||||
>
|
>
|
||||||
{t('overlays.zoomWarning', { count: selectedOverlays.size })}
|
Zoom in further to see the selected{' '}
|
||||||
|
{selectedOverlays.size === 1 ? 'overlay' : 'overlays'}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,13 +115,13 @@ export default function OverlayPane({
|
||||||
<div className="min-h-0 space-y-4 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
<div className="min-h-0 space-y-4 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||||
{t('overlays.baseMap')}
|
Base map
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{BASEMAPS.map((option) => (
|
{BASEMAPS.map((option) => (
|
||||||
<PillToggle
|
<PillToggle
|
||||||
key={option.id}
|
key={option.id}
|
||||||
label={tDynamic(`map.basemap.${option.id}`)}
|
label={option.label}
|
||||||
active={basemap === option.id}
|
active={basemap === option.id}
|
||||||
onClick={() => onBasemapChange(option.id)}
|
onClick={() => onBasemapChange(option.id)}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -145,7 +133,7 @@ export default function OverlayPane({
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||||
{t('overlays.colourOpacity')}
|
Colour opacity
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-medium tabular-nums text-warm-500 dark:text-warm-400">
|
<span className="text-[10px] font-medium tabular-nums text-warm-500 dark:text-warm-400">
|
||||||
{colorOpacityPercent}%
|
{colorOpacityPercent}%
|
||||||
|
|
@ -157,27 +145,27 @@ export default function OverlayPane({
|
||||||
step={5}
|
step={5}
|
||||||
value={[colorOpacityPercent]}
|
value={[colorOpacityPercent]}
|
||||||
onValueChange={handleColorOpacityChange}
|
onValueChange={handleColorOpacityChange}
|
||||||
aria-label={t('overlays.colourOpacity')}
|
aria-label="Colour opacity"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||||
{t('overlays.dataOverlays')}
|
Data overlays
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{OVERLAYS.map((overlay) => (
|
{OVERLAYS.map((overlay) => (
|
||||||
<div key={overlay.id} className="inline-flex items-center gap-0.5">
|
<div key={overlay.id} className="inline-flex items-center gap-0.5">
|
||||||
<PillToggle
|
<PillToggle
|
||||||
label={overlayLabel(overlay.id)}
|
label={overlay.label}
|
||||||
active={selectedOverlays.has(overlay.id)}
|
active={selectedOverlays.has(overlay.id)}
|
||||||
onClick={() => toggleOverlay(overlay.id)}
|
onClick={() => toggleOverlay(overlay.id)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setInfoOverlay(overlay)}
|
onClick={() => setInfoOverlay(overlay)}
|
||||||
title={t('overlays.about', { name: overlayLabel(overlay.id) })}
|
title={`About ${overlay.label}`}
|
||||||
ariaLabel={t('overlays.about', { name: overlayLabel(overlay.id) })}
|
ariaLabel={`About ${overlay.label}`}
|
||||||
>
|
>
|
||||||
<InfoIcon className="h-3.5 w-3.5" />
|
<InfoIcon className="h-3.5 w-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -200,13 +188,13 @@ export default function OverlayPane({
|
||||||
onClick={selectAllCrimeTypes}
|
onClick={selectAllCrimeTypes}
|
||||||
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
||||||
>
|
>
|
||||||
{t('common.all')}
|
All
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={selectNoCrimeTypes}
|
onClick={selectNoCrimeTypes}
|
||||||
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
||||||
>
|
>
|
||||||
{t('common.none')}
|
None
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,7 +210,7 @@ export default function OverlayPane({
|
||||||
onChange={() => toggleCrimeType(crime.value)}
|
onChange={() => toggleCrimeType(crime.value)}
|
||||||
className="h-3.5 w-3.5 shrink-0 rounded border-warm-300 text-amber-600 focus:ring-1 focus:ring-amber-500 dark:border-warm-600 dark:bg-warm-800"
|
className="h-3.5 w-3.5 shrink-0 rounded border-warm-300 text-amber-600 focus:ring-1 focus:ring-amber-500 dark:border-warm-600 dark:bg-warm-800"
|
||||||
/>
|
/>
|
||||||
<span>{tDynamic(crime.labelKey)}</span>
|
<span>{crime.label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -231,9 +219,9 @@ export default function OverlayPane({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{infoOverlay && (
|
{infoOverlay && (
|
||||||
<InfoPopup title={overlayLabel(infoOverlay.id)} onClose={() => setInfoOverlay(null)}>
|
<InfoPopup title={infoOverlay.label} onClose={() => setInfoOverlay(null)}>
|
||||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||||
{overlayDetail(infoOverlay.id)}
|
{infoOverlay.detail}
|
||||||
</p>
|
</p>
|
||||||
</InfoPopup>
|
</InfoPopup>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { TFunction } from 'i18next';
|
|
||||||
|
|
||||||
import type { SchoolMetadata } from '../../types';
|
import type { SchoolMetadata } from '../../types';
|
||||||
import { POI_GROUP_COLORS } from '../../lib/consts';
|
import { POI_GROUP_COLORS } from '../../lib/consts';
|
||||||
|
|
@ -34,7 +32,7 @@ function normalizeSchoolWebsiteUrl(raw: string): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSchoolMetadata(school: SchoolMetadata, t: TFunction) {
|
function renderSchoolMetadata(school: SchoolMetadata) {
|
||||||
// First line collects the headline classification (phase, type, religious
|
// First line collects the headline classification (phase, type, religious
|
||||||
// character) so the popup is scannable even when most fields are absent.
|
// character) so the popup is scannable even when most fields are absent.
|
||||||
const headline: string[] = [];
|
const headline: string[] = [];
|
||||||
|
|
@ -43,11 +41,11 @@ function renderSchoolMetadata(school: SchoolMetadata, t: TFunction) {
|
||||||
|
|
||||||
const pupilsLine =
|
const pupilsLine =
|
||||||
school.pupils !== undefined && school.capacity !== undefined
|
school.pupils !== undefined && school.capacity !== undefined
|
||||||
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} ${t('poiPopup.school.pupils')}`
|
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils`
|
||||||
: school.pupils !== undefined
|
: school.pupils !== undefined
|
||||||
? `${school.pupils.toLocaleString()} ${t('poiPopup.school.pupils')}`
|
? `${school.pupils.toLocaleString()} pupils`
|
||||||
: school.capacity !== undefined
|
: school.capacity !== undefined
|
||||||
? `${t('poiPopup.school.capacity')} ${school.capacity.toLocaleString()}`
|
? `Capacity ${school.capacity.toLocaleString()}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;
|
const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;
|
||||||
|
|
@ -56,69 +54,69 @@ function renderSchoolMetadata(school: SchoolMetadata, t: TFunction) {
|
||||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-xs text-warm-600 dark:text-warm-300">
|
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-xs text-warm-600 dark:text-warm-300">
|
||||||
{headline.length > 0 && (
|
{headline.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.type')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Type</dt>
|
||||||
<dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
|
<dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.age_range && (
|
{school.age_range && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.ages')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Ages</dt>
|
||||||
<dd className="dark:text-warm-200">{school.age_range}</dd>
|
<dd className="dark:text-warm-200">{school.age_range}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.gender && school.gender !== 'Mixed' && (
|
{school.gender && school.gender !== 'Mixed' && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.gender')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Gender</dt>
|
||||||
<dd className="dark:text-warm-200">{school.gender}</dd>
|
<dd className="dark:text-warm-200">{school.gender}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pupilsLine && (
|
{pupilsLine && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.pupilsLabel')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Pupils</dt>
|
||||||
<dd className="dark:text-warm-200">{pupilsLine}</dd>
|
<dd className="dark:text-warm-200">{pupilsLine}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.fsm_percent !== undefined && (
|
{school.fsm_percent !== undefined && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.freeMeal')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Free meal</dt>
|
||||||
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
|
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.ofsted_rating && (
|
{school.ofsted_rating && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.ofsted')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Ofsted</dt>
|
||||||
<dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
|
<dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.sixth_form === 'Has a sixth form' && (
|
{school.sixth_form === 'Has a sixth form' && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.sixthForm')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
|
||||||
<dd className="dark:text-warm-200">{t('common.yes')}</dd>
|
<dd className="dark:text-warm-200">Yes</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.religious_character &&
|
{school.religious_character &&
|
||||||
school.religious_character !== 'Does not apply' &&
|
school.religious_character !== 'Does not apply' &&
|
||||||
school.religious_character !== 'None' && (
|
school.religious_character !== 'None' && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.religion')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Religion</dt>
|
||||||
<dd className="dark:text-warm-200">{school.religious_character}</dd>
|
<dd className="dark:text-warm-200">{school.religious_character}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.admissions_policy && (
|
{school.admissions_policy && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.admissions')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Admissions</dt>
|
||||||
<dd className="dark:text-warm-200">{school.admissions_policy}</dd>
|
<dd className="dark:text-warm-200">{school.admissions_policy}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.trust && (
|
{school.trust && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.trust')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Trust</dt>
|
||||||
<dd className="dark:text-warm-200">{school.trust}</dd>
|
<dd className="dark:text-warm-200">{school.trust}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(school.address || school.postcode) && (
|
{(school.address || school.postcode) && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.address')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Address</dt>
|
||||||
<dd className="dark:text-warm-200">
|
<dd className="dark:text-warm-200">
|
||||||
{[school.address, school.postcode].filter(Boolean).join(', ')}
|
{[school.address, school.postcode].filter(Boolean).join(', ')}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
@ -126,21 +124,19 @@ function renderSchoolMetadata(school: SchoolMetadata, t: TFunction) {
|
||||||
)}
|
)}
|
||||||
{school.local_authority && (
|
{school.local_authority && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">
|
<dt className="text-warm-500 dark:text-warm-400">LA</dt>
|
||||||
{t('poiPopup.school.localAuthority')}
|
|
||||||
</dt>
|
|
||||||
<dd className="dark:text-warm-200">{school.local_authority}</dd>
|
<dd className="dark:text-warm-200">{school.local_authority}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{school.head_name && (
|
{school.head_name && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.head')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Head</dt>
|
||||||
<dd className="dark:text-warm-200">{school.head_name}</dd>
|
<dd className="dark:text-warm-200">{school.head_name}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{websiteUrl && (
|
{websiteUrl && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.website')}</dt>
|
<dt className="text-warm-500 dark:text-warm-400">Website</dt>
|
||||||
<dd className="truncate">
|
<dd className="truncate">
|
||||||
<a
|
<a
|
||||||
href={websiteUrl}
|
href={websiteUrl}
|
||||||
|
|
@ -162,7 +158,6 @@ export const PoiPopupCardContent = memo(function PoiPopupCardContent({
|
||||||
}: {
|
}: {
|
||||||
poi: PoiPopupCardData;
|
poi: PoiPopupCardData;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 max-w-[280px]">
|
<div className="px-3 py-2 max-w-[280px]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -187,7 +182,7 @@ export const PoiPopupCardContent = memo(function PoiPopupCardContent({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{poi.school && renderSchoolMetadata(poi.school, t)}
|
{poi.school && renderSchoolMetadata(poi.school)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,7 @@ export function PropertiesPane({
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
<IndeterminateProgressBar show={loading && properties.length > 0} />
|
<IndeterminateProgressBar show={loading && properties.length > 0} />
|
||||||
<div
|
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={onScroll}
|
|
||||||
className="flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]"
|
|
||||||
>
|
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
<InfoPopup
|
<InfoPopup
|
||||||
title={t('propertyCard.propertyData')}
|
title={t('propertyCard.propertyData')}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ interface StackedBarChartProps {
|
||||||
/** Strip common suffixes/prefixes to produce short legend labels */
|
/** Strip common suffixes/prefixes to produce short legend labels */
|
||||||
function shortenLabel(name: string): string {
|
function shortenLabel(name: string): string {
|
||||||
return name
|
return name
|
||||||
.replace(/ \(\/yr, \d+y\)$/, '')
|
|
||||||
.replace(' (avg/yr)', '')
|
.replace(' (avg/yr)', '')
|
||||||
.replace(/^% /, '')
|
.replace(/^% /, '')
|
||||||
.replace('and sexual offences', '')
|
.replace('and sexual offences', '')
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,12 @@ import { useMemo } from 'react';
|
||||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||||
import type { PercentileScale } from '../../../lib/format';
|
import type { PercentileScale } from '../../../lib/format';
|
||||||
import { groupFeaturesByCategory } from '../../../lib/features';
|
import { groupFeaturesByCategory } from '../../../lib/features';
|
||||||
import {
|
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
|
||||||
SPECIFIC_CRIME_VARIANT_CONFIG,
|
|
||||||
getSpecificCrimeFeatureName,
|
|
||||||
isSpecificCrimeFilterName,
|
|
||||||
} from '../../../lib/crime-filter';
|
|
||||||
import {
|
|
||||||
getCrimeSeverityFeatureName,
|
|
||||||
getCrimeSeverityFilterName,
|
|
||||||
getCrimeSeverityVariantConfig,
|
|
||||||
isCrimeSeverityFilterName,
|
|
||||||
} from '../../../lib/crime-severity-filter';
|
|
||||||
import {
|
import {
|
||||||
getElectionVoteShareFeatureName,
|
getElectionVoteShareFeatureName,
|
||||||
isElectionVoteShareFilterName,
|
isElectionVoteShareFilterName,
|
||||||
} from '../../../lib/election-filter';
|
} from '../../../lib/election-filter';
|
||||||
import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
|
import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
|
||||||
import {
|
|
||||||
QUALIFICATION_VARIANT_CONFIG,
|
|
||||||
getQualificationFeatureName,
|
|
||||||
isQualificationFilterName,
|
|
||||||
} from '../../../lib/qualification-filter';
|
|
||||||
import {
|
|
||||||
TENURE_VARIANT_CONFIG,
|
|
||||||
getTenureFeatureName,
|
|
||||||
isTenureFilterName,
|
|
||||||
} from '../../../lib/tenure-filter';
|
|
||||||
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
|
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
|
||||||
import {
|
import {
|
||||||
getPoiDistanceFeatureName,
|
getPoiDistanceFeatureName,
|
||||||
|
|
@ -38,7 +18,7 @@ import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
import { EthnicityFilterCard } from './EthnicityFilterCard';
|
import { EthnicityFilterCard } from './EthnicityFilterCard';
|
||||||
import { PoiDistanceFilterCard } from './PoiDistanceFilterCard';
|
import { PoiDistanceFilterCard } from './PoiDistanceFilterCard';
|
||||||
import { SchoolFilterCard } from './SchoolFilterCard';
|
import { SchoolFilterCard } from './SchoolFilterCard';
|
||||||
import { VariantFilterCard } from './VariantFilterCard';
|
import { SpecificCrimeFilterCard } from './SpecificCrimeFilterCard';
|
||||||
import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard';
|
import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard';
|
||||||
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
|
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
|
||||||
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
|
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
|
||||||
|
|
@ -60,10 +40,6 @@ interface ActiveFilterListProps {
|
||||||
destinationDropdownPortal: boolean;
|
destinationDropdownPortal: boolean;
|
||||||
isGroupExpanded: (name: string) => boolean;
|
isGroupExpanded: (name: string) => boolean;
|
||||||
onToggleGroup: (name: string) => void;
|
onToggleGroup: (name: string) => void;
|
||||||
/** Registers a group wrapper so an expanded group can be scrolled into view. */
|
|
||||||
registerGroup?: (name: string) => (node: HTMLDivElement | null) => void;
|
|
||||||
/** Notifies the reveal-on-expand mechanism when a group is toggled. */
|
|
||||||
onGroupToggle?: (name: string, willExpand: boolean) => void;
|
|
||||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||||
onRemoveFilter: (name: string) => void;
|
onRemoveFilter: (name: string) => void;
|
||||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||||
|
|
@ -100,8 +76,6 @@ export function ActiveFilterList({
|
||||||
destinationDropdownPortal,
|
destinationDropdownPortal,
|
||||||
isGroupExpanded,
|
isGroupExpanded,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
registerGroup,
|
|
||||||
onGroupToggle,
|
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
onRemoveFilter,
|
onRemoveFilter,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
|
|
@ -178,11 +152,10 @@ export function ActiveFilterList({
|
||||||
if (isSpecificCrimeFilterName(feature.name)) {
|
if (isSpecificCrimeFilterName(feature.name)) {
|
||||||
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
|
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
|
||||||
return (
|
return (
|
||||||
<VariantFilterCard
|
<SpecificCrimeFilterCard
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
config={SPECIFIC_CRIME_VARIANT_CONFIG}
|
|
||||||
features={features}
|
features={features}
|
||||||
variantFeature={feature}
|
crimeFeature={feature}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
activeFeature={activeFeature}
|
activeFeature={activeFeature}
|
||||||
dragValue={dragValue}
|
dragValue={dragValue}
|
||||||
|
|
@ -204,91 +177,6 @@ export function ActiveFilterList({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCrimeSeverityFilterName(feature.name)) {
|
|
||||||
const crimeSeverityBackendName = getCrimeSeverityFeatureName(feature.name);
|
|
||||||
const crimeSeverityFilterName = getCrimeSeverityFilterName(feature.name);
|
|
||||||
if (!crimeSeverityFilterName) return null;
|
|
||||||
return (
|
|
||||||
<VariantFilterCard
|
|
||||||
key={feature.name}
|
|
||||||
config={getCrimeSeverityVariantConfig(crimeSeverityFilterName)}
|
|
||||||
features={features}
|
|
||||||
variantFeature={feature}
|
|
||||||
filters={filters}
|
|
||||||
activeFeature={activeFeature}
|
|
||||||
dragValue={dragValue}
|
|
||||||
pinnedFeature={pinnedFeature}
|
|
||||||
filterImpact={
|
|
||||||
crimeSeverityBackendName ? filterImpacts?.[crimeSeverityBackendName] : undefined
|
|
||||||
}
|
|
||||||
percentileScale={
|
|
||||||
crimeSeverityBackendName ? percentileScales.get(crimeSeverityBackendName) : undefined
|
|
||||||
}
|
|
||||||
onFilterChange={onFilterChange}
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragChange={onDragChange}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
onTogglePin={onTogglePin}
|
|
||||||
onShowInfo={onShowInfo}
|
|
||||||
onRemove={() => onRemoveFilter(feature.name)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isQualificationFilterName(feature.name)) {
|
|
||||||
const qualificationBackendName = getQualificationFeatureName(feature.name);
|
|
||||||
return (
|
|
||||||
<VariantFilterCard
|
|
||||||
key={feature.name}
|
|
||||||
config={QUALIFICATION_VARIANT_CONFIG}
|
|
||||||
features={features}
|
|
||||||
variantFeature={feature}
|
|
||||||
filters={filters}
|
|
||||||
activeFeature={activeFeature}
|
|
||||||
dragValue={dragValue}
|
|
||||||
pinnedFeature={pinnedFeature}
|
|
||||||
filterImpact={
|
|
||||||
qualificationBackendName ? filterImpacts?.[qualificationBackendName] : undefined
|
|
||||||
}
|
|
||||||
percentileScale={
|
|
||||||
qualificationBackendName ? percentileScales.get(qualificationBackendName) : undefined
|
|
||||||
}
|
|
||||||
onFilterChange={onFilterChange}
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragChange={onDragChange}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
onTogglePin={onTogglePin}
|
|
||||||
onShowInfo={onShowInfo}
|
|
||||||
onRemove={() => onRemoveFilter(feature.name)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTenureFilterName(feature.name)) {
|
|
||||||
const tenureBackendName = getTenureFeatureName(feature.name);
|
|
||||||
return (
|
|
||||||
<VariantFilterCard
|
|
||||||
key={feature.name}
|
|
||||||
config={TENURE_VARIANT_CONFIG}
|
|
||||||
features={features}
|
|
||||||
variantFeature={feature}
|
|
||||||
filters={filters}
|
|
||||||
activeFeature={activeFeature}
|
|
||||||
dragValue={dragValue}
|
|
||||||
pinnedFeature={pinnedFeature}
|
|
||||||
filterImpact={tenureBackendName ? filterImpacts?.[tenureBackendName] : undefined}
|
|
||||||
percentileScale={tenureBackendName ? percentileScales.get(tenureBackendName) : undefined}
|
|
||||||
onFilterChange={onFilterChange}
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragChange={onDragChange}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
onTogglePin={onTogglePin}
|
|
||||||
onShowInfo={onShowInfo}
|
|
||||||
onRemove={() => onRemoveFilter(feature.name)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isElectionVoteShareFilterName(feature.name)) {
|
if (isElectionVoteShareFilterName(feature.name)) {
|
||||||
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
|
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
|
||||||
return (
|
return (
|
||||||
|
|
@ -410,14 +298,11 @@ export function ActiveFilterList({
|
||||||
if (count === 0) return null;
|
if (count === 0) return null;
|
||||||
const expanded = isGroupExpanded(group.name);
|
const expanded = isGroupExpanded(group.name);
|
||||||
return (
|
return (
|
||||||
<div key={group.name} className="shrink-0" ref={registerGroup?.(group.name)}>
|
<div key={group.name} className="shrink-0">
|
||||||
<CollapsibleGroupHeader
|
<CollapsibleGroupHeader
|
||||||
name={group.name}
|
name={group.name}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={() => {
|
onToggle={() => onToggleGroup(group.name)}
|
||||||
onToggleGroup(group.name);
|
|
||||||
onGroupToggle?.(group.name, !expanded);
|
|
||||||
}}
|
|
||||||
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useCallback, type MutableRefObject, type RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useRevealOnExpand } from '../../../hooks/useRevealOnExpand';
|
|
||||||
import type { AiFilterErrorType } from '../../../hooks/useAiFilters';
|
import type { AiFilterErrorType } from '../../../hooks/useAiFilters';
|
||||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
import type { PercentileScale } from '../../../lib/format';
|
import type { PercentileScale } from '../../../lib/format';
|
||||||
|
|
@ -108,16 +107,6 @@ export function ActiveFiltersPanel({
|
||||||
onTravelTimeToggleNoBuses,
|
onTravelTimeToggleNoBuses,
|
||||||
}: ActiveFiltersPanelProps) {
|
}: ActiveFiltersPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
|
|
||||||
// Compose the parent-owned scrollRef (used to locate newly added filters) with
|
|
||||||
// the reveal-on-expand container ref so both point at the same scroll element.
|
|
||||||
const setScrollNode = useCallback(
|
|
||||||
(node: HTMLDivElement | null) => {
|
|
||||||
(scrollRef as MutableRefObject<HTMLDivElement | null>).current = node;
|
|
||||||
setContainer(node);
|
|
||||||
},
|
|
||||||
[scrollRef, setContainer]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -167,10 +156,7 @@ export function ActiveFiltersPanel({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<div ref={scrollRef} className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden">
|
||||||
ref={setScrollNode}
|
|
||||||
className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden"
|
|
||||||
>
|
|
||||||
<AiFilterInput
|
<AiFilterInput
|
||||||
loading={aiFilterLoading}
|
loading={aiFilterLoading}
|
||||||
error={aiFilterError}
|
error={aiFilterError}
|
||||||
|
|
@ -210,8 +196,6 @@ export function ActiveFiltersPanel({
|
||||||
destinationDropdownPortal={destinationDropdownPortal}
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
isGroupExpanded={isGroupExpanded}
|
isGroupExpanded={isGroupExpanded}
|
||||||
onToggleGroup={onToggleGroup}
|
onToggleGroup={onToggleGroup}
|
||||||
registerGroup={registerGroup}
|
|
||||||
onGroupToggle={revealGroupOnToggle}
|
|
||||||
onFilterChange={onFilterChange}
|
onFilterChange={onFilterChange}
|
||||||
onRemoveFilter={onRemoveFilter}
|
onRemoveFilter={onRemoveFilter}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,15 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useRevealOnExpand } from '../../../hooks/useRevealOnExpand';
|
|
||||||
import type { TravelTimeEntry, TransportMode } from '../../../hooks/useTravelTime';
|
import type { TravelTimeEntry, TransportMode } from '../../../hooks/useTravelTime';
|
||||||
import type { FeatureMeta } from '../../../types';
|
import type { FeatureMeta } from '../../../types';
|
||||||
import { ChevronIcon } from '../../ui/icons';
|
import { ChevronIcon } from '../../ui/icons';
|
||||||
import FeatureBrowser from '../FeatureBrowser';
|
import FeatureBrowser from '../FeatureBrowser';
|
||||||
import { SPECIFIC_CRIMES_FILTER_NAME, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
|
import { SPECIFIC_CRIMES_FILTER_NAME, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
|
||||||
import {
|
|
||||||
CRIME_SEVERITY_FILTER_NAMES,
|
|
||||||
getCrimeSeverityFilterName,
|
|
||||||
isCrimeSeverityFilterName,
|
|
||||||
type CrimeSeverityFilterName,
|
|
||||||
} from '../../../lib/crime-severity-filter';
|
|
||||||
import {
|
import {
|
||||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||||
isElectionVoteShareFilterName,
|
isElectionVoteShareFilterName,
|
||||||
} from '../../../lib/election-filter';
|
} from '../../../lib/election-filter';
|
||||||
import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
|
import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
|
||||||
import {
|
|
||||||
QUALIFICATIONS_FILTER_NAME,
|
|
||||||
isQualificationFilterName,
|
|
||||||
} from '../../../lib/qualification-filter';
|
|
||||||
import { TENURE_FILTER_NAME, isTenureFilterName } from '../../../lib/tenure-filter';
|
|
||||||
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
|
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
|
||||||
import {
|
import {
|
||||||
POI_DISTANCE_FILTER_NAME,
|
POI_DISTANCE_FILTER_NAME,
|
||||||
|
|
@ -39,11 +27,8 @@ interface AddFilterPanelProps {
|
||||||
pinnedFeature: string | null;
|
pinnedFeature: string | null;
|
||||||
defaultSchoolFeatureName: string | null;
|
defaultSchoolFeatureName: string | null;
|
||||||
defaultSpecificCrimeFeatureName: string | null;
|
defaultSpecificCrimeFeatureName: string | null;
|
||||||
defaultCrimeSeverityFeatureNames: Record<CrimeSeverityFilterName, string | null>;
|
|
||||||
defaultElectionVoteShareFeatureName: string | null;
|
defaultElectionVoteShareFeatureName: string | null;
|
||||||
defaultEthnicityFeatureName: string | null;
|
defaultEthnicityFeatureName: string | null;
|
||||||
defaultQualificationFeatureName: string | null;
|
|
||||||
defaultTenureFeatureName: string | null;
|
|
||||||
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
|
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
|
||||||
openInfoFeature?: string | null;
|
openInfoFeature?: string | null;
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
|
|
@ -64,11 +49,8 @@ export function AddFilterPanel({
|
||||||
pinnedFeature,
|
pinnedFeature,
|
||||||
defaultSchoolFeatureName,
|
defaultSchoolFeatureName,
|
||||||
defaultSpecificCrimeFeatureName,
|
defaultSpecificCrimeFeatureName,
|
||||||
defaultCrimeSeverityFeatureNames,
|
|
||||||
defaultElectionVoteShareFeatureName,
|
defaultElectionVoteShareFeatureName,
|
||||||
defaultEthnicityFeatureName,
|
defaultEthnicityFeatureName,
|
||||||
defaultQualificationFeatureName,
|
|
||||||
defaultTenureFeatureName,
|
|
||||||
defaultPoiFilterFeatureNames,
|
defaultPoiFilterFeatureNames,
|
||||||
openInfoFeature,
|
openInfoFeature,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
|
|
@ -81,7 +63,6 @@ export function AddFilterPanel({
|
||||||
onUpgradeClick,
|
onUpgradeClick,
|
||||||
}: AddFilterPanelProps) {
|
}: AddFilterPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
|
|
||||||
|
|
||||||
const browserPinnedFeature =
|
const browserPinnedFeature =
|
||||||
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
||||||
|
|
@ -92,15 +73,9 @@ export function AddFilterPanel({
|
||||||
? ELECTION_VOTE_SHARE_FILTER_NAME
|
? ELECTION_VOTE_SHARE_FILTER_NAME
|
||||||
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
|
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
|
||||||
? ETHNICITIES_FILTER_NAME
|
? ETHNICITIES_FILTER_NAME
|
||||||
: pinnedFeature && isQualificationFilterName(pinnedFeature)
|
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
||||||
? QUALIFICATIONS_FILTER_NAME
|
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
||||||
: pinnedFeature && isTenureFilterName(pinnedFeature)
|
: pinnedFeature;
|
||||||
? TENURE_FILTER_NAME
|
|
||||||
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
|
||||||
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
|
||||||
: pinnedFeature && isCrimeSeverityFilterName(pinnedFeature)
|
|
||||||
? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature)
|
|
||||||
: pinnedFeature;
|
|
||||||
|
|
||||||
const handleTogglePin = (name: string) => {
|
const handleTogglePin = (name: string) => {
|
||||||
if (name === SCHOOL_FILTER_NAME) {
|
if (name === SCHOOL_FILTER_NAME) {
|
||||||
|
|
@ -119,20 +94,6 @@ export function AddFilterPanel({
|
||||||
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
|
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (name === QUALIFICATIONS_FILTER_NAME) {
|
|
||||||
if (defaultQualificationFeatureName) onTogglePin(defaultQualificationFeatureName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (name === TENURE_FILTER_NAME) {
|
|
||||||
if (defaultTenureFeatureName) onTogglePin(defaultTenureFeatureName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
|
|
||||||
const defaultSeverityFeatureName =
|
|
||||||
defaultCrimeSeverityFeatureNames[name as CrimeSeverityFilterName];
|
|
||||||
if (defaultSeverityFeatureName) onTogglePin(defaultSeverityFeatureName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
||||||
const defaultPoiFeatureName = defaultPoiFilterFeatureNames[name as PoiFilterName];
|
const defaultPoiFeatureName = defaultPoiFilterFeatureNames[name as PoiFilterName];
|
||||||
if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName);
|
if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName);
|
||||||
|
|
@ -162,7 +123,7 @@ export function AddFilterPanel({
|
||||||
{(!collapsed || !isLicensed) && (
|
{(!collapsed || !isLicensed) && (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<FeatureBrowser
|
<FeatureBrowser
|
||||||
availableFeatures={availableFeatures}
|
availableFeatures={availableFeatures}
|
||||||
allFeatures={allFeatures}
|
allFeatures={allFeatures}
|
||||||
|
|
@ -174,8 +135,6 @@ export function AddFilterPanel({
|
||||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||||
travelTimeEntries={travelTimeEntries}
|
travelTimeEntries={travelTimeEntries}
|
||||||
onAddTravelTimeEntry={onAddTravelTimeEntry}
|
onAddTravelTimeEntry={onAddTravelTimeEntry}
|
||||||
registerGroup={registerGroup}
|
|
||||||
onGroupToggle={revealGroupOnToggle}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,23 @@ import { ChevronIcon } from '../../ui/icons';
|
||||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||||
import type { VariantFilterConfig } from '../../../lib/variant-filter';
|
|
||||||
import { formatNumber, type PercentileScale } from '../../../lib/format';
|
import { formatNumber, type PercentileScale } from '../../../lib/format';
|
||||||
import { getFeatureIcon } from '../../../lib/feature-icons';
|
import { getFeatureIcon } from '../../../lib/feature-icons';
|
||||||
import { getGroupIcon } from '../../../lib/group-icons';
|
import { getGroupIcon } from '../../../lib/group-icons';
|
||||||
|
import {
|
||||||
|
SPECIFIC_CRIMES_FILTER_NAME,
|
||||||
|
SPECIFIC_CRIME_FEATURE_NAMES,
|
||||||
|
clampSpecificCrimeRange,
|
||||||
|
getDefaultSpecificCrimeFeatureName,
|
||||||
|
getSpecificCrimeFeatureName,
|
||||||
|
getSpecificCrimeFilterMeta,
|
||||||
|
replaceSpecificCrimeFilterKeySelection,
|
||||||
|
} from '../../../lib/crime-filter';
|
||||||
import { SliderLabels } from './SliderLabels';
|
import { SliderLabels } from './SliderLabels';
|
||||||
|
|
||||||
/**
|
export function SpecificCrimeFilterCard({
|
||||||
* One card for a "pick a variant, filter its range" aggregate filter
|
|
||||||
* (specific crimes, qualifications, …). Behaviour is variant-agnostic: the
|
|
||||||
* `config` supplies the dropdown options, labels and helper functions so the
|
|
||||||
* same component backs every such filter (see [`VariantFilterConfig`]).
|
|
||||||
*/
|
|
||||||
export function VariantFilterCard({
|
|
||||||
config,
|
|
||||||
features,
|
features,
|
||||||
variantFeature,
|
crimeFeature,
|
||||||
filters,
|
filters,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
dragValue,
|
dragValue,
|
||||||
|
|
@ -35,9 +36,8 @@ export function VariantFilterCard({
|
||||||
onShowInfo,
|
onShowInfo,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
config: VariantFilterConfig;
|
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
variantFeature: FeatureMeta;
|
crimeFeature: FeatureMeta;
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
activeFeature: string | null;
|
activeFeature: string | null;
|
||||||
dragValue: [number, number] | null;
|
dragValue: [number, number] | null;
|
||||||
|
|
@ -53,62 +53,27 @@ export function VariantFilterCard({
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const variantMeta = config.getFilterMeta(features);
|
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
|
||||||
|
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
|
||||||
|
features.find((feature) => feature.name === name)
|
||||||
|
).filter((feature): feature is FeatureMeta => Boolean(feature));
|
||||||
const selectedFeatureName =
|
const selectedFeatureName =
|
||||||
config.getFeatureName(variantFeature.name) ?? config.getDefaultFeatureName(features);
|
getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features);
|
||||||
const selectedFeature = selectedFeatureName
|
const selectedFeature = selectedFeatureName
|
||||||
? features.find((feature) => feature.name === selectedFeatureName)
|
? features.find((feature) => feature.name === selectedFeatureName)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Optional secondary axis: the same variant measured over a different window
|
if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null;
|
||||||
// (e.g. 7- vs 2-year crime rates). The current window comes from the selected
|
|
||||||
// feature so the dropdown can re-point each option into that window.
|
|
||||||
const windowConfig = config.window;
|
|
||||||
const currentWindow =
|
|
||||||
(windowConfig && selectedFeatureName ? windowConfig.getWindow(selectedFeatureName) : null) ??
|
|
||||||
windowConfig?.options[0]?.id ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
const variantOptions = config.featureNames
|
const isActive = activeFeature === crimeFeature.name;
|
||||||
.map((canonicalName) => {
|
const isPinned = pinnedFeature === crimeFeature.name;
|
||||||
const optionName =
|
|
||||||
windowConfig && currentWindow
|
|
||||||
? windowConfig.withWindow(canonicalName, currentWindow)
|
|
||||||
: canonicalName;
|
|
||||||
const feature = features.find((f) => f.name === optionName);
|
|
||||||
if (!feature) return null;
|
|
||||||
return {
|
|
||||||
value: optionName,
|
|
||||||
label: ts(
|
|
||||||
config.getOptionLabelSource ? config.getOptionLabelSource(optionName) : optionName
|
|
||||||
),
|
|
||||||
feature,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((option): option is { value: string; label: string; feature: FeatureMeta } =>
|
|
||||||
Boolean(option)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only offer windows that actually exist for the selected variant, so a
|
|
||||||
// missing backend column can't strand the card on an empty selection.
|
|
||||||
const windowOptions =
|
|
||||||
windowConfig && selectedFeatureName
|
|
||||||
? windowConfig.options.filter((option) =>
|
|
||||||
features.some((f) => f.name === windowConfig.withWindow(selectedFeatureName, option.id))
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (!selectedFeature || variantOptions.length === 0 || !selectedFeatureName) return null;
|
|
||||||
|
|
||||||
const isActive = activeFeature === variantFeature.name;
|
|
||||||
const isPinned = pinnedFeature === variantFeature.name;
|
|
||||||
const hist = selectedFeature.histogram;
|
const hist = selectedFeature.histogram;
|
||||||
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
||||||
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
|
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
|
||||||
const displayValue =
|
const displayValue =
|
||||||
isActive && dragValue
|
isActive && dragValue
|
||||||
? dragValue
|
? dragValue
|
||||||
: (filters[variantFeature.name] as [number, number]) || [dataMin, dataMax];
|
: (filters[crimeFeature.name] as [number, number]) || [dataMin, dataMax];
|
||||||
const scale = percentileScale;
|
const scale = percentileScale;
|
||||||
const clampMin = displayValue[0] <= dataMin;
|
const clampMin = displayValue[0] <= dataMin;
|
||||||
const clampMax = displayValue[1] >= dataMax;
|
const clampMax = displayValue[1] >= dataMax;
|
||||||
|
|
@ -124,14 +89,14 @@ export function VariantFilterCard({
|
||||||
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
|
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
|
||||||
];
|
];
|
||||||
|
|
||||||
const replaceVariantFeature = (nextFeatureName: string) => {
|
const replaceCrimeFeature = (nextFeatureName: string) => {
|
||||||
const nextName = config.replaceFilterKeySelection(variantFeature.name, nextFeatureName);
|
const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName);
|
||||||
if (nextName === variantFeature.name) return;
|
if (nextName === crimeFeature.name) return;
|
||||||
|
|
||||||
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
||||||
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||||
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||||
const nextRange = config.clampRange(
|
const nextRange = clampSpecificCrimeRange(
|
||||||
[
|
[
|
||||||
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
||||||
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
||||||
|
|
@ -143,11 +108,6 @@ export function VariantFilterCard({
|
||||||
if (isPinned) onTogglePin(nextName);
|
if (isPinned) onTogglePin(nextName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchWindow = (windowId: string) => {
|
|
||||||
if (!windowConfig || windowId === currentWindow) return;
|
|
||||||
replaceVariantFeature(windowConfig.withWindow(selectedFeatureName, windowId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
|
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
|
||||||
const mobileIcon =
|
const mobileIcon =
|
||||||
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
|
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
|
||||||
|
|
@ -158,7 +118,7 @@ export function VariantFilterCard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={config.filterName}
|
data-filter-name={SPECIFIC_CRIMES_FILTER_NAME}
|
||||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||||
isActive
|
isActive
|
||||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||||
|
|
@ -169,7 +129,7 @@ export function VariantFilterCard({
|
||||||
>
|
>
|
||||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||||
<FeatureLabel
|
<FeatureLabel
|
||||||
feature={variantMeta}
|
feature={specificCrimeMeta}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="min-w-0 shrink"
|
className="min-w-0 shrink"
|
||||||
hideIconOnMobile
|
hideIconOnMobile
|
||||||
|
|
@ -177,7 +137,7 @@ export function VariantFilterCard({
|
||||||
/>
|
/>
|
||||||
<FeatureActions
|
<FeatureActions
|
||||||
feature={selectedFeature}
|
feature={selectedFeature}
|
||||||
actionName={variantFeature.name}
|
actionName={crimeFeature.name}
|
||||||
isPinned={isPinned}
|
isPinned={isPinned}
|
||||||
isPreviewing={isActive}
|
isPreviewing={isActive}
|
||||||
onTogglePin={onTogglePin}
|
onTogglePin={onTogglePin}
|
||||||
|
|
@ -186,65 +146,28 @@ export function VariantFilterCard({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* A single-variant filter (e.g. Serious/Minor crime) has nothing to pick,
|
<div>
|
||||||
so the dropdown is hidden and only the window toggle + slider remain. */}
|
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
{variantOptions.length > 1 && (
|
{t('filters.crimeType')}
|
||||||
<div>
|
</label>
|
||||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
<div className="relative">
|
||||||
{t(config.dropdownLabelKey)}
|
<select
|
||||||
</label>
|
value={selectedFeatureName}
|
||||||
<div className="relative">
|
onChange={(e) => replaceCrimeFeature(e.target.value)}
|
||||||
<select
|
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
|
||||||
value={selectedFeatureName}
|
|
||||||
onChange={(e) => replaceVariantFeature(e.target.value)}
|
|
||||||
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
|
|
||||||
>
|
|
||||||
{variantOptions.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronIcon
|
|
||||||
direction="down"
|
|
||||||
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{windowConfig && currentWindow && windowOptions.length > 1 && (
|
|
||||||
<div>
|
|
||||||
{windowConfig.labelKey && (
|
|
||||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
|
||||||
{t(windowConfig.labelKey)}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
className="inline-flex rounded-md border border-warm-200 bg-warm-50 p-0.5 dark:border-warm-700 dark:bg-navy-900"
|
|
||||||
>
|
>
|
||||||
{windowOptions.map((option) => {
|
{crimeOptions.map((option) => (
|
||||||
const active = option.id === currentWindow;
|
<option key={option.name} value={option.name}>
|
||||||
return (
|
{ts(option.name)}
|
||||||
<button
|
</option>
|
||||||
key={option.id}
|
))}
|
||||||
type="button"
|
</select>
|
||||||
aria-pressed={active}
|
<ChevronIcon
|
||||||
onClick={() => switchWindow(option.id)}
|
direction="down"
|
||||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
|
||||||
active
|
/>
|
||||||
? 'bg-teal-600 text-white shadow-sm'
|
|
||||||
: 'text-warm-500 hover:text-navy-950 dark:text-warm-400 dark:hover:text-warm-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(option.labelKey)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-1.5 md:block">
|
<div className="flex items-start gap-1.5 md:block">
|
||||||
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
||||||
|
|
@ -275,7 +198,7 @@ export function VariantFilterCard({
|
||||||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
onPointerDown={() => onDragStart(variantFeature.name, displayValue)}
|
onPointerDown={() => onDragStart(crimeFeature.name, displayValue)}
|
||||||
onPointerUp={() => onDragEnd()}
|
onPointerUp={() => onDragEnd()}
|
||||||
/>
|
/>
|
||||||
<SliderLabels
|
<SliderLabels
|
||||||
|
|
@ -288,7 +211,7 @@ export function VariantFilterCard({
|
||||||
raw={selectedFeature.raw}
|
raw={selectedFeature.raw}
|
||||||
feature={selectedFeature}
|
feature={selectedFeature}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
onFilterChange(variantFeature.name, config.clampRange(v, selectedFeature))
|
onFilterChange(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{filterImpact != null && filterImpact > 0 && (
|
{filterImpact != null && filterImpact > 0 && (
|
||||||
|
|
@ -236,12 +236,8 @@ export function DesktopMapPage({
|
||||||
onClick={onToggleActualListings}
|
onClick={onToggleActualListings}
|
||||||
aria-pressed={actualListingsEnabled}
|
aria-pressed={actualListingsEnabled}
|
||||||
aria-busy={actualListingsLoading}
|
aria-busy={actualListingsLoading}
|
||||||
aria-label={
|
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||||
}
|
|
||||||
title={
|
|
||||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
|
||||||
}
|
|
||||||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||||
>
|
>
|
||||||
{actualListingsLoading ? (
|
{actualListingsLoading ? (
|
||||||
|
|
@ -250,18 +246,16 @@ export function DesktopMapPage({
|
||||||
<HouseIcon className="h-5 w-5" />
|
<HouseIcon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t('map.actualListings.label')}
|
Listings{actualListingsEnabled ? ` (${actualListings.length})` : ''}
|
||||||
{actualListingsEnabled ? ` (${actualListings.length})` : ''}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
data-tutorial="overlays-button"
|
|
||||||
onClick={onToggleOverlayPane}
|
onClick={onToggleOverlayPane}
|
||||||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||||
>
|
>
|
||||||
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||||
<span className="text-sm font-medium">{t('overlays.heading')}</span>
|
<span className="text-sm font-medium">Overlays</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
data-tutorial="poi-button"
|
data-tutorial="poi-button"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
|
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ActualListing,
|
ActualListing,
|
||||||
|
|
@ -134,7 +133,6 @@ export function MobileMapPage({
|
||||||
upgradeModal,
|
upgradeModal,
|
||||||
editingBar,
|
editingBar,
|
||||||
}: MobileMapPageProps) {
|
}: MobileMapPageProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const floatingPaneAvailableHeight = `max(12rem, calc(100dvh - ${Math.ceil(
|
const floatingPaneAvailableHeight = `max(12rem, calc(100dvh - ${Math.ceil(
|
||||||
bottomScreenInset
|
bottomScreenInset
|
||||||
)}px - 7rem))`;
|
)}px - 7rem))`;
|
||||||
|
|
@ -206,12 +204,8 @@ export function MobileMapPage({
|
||||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||||
aria-pressed={actualListingsEnabled}
|
aria-pressed={actualListingsEnabled}
|
||||||
aria-busy={actualListingsLoading}
|
aria-busy={actualListingsLoading}
|
||||||
aria-label={
|
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||||
}
|
|
||||||
title={
|
|
||||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{actualListingsLoading ? (
|
{actualListingsLoading ? (
|
||||||
<SpinnerIcon className="h-5 w-5 animate-spin" />
|
<SpinnerIcon className="h-5 w-5 animate-spin" />
|
||||||
|
|
@ -223,7 +217,7 @@ export function MobileMapPage({
|
||||||
<button
|
<button
|
||||||
onClick={onToggleOverlayPane}
|
onClick={onToggleOverlayPane}
|
||||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||||
aria-label={t('overlays.heading')}
|
aria-label="Overlays"
|
||||||
>
|
>
|
||||||
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,8 @@ import type { HexagonLocation } from '../../../lib/external-search';
|
||||||
import type { useMapData } from '../../../hooks/useMapData';
|
import type { useMapData } from '../../../hooks/useMapData';
|
||||||
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
|
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
|
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
|
||||||
import { getCrimeSeverityFeatureName } from '../../../lib/crime-severity-filter';
|
|
||||||
import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
|
import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
|
||||||
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
|
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
|
||||||
import { getQualificationFeatureName } from '../../../lib/qualification-filter';
|
|
||||||
import { getTenureFeatureName } from '../../../lib/tenure-filter';
|
|
||||||
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
|
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
|
||||||
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
|
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
|
||||||
|
|
||||||
|
|
@ -26,11 +23,8 @@ export function getMapPageBackendFeatureName(featureName: string): string {
|
||||||
return (
|
return (
|
||||||
getSchoolBackendFeatureName(featureName) ??
|
getSchoolBackendFeatureName(featureName) ??
|
||||||
getSpecificCrimeFeatureName(featureName) ??
|
getSpecificCrimeFeatureName(featureName) ??
|
||||||
getCrimeSeverityFeatureName(featureName) ??
|
|
||||||
getElectionVoteShareFeatureName(featureName) ??
|
getElectionVoteShareFeatureName(featureName) ??
|
||||||
getEthnicityFeatureName(featureName) ??
|
getEthnicityFeatureName(featureName) ??
|
||||||
getQualificationFeatureName(featureName) ??
|
|
||||||
getTenureFeatureName(featureName) ??
|
|
||||||
getPoiDistanceFeatureName(featureName) ??
|
getPoiDistanceFeatureName(featureName) ??
|
||||||
featureName
|
featureName
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ interface UseScreenshotReadySignalOptions {
|
||||||
dataLength: number;
|
dataLength: number;
|
||||||
postcodeDataLength: number;
|
postcodeDataLength: number;
|
||||||
usePostcodeView: boolean;
|
usePostcodeView: boolean;
|
||||||
|
licenseRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useScreenshotReadySignal({
|
export function useScreenshotReadySignal({
|
||||||
|
|
@ -124,12 +125,13 @@ export function useScreenshotReadySignal({
|
||||||
dataLength,
|
dataLength,
|
||||||
postcodeDataLength,
|
postcodeDataLength,
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
|
licenseRequired,
|
||||||
}: UseScreenshotReadySignalOptions) {
|
}: UseScreenshotReadySignalOptions) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!screenshotMode || loading || !boundsReady) return;
|
if (!screenshotMode || loading || !boundsReady) return;
|
||||||
|
|
||||||
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
|
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
|
||||||
if (!hasData) return;
|
if (!hasData && !licenseRequired) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let signalled = false;
|
let signalled = false;
|
||||||
|
|
@ -168,5 +170,13 @@ export function useScreenshotReadySignal({
|
||||||
if (timeoutId != null) window.clearTimeout(timeoutId);
|
if (timeoutId != null) window.clearTimeout(timeoutId);
|
||||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||||
};
|
};
|
||||||
}, [screenshotMode, loading, boundsReady, dataLength, postcodeDataLength, usePostcodeView]);
|
}, [
|
||||||
|
screenshotMode,
|
||||||
|
loading,
|
||||||
|
boundsReady,
|
||||||
|
dataLength,
|
||||||
|
postcodeDataLength,
|
||||||
|
usePostcodeView,
|
||||||
|
licenseRequired,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export interface MapPageProps {
|
||||||
poiCategoryGroups: POICategoryGroup[];
|
poiCategoryGroups: POICategoryGroup[];
|
||||||
initialFilters: FeatureFilters;
|
initialFilters: FeatureFilters;
|
||||||
initialViewState: ViewState;
|
initialViewState: ViewState;
|
||||||
|
/** Offer the "check your area" GPS prompt (fresh demo visit, no URL view). */
|
||||||
|
offerDemoLocation?: boolean;
|
||||||
initialPOICategories: Set<string>;
|
initialPOICategories: Set<string>;
|
||||||
initialOverlays?: Set<OverlayId>;
|
initialOverlays?: Set<OverlayId>;
|
||||||
initialCrimeTypes?: Set<string>;
|
initialCrimeTypes?: Set<string>;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,16 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface IndeterminateProgressBarProps {
|
interface IndeterminateProgressBarProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IndeterminateProgressBar({ show, className = '' }: IndeterminateProgressBarProps) {
|
export function IndeterminateProgressBar({ show, className = '' }: IndeterminateProgressBarProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-valuetext={t('common.loading')}
|
aria-valuetext="loading"
|
||||||
className={`pointer-events-none absolute top-0 left-0 right-0 z-30 h-0.5 overflow-hidden bg-teal-500/10 dark:bg-teal-400/10 animate-fade-in ${className}`}
|
className={`pointer-events-none absolute top-0 left-0 right-0 z-30 h-0.5 overflow-hidden bg-teal-500/10 dark:bg-teal-400/10 animate-fade-in ${className}`}
|
||||||
>
|
>
|
||||||
<div className="h-full w-1/4 bg-teal-500 dark:bg-teal-400 animate-indeterminate-progress" />
|
<div className="h-full w-1/4 bg-teal-500 dark:bg-teal-400 animate-indeterminate-progress" />
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ interface UpgradeModalProps {
|
||||||
onLoginClick: () => void;
|
onLoginClick: () => void;
|
||||||
onRegisterClick: () => void;
|
onRegisterClick: () => void;
|
||||||
onStartCheckout: () => Promise<void>;
|
onStartCheckout: () => Promise<void>;
|
||||||
/** Dismiss the modal and keep exploring with the free filter allowance. */
|
onZoomToFreeZone: () => void;
|
||||||
onClose: () => void;
|
/** When true, the user came in via a share link; relabel "Continue with demo"
|
||||||
|
* to "Back to shared area" since clicking it returns to the share's coords,
|
||||||
|
* not the central-London demo. */
|
||||||
|
isShareReturn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpgradeModal({
|
export default function UpgradeModal({
|
||||||
|
|
@ -19,7 +22,8 @@ export default function UpgradeModal({
|
||||||
onLoginClick,
|
onLoginClick,
|
||||||
onRegisterClick,
|
onRegisterClick,
|
||||||
onStartCheckout,
|
onStartCheckout,
|
||||||
onClose,
|
onZoomToFreeZone,
|
||||||
|
isShareReturn,
|
||||||
}: UpgradeModalProps) {
|
}: UpgradeModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -38,11 +42,11 @@ export default function UpgradeModal({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onZoomToFreeZone();
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose]);
|
}, [onZoomToFreeZone]);
|
||||||
|
|
||||||
const priceLabel =
|
const priceLabel =
|
||||||
pricePence === null
|
pricePence === null
|
||||||
|
|
@ -80,7 +84,7 @@ export default function UpgradeModal({
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onZoomToFreeZone}
|
||||||
aria-label={t('common.close')}
|
aria-label={t('common.close')}
|
||||||
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||||
>
|
>
|
||||||
|
|
@ -92,7 +96,9 @@ export default function UpgradeModal({
|
||||||
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
|
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
|
||||||
{t('upgrade.title')}
|
{t('upgrade.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-warm-300 text-sm">{t('upgrade.description')}</p>
|
<p className="text-warm-300 text-sm">
|
||||||
|
{isShareReturn ? t('upgrade.sharedAreaDescription') : t('upgrade.description')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
|
|
@ -146,10 +152,10 @@ export default function UpgradeModal({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onZoomToFreeZone}
|
||||||
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
|
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
|
||||||
>
|
>
|
||||||
{t('upgrade.continueFree')}
|
{isShareReturn ? t('upgrade.backToSharedArea') : t('upgrade.continueWithDemo')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { FeatureFilters } from '../types';
|
import type { FeatureFilters } from '../types';
|
||||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
|
|
@ -89,7 +88,6 @@ function buildSummary(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAiFilters(): UseAiFiltersResult {
|
export function useAiFilters(): UseAiFiltersResult {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [errorType, setErrorType] = useState<AiFilterErrorType | null>(null);
|
const [errorType, setErrorType] = useState<AiFilterErrorType | null>(null);
|
||||||
|
|
@ -172,14 +170,14 @@ export function useAiFilters(): UseAiFiltersResult {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (controller.signal.aborted) return null;
|
if (controller.signal.aborted) return null;
|
||||||
logNonAbortError('ai-filters', err);
|
logNonAbortError('ai-filters', err);
|
||||||
const message = err instanceof Error ? err.message : t('aiFilter.generateFailed');
|
const message = err instanceof Error ? err.message : 'Failed to generate filters';
|
||||||
setErrorType('error');
|
setErrorType('error');
|
||||||
setError(message);
|
setError(message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { fetchAiFilters, loading, error, errorType, notes, summary };
|
return { fetchAiFilters, loading, error, errorType, notes, summary };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import pb from '../lib/pocketbase';
|
import pb from '../lib/pocketbase';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
|
|
||||||
|
|
@ -32,7 +31,6 @@ function authRefreshInvalidated(error: unknown): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [user, setUser] = useState<AuthUser | null>(() => {
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||||
if (pb.authStore.isValid && pb.authStore.record) {
|
if (pb.authStore.isValid && pb.authStore.record) {
|
||||||
return recordToUser(pb.authStore.record);
|
return recordToUser(pb.authStore.record);
|
||||||
|
|
@ -54,72 +52,63 @@ export function useAuth() {
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
async (email: string, password: string) => {
|
setLoading(true);
|
||||||
setLoading(true);
|
setError(null);
|
||||||
setError(null);
|
try {
|
||||||
try {
|
const result = await pb.collection('users').authWithPassword(email, password);
|
||||||
const result = await pb.collection('users').authWithPassword(email, password);
|
setUser(recordToUser(result.record));
|
||||||
setUser(recordToUser(result.record));
|
trackEvent('Login', { method: 'email' });
|
||||||
trackEvent('Login', { method: 'email' });
|
} catch (err) {
|
||||||
} catch (err) {
|
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||||
const msg = err instanceof Error ? err.message : t('auth.loginFailed');
|
setError(msg);
|
||||||
setError(msg);
|
throw err;
|
||||||
throw err;
|
} finally {
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(async (email: string, password: string) => {
|
||||||
async (email: string, password: string) => {
|
setLoading(true);
|
||||||
setLoading(true);
|
setError(null);
|
||||||
setError(null);
|
try {
|
||||||
try {
|
await pb.collection('users').create({
|
||||||
await pb.collection('users').create({
|
email,
|
||||||
email,
|
password,
|
||||||
password,
|
passwordConfirm: password,
|
||||||
passwordConfirm: password,
|
newsletter: true,
|
||||||
newsletter: true,
|
});
|
||||||
});
|
// Auto-login after registration
|
||||||
// Auto-login after registration
|
const result = await pb.collection('users').authWithPassword(email, password);
|
||||||
const result = await pb.collection('users').authWithPassword(email, password);
|
setUser(recordToUser(result.record));
|
||||||
setUser(recordToUser(result.record));
|
trackEvent('Register');
|
||||||
trackEvent('Register');
|
} catch (err) {
|
||||||
} catch (err) {
|
const msg = err instanceof Error ? err.message : 'Registration failed';
|
||||||
const msg = err instanceof Error ? err.message : t('auth.registrationFailed');
|
setError(msg);
|
||||||
setError(msg);
|
throw err;
|
||||||
throw err;
|
} finally {
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loginWithOAuth = useCallback(
|
const loginWithOAuth = useCallback(async (provider: string) => {
|
||||||
async (provider: string) => {
|
setLoading(true);
|
||||||
setLoading(true);
|
setError(null);
|
||||||
setError(null);
|
try {
|
||||||
try {
|
const result = await pb.collection('users').authWithOAuth2({
|
||||||
const result = await pb.collection('users').authWithOAuth2({
|
provider,
|
||||||
provider,
|
createData: { newsletter: true },
|
||||||
createData: { newsletter: true },
|
});
|
||||||
});
|
setUser(recordToUser(result.record));
|
||||||
setUser(recordToUser(result.record));
|
trackEvent('Login', { method: provider });
|
||||||
trackEvent('Login', { method: provider });
|
} catch (err) {
|
||||||
} catch (err) {
|
const msg = err instanceof Error ? err.message : 'OAuth login failed';
|
||||||
const msg = err instanceof Error ? err.message : t('auth.oauthFailed');
|
setError(msg);
|
||||||
setError(msg);
|
throw err;
|
||||||
throw err;
|
} finally {
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
trackEvent('Logout');
|
trackEvent('Logout');
|
||||||
|
|
@ -127,22 +116,19 @@ export function useAuth() {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestPasswordReset = useCallback(
|
const requestPasswordReset = useCallback(async (email: string) => {
|
||||||
async (email: string) => {
|
setLoading(true);
|
||||||
setLoading(true);
|
setError(null);
|
||||||
setError(null);
|
try {
|
||||||
try {
|
await pb.collection('users').requestPasswordReset(email);
|
||||||
await pb.collection('users').requestPasswordReset(email);
|
} catch (err) {
|
||||||
} catch (err) {
|
const msg = err instanceof Error ? err.message : 'Password reset request failed';
|
||||||
const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed');
|
setError(msg);
|
||||||
setError(msg);
|
throw err;
|
||||||
throw err;
|
} finally {
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshAuth = useCallback(async () => {
|
const refreshAuth = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
create: vi.fn(),
|
|
||||||
getFullList: vi.fn(),
|
|
||||||
authStore: {
|
|
||||||
isValid: true,
|
|
||||||
record: { id: 'user-1' } as { id: string } | null,
|
|
||||||
onChange: () => () => {},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../lib/pocketbase', () => ({
|
|
||||||
default: {
|
|
||||||
authStore: mocks.authStore,
|
|
||||||
collection: () => ({ getFullList: mocks.getFullList, create: mocks.create }),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useClickedListings } from './useClickedListings';
|
|
||||||
|
|
||||||
describe('useClickedListings', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.create.mockReset().mockResolvedValue({});
|
|
||||||
mocks.getFullList.mockReset().mockResolvedValue([]);
|
|
||||||
mocks.authStore.isValid = true;
|
|
||||||
mocks.authStore.record = { id: 'user-1' };
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hydrates the visited set from PocketBase for the signed-in user', async () => {
|
|
||||||
mocks.getFullList.mockResolvedValue([{ url: 'https://example.com/a' }]);
|
|
||||||
const { result } = renderHook(() => useClickedListings());
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.clickedUrls.has('https://example.com/a')).toBe(true));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records a click optimistically and persists it', async () => {
|
|
||||||
const { result } = renderHook(() => useClickedListings());
|
|
||||||
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
|
|
||||||
|
|
||||||
act(() => result.current.markClicked('https://example.com/b'));
|
|
||||||
|
|
||||||
// Visible immediately — no need to await the network round-trip.
|
|
||||||
expect(result.current.clickedUrls.has('https://example.com/b')).toBe(true);
|
|
||||||
expect(mocks.create).toHaveBeenCalledWith({
|
|
||||||
user: 'user-1',
|
|
||||||
url: 'https://example.com/b',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('produces a new Set instance for a new url so deck.gl re-colours', async () => {
|
|
||||||
const { result } = renderHook(() => useClickedListings());
|
|
||||||
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
|
|
||||||
|
|
||||||
const before = result.current.clickedUrls;
|
|
||||||
act(() => result.current.markClicked('https://example.com/c'));
|
|
||||||
expect(result.current.clickedUrls).not.toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not re-persist an already visited url', async () => {
|
|
||||||
mocks.getFullList.mockResolvedValue([{ url: 'https://example.com/a' }]);
|
|
||||||
const { result } = renderHook(() => useClickedListings());
|
|
||||||
await waitFor(() => expect(result.current.clickedUrls.has('https://example.com/a')).toBe(true));
|
|
||||||
|
|
||||||
const before = result.current.clickedUrls;
|
|
||||||
act(() => result.current.markClicked('https://example.com/a'));
|
|
||||||
|
|
||||||
expect(mocks.create).not.toHaveBeenCalled();
|
|
||||||
expect(result.current.clickedUrls).toBe(before); // identity stable → no needless recompute
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores empty urls', async () => {
|
|
||||||
const { result } = renderHook(() => useClickedListings());
|
|
||||||
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
|
|
||||||
|
|
||||||
act(() => result.current.markClicked(''));
|
|
||||||
act(() => result.current.markClicked(undefined));
|
|
||||||
|
|
||||||
expect(mocks.create).not.toHaveBeenCalled();
|
|
||||||
expect(result.current.clickedUrls.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recolours for anonymous users in-memory but does not persist', async () => {
|
|
||||||
mocks.authStore.isValid = false;
|
|
||||||
mocks.authStore.record = null;
|
|
||||||
const { result } = renderHook(() => useClickedListings());
|
|
||||||
|
|
||||||
act(() => result.current.markClicked('https://example.com/d'));
|
|
||||||
|
|
||||||
expect(result.current.clickedUrls.has('https://example.com/d')).toBe(true);
|
|
||||||
expect(mocks.create).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import pb from '../lib/pocketbase';
|
|
||||||
|
|
||||||
/** Per-user record of opened listings, stored in PocketBase so visited listings can be drawn
|
|
||||||
* in a distinct colour on the map and persist across devices. One row per (user, url). */
|
|
||||||
const CLICKED_LISTINGS_COLLECTION = 'clicked_listings';
|
|
||||||
|
|
||||||
function currentUserId(): string | null {
|
|
||||||
return pb.authStore.isValid && pb.authStore.record ? pb.authStore.record.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tracks which listings the signed-in user has opened.
|
|
||||||
*
|
|
||||||
* Reads the user from the shared PocketBase authStore so it stays self-contained — no need to
|
|
||||||
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
|
|
||||||
* the map's colour accessor; a fresh Set instance is produced on every change so it can double
|
|
||||||
* as a deck.gl `updateTrigger` (identity-compared).
|
|
||||||
*
|
|
||||||
* Writes are optimistic: `markClicked` updates local state synchronously so the pin recolours
|
|
||||||
* the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch.
|
|
||||||
*/
|
|
||||||
export function useClickedListings() {
|
|
||||||
const [clickedUrls, setClickedUrls] = useState<Set<string>>(() => new Set());
|
|
||||||
const [userId, setUserId] = useState<string | null>(currentUserId);
|
|
||||||
|
|
||||||
const userIdRef = useRef(userId);
|
|
||||||
userIdRef.current = userId;
|
|
||||||
// Mirrors the latest committed set so markClicked can decide (synchronously) whether a url is
|
|
||||||
// new — without depending on the async state updater having run yet.
|
|
||||||
const clickedUrlsRef = useRef(clickedUrls);
|
|
||||||
clickedUrlsRef.current = clickedUrls;
|
|
||||||
|
|
||||||
// Follow login/logout via the shared authStore (also covers cross-tab auth changes).
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = pb.authStore.onChange(() => setUserId(currentUserId()));
|
|
||||||
return unsubscribe;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load the user's previously opened listings whenever the signed-in user changes. Signed-out
|
|
||||||
// users keep an in-memory set for the current session only (nothing to persist to).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userId) {
|
|
||||||
setClickedUrls(new Set());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
pb.collection(CLICKED_LISTINGS_COLLECTION)
|
|
||||||
.getFullList({ filter: `user = "${userId}"`, fields: 'url' })
|
|
||||||
.then((records) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const urls = records
|
|
||||||
.map((record) => (record as { url?: unknown }).url)
|
|
||||||
.filter((url): url is string => typeof url === 'string');
|
|
||||||
setClickedUrls(new Set(urls));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Visited history is a convenience; a failed load just starts the session empty.
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
const markClicked = useCallback((url: string | undefined | null) => {
|
|
||||||
if (!url || clickedUrlsRef.current.has(url)) return; // unknown or already visited
|
|
||||||
|
|
||||||
// Optimistic, synchronous local update → the map pin recolours on click.
|
|
||||||
setClickedUrls((prev) => (prev.has(url) ? prev : new Set(prev).add(url)));
|
|
||||||
|
|
||||||
const uid = userIdRef.current;
|
|
||||||
if (!uid) return; // anonymous: recolour for the session, nothing to persist
|
|
||||||
pb.collection(CLICKED_LISTINGS_COLLECTION)
|
|
||||||
.create({ user: uid, url })
|
|
||||||
.catch(() => {
|
|
||||||
// Ignore duplicate-index conflicts and transient failures — local state already
|
|
||||||
// reflects the click, and a missed write only means it won't persist to the next visit.
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { clickedUrls, markClicked };
|
|
||||||
}
|
|
||||||
|
|
@ -17,8 +17,6 @@ import type {
|
||||||
import {
|
import {
|
||||||
DENSITY_GRADIENT,
|
DENSITY_GRADIENT,
|
||||||
DENSITY_GRADIENT_DARK,
|
DENSITY_GRADIENT_DARK,
|
||||||
FILTERED_OUT_FILL,
|
|
||||||
FILTERED_OUT_LINE,
|
|
||||||
getEnumPaletteForFeature,
|
getEnumPaletteForFeature,
|
||||||
getFeatureGradient,
|
getFeatureGradient,
|
||||||
} from '../lib/consts';
|
} from '../lib/consts';
|
||||||
|
|
@ -127,12 +125,11 @@ export function useDeckLayers({
|
||||||
zoom,
|
zoom,
|
||||||
isDark,
|
isDark,
|
||||||
});
|
});
|
||||||
const { listingLayers, listingPopup, clearListingPopup, clickedListingUrls, markListingClicked } =
|
const { listingLayers, listingPopup, clearListingPopup } = useListingLayers({
|
||||||
useListingLayers({
|
listings: actualListings,
|
||||||
listings: actualListings,
|
zoom,
|
||||||
zoom,
|
isDark,
|
||||||
isDark,
|
});
|
||||||
});
|
|
||||||
const { developmentLayers, developmentPopup, clearDevelopmentPopup } = useDevelopmentLayers({
|
const { developmentLayers, developmentPopup, clearDevelopmentPopup } = useDevelopmentLayers({
|
||||||
developments,
|
developments,
|
||||||
zoom,
|
zoom,
|
||||||
|
|
@ -353,12 +350,7 @@ export function useDeckLayers({
|
||||||
getHexagon: (d) => d.h3,
|
getHexagon: (d) => d.h3,
|
||||||
getFillColor: (d) => {
|
getFillColor: (d) => {
|
||||||
if ((d.count as number) <= 0) {
|
if ((d.count as number) <= 0) {
|
||||||
// Filtered out: keep it on the map as a faint grey ghost (still
|
return [0, 0, 0, 0] as [number, number, number, number];
|
||||||
// clickable) rather than hiding it entirely.
|
|
||||||
return withColorOpacity(
|
|
||||||
FILTERED_OUT_FILL[isDarkRef.current ? 'dark' : 'light'],
|
|
||||||
colorOpacityRef.current
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const fill = (color: RgbaColor) => withColorOpacity(color, colorOpacityRef.current);
|
const fill = (color: RgbaColor) => withColorOpacity(color, colorOpacityRef.current);
|
||||||
const dark = isDarkRef.current;
|
const dark = isDarkRef.current;
|
||||||
|
|
@ -512,12 +504,7 @@ export function useDeckLayers({
|
||||||
getFillColor: (f) => {
|
getFillColor: (f) => {
|
||||||
const d = f.properties;
|
const d = f.properties;
|
||||||
if (d.count <= 0) {
|
if (d.count <= 0) {
|
||||||
// Filtered out: keep it on the map as a faint grey ghost (still
|
return [0, 0, 0, 0] as [number, number, number, number];
|
||||||
// clickable) rather than hiding it entirely.
|
|
||||||
return withColorOpacity(
|
|
||||||
FILTERED_OUT_FILL[isDarkRef.current ? 'dark' : 'light'],
|
|
||||||
colorOpacityRef.current
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const fill = (color: RgbaColor) => withColorOpacity(color, colorOpacityRef.current);
|
const fill = (color: RgbaColor) => withColorOpacity(color, colorOpacityRef.current);
|
||||||
const dark = isDarkRef.current;
|
const dark = isDarkRef.current;
|
||||||
|
|
@ -600,7 +587,7 @@ export function useDeckLayers({
|
||||||
return [29, 228, 195, 255] as [number, number, number, number];
|
return [29, 228, 195, 255] as [number, number, number, number];
|
||||||
if (pc === hoveredPostcodeRef.current)
|
if (pc === hoveredPostcodeRef.current)
|
||||||
return [29, 228, 195, 200] as [number, number, number, number];
|
return [29, 228, 195, 200] as [number, number, number, number];
|
||||||
if (f.properties.count <= 0) return FILTERED_OUT_LINE[dark ? 'dark' : 'light'];
|
if (f.properties.count <= 0) return [0, 0, 0, 0] as [number, number, number, number];
|
||||||
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
|
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
|
|
@ -612,7 +599,7 @@ export function useDeckLayers({
|
||||||
const pc = f.properties.postcode;
|
const pc = f.properties.postcode;
|
||||||
if (pc === selectedPostcodeIdRef.current) return 4;
|
if (pc === selectedPostcodeIdRef.current) return 4;
|
||||||
if (pc === hoveredPostcodeRef.current) return 2;
|
if (pc === hoveredPostcodeRef.current) return 2;
|
||||||
// Filtered-out polygons (count <= 0) keep the same 1px ghost border.
|
if (f.properties.count <= 0) return 0;
|
||||||
return 1;
|
return 1;
|
||||||
},
|
},
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
|
|
@ -762,8 +749,6 @@ export function useDeckLayers({
|
||||||
clearPopupInfo,
|
clearPopupInfo,
|
||||||
listingPopup,
|
listingPopup,
|
||||||
clearListingPopup,
|
clearListingPopup,
|
||||||
clickedListingUrls,
|
|
||||||
markListingClicked,
|
|
||||||
developmentPopup,
|
developmentPopup,
|
||||||
clearDevelopmentPopup,
|
clearDevelopmentPopup,
|
||||||
hoverPosition,
|
hoverPosition,
|
||||||
|
|
|
||||||
|
|
@ -132,102 +132,6 @@ describe('useFilters', () => {
|
||||||
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
|
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps the initial filter set to the limit for non-paying users', () => {
|
|
||||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
|
||||||
name,
|
|
||||||
type: 'numeric',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// A bookmarked or shared URL may carry more filters than the cap allows; the
|
|
||||||
// initial set must start clamped so the first map request never exceeds the
|
|
||||||
// server-side cap (which would 400 and blank the map).
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useFilters({
|
|
||||||
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1], e: [0, 1] },
|
|
||||||
features: sixFeatures,
|
|
||||||
filterLimit: 3,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(Object.keys(result.current.filters)).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not clamp the initial filter set when filterLimit is null (licensed users)', () => {
|
|
||||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
|
||||||
name,
|
|
||||||
type: 'numeric',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useFilters({
|
|
||||||
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1], e: [0, 1] },
|
|
||||||
features: sixFeatures,
|
|
||||||
filterLimit: null,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(Object.keys(result.current.filters)).toHaveLength(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts reserved slots (e.g. travel-time filters) toward the cap', () => {
|
|
||||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
|
||||||
name,
|
|
||||||
type: 'numeric',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
}));
|
|
||||||
const onFilterLimitReached = vi.fn();
|
|
||||||
|
|
||||||
// Limit 3 with 1 slot already reserved (e.g. a travel-time filter) leaves room
|
|
||||||
// for only 2 feature filters.
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useFilters({
|
|
||||||
initialFilters: {},
|
|
||||||
features: sixFeatures,
|
|
||||||
filterLimit: 3,
|
|
||||||
reservedFilterSlots: 1,
|
|
||||||
onFilterLimitReached,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
['a', 'b'].forEach((name) => result.current.handleAddFilter(name));
|
|
||||||
});
|
|
||||||
expect(Object.keys(result.current.filters)).toHaveLength(2);
|
|
||||||
expect(onFilterLimitReached).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleAddFilter('c');
|
|
||||||
});
|
|
||||||
expect(Object.keys(result.current.filters)).toHaveLength(2);
|
|
||||||
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps the initial filter set accounting for reserved slots', () => {
|
|
||||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
|
||||||
name,
|
|
||||||
type: 'numeric',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 4 initial feature filters, limit 3, 1 reserved slot → keep only 2 features.
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useFilters({
|
|
||||||
initialFilters: { a: [0, 1], b: [0, 1], c: [0, 1], d: [0, 1] },
|
|
||||||
features: sixFeatures,
|
|
||||||
filterLimit: 3,
|
|
||||||
reservedFilterSlots: 1,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(Object.keys(result.current.filters)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not cap filters when filterLimit is null (licensed users)', () => {
|
it('does not cap filters when filterLimit is null (licensed users)', () => {
|
||||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,6 @@ import {
|
||||||
getSpecificCrimeFilterKeyId,
|
getSpecificCrimeFilterKeyId,
|
||||||
normalizeSpecificCrimeFilters,
|
normalizeSpecificCrimeFilters,
|
||||||
} from '../lib/crime-filter';
|
} from '../lib/crime-filter';
|
||||||
import {
|
|
||||||
CRIME_SEVERITY_FILTER_NAMES,
|
|
||||||
createCrimeSeverityFilterKey,
|
|
||||||
getCrimeSeverityFeatureName,
|
|
||||||
getCrimeSeverityFilterKeyId,
|
|
||||||
getDefaultCrimeSeverityFeatureName,
|
|
||||||
normalizeCrimeSeverityFilters,
|
|
||||||
type CrimeSeverityFilterName,
|
|
||||||
} from '../lib/crime-severity-filter';
|
|
||||||
import {
|
import {
|
||||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||||
createElectionVoteShareFilterKey,
|
createElectionVoteShareFilterKey,
|
||||||
|
|
@ -42,22 +33,6 @@ import {
|
||||||
getEthnicityFilterKeyId,
|
getEthnicityFilterKeyId,
|
||||||
normalizeEthnicityFilters,
|
normalizeEthnicityFilters,
|
||||||
} from '../lib/ethnicity-filter';
|
} from '../lib/ethnicity-filter';
|
||||||
import {
|
|
||||||
QUALIFICATIONS_FILTER_NAME,
|
|
||||||
createQualificationFilterKey,
|
|
||||||
getDefaultQualificationFeatureName,
|
|
||||||
getQualificationFeatureName,
|
|
||||||
getQualificationFilterKeyId,
|
|
||||||
normalizeQualificationFilters,
|
|
||||||
} from '../lib/qualification-filter';
|
|
||||||
import {
|
|
||||||
TENURE_FILTER_NAME,
|
|
||||||
createTenureFilterKey,
|
|
||||||
getDefaultTenureFeatureName,
|
|
||||||
getTenureFeatureName,
|
|
||||||
getTenureFilterKeyId,
|
|
||||||
normalizeTenureFilters,
|
|
||||||
} from '../lib/tenure-filter';
|
|
||||||
import {
|
import {
|
||||||
POI_FILTER_NAMES,
|
POI_FILTER_NAMES,
|
||||||
createPoiFilterKey,
|
createPoiFilterKey,
|
||||||
|
|
@ -74,28 +49,15 @@ interface UseFiltersOptions {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
/** Max simultaneous filters for the current user; `null`/`undefined` = unlimited. */
|
/** Max simultaneous filters for the current user; `null`/`undefined` = unlimited. */
|
||||||
filterLimit?: number | null;
|
filterLimit?: number | null;
|
||||||
/** Filter slots already consumed outside this hook (e.g. travel-time entries),
|
/** Called when an add is blocked because `filterLimit` is already reached. */
|
||||||
* which share the same cap. Subtracted from `filterLimit` when gating adds. */
|
|
||||||
reservedFilterSlots?: number;
|
|
||||||
/** When true, block adding any *new* filter outright (existing filters can still
|
|
||||||
* be tweaked/replaced). Used so a non-paying user on a shared link can view and
|
|
||||||
* adjust the shared set but not build on top of it. Triggers `onFilterLimitReached`. */
|
|
||||||
lockAdds?: boolean;
|
|
||||||
/** Called when an add is blocked because `filterLimit` is reached or `lockAdds` is set. */
|
|
||||||
onFilterLimitReached?: () => void;
|
onFilterLimitReached?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||||
return normalizePoiDistanceFilters(
|
return normalizePoiDistanceFilters(
|
||||||
normalizeTenureFilters(
|
normalizeEthnicityFilters(
|
||||||
normalizeQualificationFilters(
|
normalizeElectionVoteShareFilters(
|
||||||
normalizeEthnicityFilters(
|
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
|
||||||
normalizeElectionVoteShareFilters(
|
|
||||||
normalizeCrimeSeverityFilters(
|
|
||||||
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -105,11 +67,8 @@ function getBackendFeatureName(name: string): string {
|
||||||
return (
|
return (
|
||||||
getSchoolBackendFeatureName(name) ??
|
getSchoolBackendFeatureName(name) ??
|
||||||
getSpecificCrimeFeatureName(name) ??
|
getSpecificCrimeFeatureName(name) ??
|
||||||
getCrimeSeverityFeatureName(name) ??
|
|
||||||
getElectionVoteShareFeatureName(name) ??
|
getElectionVoteShareFeatureName(name) ??
|
||||||
getEthnicityFeatureName(name) ??
|
getEthnicityFeatureName(name) ??
|
||||||
getQualificationFeatureName(name) ??
|
|
||||||
getTenureFeatureName(name) ??
|
|
||||||
getPoiDistanceFeatureName(name) ??
|
getPoiDistanceFeatureName(name) ??
|
||||||
name
|
name
|
||||||
);
|
);
|
||||||
|
|
@ -153,22 +112,11 @@ export function useFilters({
|
||||||
initialFilters,
|
initialFilters,
|
||||||
features,
|
features,
|
||||||
filterLimit,
|
filterLimit,
|
||||||
reservedFilterSlots = 0,
|
|
||||||
lockAdds = false,
|
|
||||||
onFilterLimitReached,
|
onFilterLimitReached,
|
||||||
}: UseFiltersOptions) {
|
}: UseFiltersOptions) {
|
||||||
const initialFiltersRef = useRef<FeatureFilters | null>(null);
|
const initialFiltersRef = useRef<FeatureFilters | null>(null);
|
||||||
if (!initialFiltersRef.current) {
|
if (!initialFiltersRef.current) {
|
||||||
// Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the
|
initialFiltersRef.current = normalizeFilters(initialFilters);
|
||||||
// user's remaining budget — the cap minus slots already taken by travel-time
|
|
||||||
// filters — so a non-paying user never starts above the cap, which the server
|
|
||||||
// would reject (400), blanking the map on first load.
|
|
||||||
const normalized = normalizeFilters(initialFilters);
|
|
||||||
const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
|
|
||||||
initialFiltersRef.current =
|
|
||||||
budget != null && Object.keys(normalized).length > budget
|
|
||||||
? Object.fromEntries(Object.entries(normalized).slice(0, budget))
|
|
||||||
: normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
|
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
|
||||||
|
|
@ -177,10 +125,6 @@ export function useFilters({
|
||||||
filtersRef.current = filters;
|
filtersRef.current = filters;
|
||||||
const filterLimitRef = useRef(filterLimit);
|
const filterLimitRef = useRef(filterLimit);
|
||||||
filterLimitRef.current = filterLimit;
|
filterLimitRef.current = filterLimit;
|
||||||
const reservedSlotsRef = useRef(reservedFilterSlots);
|
|
||||||
reservedSlotsRef.current = reservedFilterSlots;
|
|
||||||
const lockAddsRef = useRef(lockAdds);
|
|
||||||
lockAddsRef.current = lockAdds;
|
|
||||||
const onFilterLimitReachedRef = useRef(onFilterLimitReached);
|
const onFilterLimitReachedRef = useRef(onFilterLimitReached);
|
||||||
onFilterLimitReachedRef.current = onFilterLimitReached;
|
onFilterLimitReachedRef.current = onFilterLimitReached;
|
||||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||||
|
|
@ -196,21 +140,12 @@ export function useFilters({
|
||||||
const specificCrimeFilterIdRef = useRef(
|
const specificCrimeFilterIdRef = useRef(
|
||||||
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
|
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
|
||||||
);
|
);
|
||||||
const crimeSeverityFilterIdRef = useRef(
|
|
||||||
getNextNumericKeyId(initialFiltersRef.current!, getCrimeSeverityFilterKeyId)
|
|
||||||
);
|
|
||||||
const electionVoteShareFilterIdRef = useRef(
|
const electionVoteShareFilterIdRef = useRef(
|
||||||
getNextNumericKeyId(initialFiltersRef.current!, getElectionVoteShareFilterKeyId)
|
getNextNumericKeyId(initialFiltersRef.current!, getElectionVoteShareFilterKeyId)
|
||||||
);
|
);
|
||||||
const ethnicityFilterIdRef = useRef(
|
const ethnicityFilterIdRef = useRef(
|
||||||
getNextNumericKeyId(initialFiltersRef.current!, getEthnicityFilterKeyId)
|
getNextNumericKeyId(initialFiltersRef.current!, getEthnicityFilterKeyId)
|
||||||
);
|
);
|
||||||
const qualificationFilterIdRef = useRef(
|
|
||||||
getNextNumericKeyId(initialFiltersRef.current!, getQualificationFilterKeyId)
|
|
||||||
);
|
|
||||||
const tenureFilterIdRef = useRef(
|
|
||||||
getNextNumericKeyId(initialFiltersRef.current!, getTenureFilterKeyId)
|
|
||||||
);
|
|
||||||
const poiDistanceFilterIdRef = useRef(
|
const poiDistanceFilterIdRef = useRef(
|
||||||
getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId)
|
getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId)
|
||||||
);
|
);
|
||||||
|
|
@ -249,39 +184,28 @@ export function useFilters({
|
||||||
if (
|
if (
|
||||||
name !== SCHOOL_FILTER_NAME &&
|
name !== SCHOOL_FILTER_NAME &&
|
||||||
name !== SPECIFIC_CRIMES_FILTER_NAME &&
|
name !== SPECIFIC_CRIMES_FILTER_NAME &&
|
||||||
!CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName) &&
|
|
||||||
name !== ELECTION_VOTE_SHARE_FILTER_NAME &&
|
name !== ELECTION_VOTE_SHARE_FILTER_NAME &&
|
||||||
name !== ETHNICITIES_FILTER_NAME &&
|
name !== ETHNICITIES_FILTER_NAME &&
|
||||||
name !== QUALIFICATIONS_FILTER_NAME &&
|
|
||||||
name !== TENURE_FILTER_NAME &&
|
|
||||||
!POI_FILTER_NAMES.includes(name as PoiFilterName) &&
|
!POI_FILTER_NAMES.includes(name as PoiFilterName) &&
|
||||||
!meta
|
!meta
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-paying users are capped at `filterLimit` simultaneous filters, with
|
// Demo (unlicensed) users are capped at `filterLimit` simultaneous filters.
|
||||||
// travel-time entries (reservedFilterSlots) counting toward the same cap.
|
|
||||||
// Re-applying an existing regular feature replaces it (no new key), so it's
|
// Re-applying an existing regular feature replaces it (no new key), so it's
|
||||||
// always allowed; the special/generated filters always add a new key.
|
// always allowed; the special/generated filters always add a new key.
|
||||||
// `lockAdds` (a non-paying user on a shared link) blocks every new key
|
|
||||||
// regardless of count — they can adjust the shared set but not build on it.
|
|
||||||
const limit = filterLimitRef.current;
|
const limit = filterLimitRef.current;
|
||||||
if (limit != null || lockAddsRef.current) {
|
if (limit != null) {
|
||||||
const current = filtersRef.current;
|
const current = filtersRef.current;
|
||||||
const addsNewKey =
|
const addsNewKey =
|
||||||
name === SCHOOL_FILTER_NAME ||
|
name === SCHOOL_FILTER_NAME ||
|
||||||
name === SPECIFIC_CRIMES_FILTER_NAME ||
|
name === SPECIFIC_CRIMES_FILTER_NAME ||
|
||||||
CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName) ||
|
|
||||||
name === ELECTION_VOTE_SHARE_FILTER_NAME ||
|
name === ELECTION_VOTE_SHARE_FILTER_NAME ||
|
||||||
name === ETHNICITIES_FILTER_NAME ||
|
name === ETHNICITIES_FILTER_NAME ||
|
||||||
name === QUALIFICATIONS_FILTER_NAME ||
|
|
||||||
name === TENURE_FILTER_NAME ||
|
|
||||||
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
|
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
|
||||||
!(name in current);
|
!(name in current);
|
||||||
const overLimit =
|
if (addsNewKey && Object.keys(current).length >= limit) {
|
||||||
limit != null && Object.keys(current).length + reservedSlotsRef.current >= limit;
|
|
||||||
if (addsNewKey && (lockAddsRef.current || overLimit)) {
|
|
||||||
onFilterLimitReachedRef.current?.();
|
onFilterLimitReachedRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -323,29 +247,6 @@ export function useFilters({
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
|
|
||||||
const severityFilterName = name as CrimeSeverityFilterName;
|
|
||||||
const defaultSeverityFeatureName = getDefaultCrimeSeverityFeatureName(
|
|
||||||
features,
|
|
||||||
severityFilterName
|
|
||||||
);
|
|
||||||
const defaultSeverityFeature = defaultSeverityFeatureName
|
|
||||||
? features.find((feature) => feature.name === defaultSeverityFeatureName)
|
|
||||||
: undefined;
|
|
||||||
if (!defaultSeverityFeatureName) return prev;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[createCrimeSeverityFilterKey(
|
|
||||||
severityFilterName,
|
|
||||||
defaultSeverityFeatureName,
|
|
||||||
crimeSeverityFilterIdRef.current++
|
|
||||||
)]: [
|
|
||||||
defaultSeverityFeature?.histogram?.min ?? defaultSeverityFeature?.min ?? 0,
|
|
||||||
defaultSeverityFeature?.histogram?.max ?? defaultSeverityFeature?.max ?? 100,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
||||||
const defaultVoteShareFeatureName = getDefaultElectionVoteShareFeatureName(features);
|
const defaultVoteShareFeatureName = getDefaultElectionVoteShareFeatureName(features);
|
||||||
const defaultVoteShareFeature = defaultVoteShareFeatureName
|
const defaultVoteShareFeature = defaultVoteShareFeatureName
|
||||||
|
|
@ -380,41 +281,6 @@ export function useFilters({
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (name === QUALIFICATIONS_FILTER_NAME) {
|
|
||||||
const defaultQualificationFeatureName = getDefaultQualificationFeatureName(features);
|
|
||||||
const defaultQualificationFeature = defaultQualificationFeatureName
|
|
||||||
? features.find((feature) => feature.name === defaultQualificationFeatureName)
|
|
||||||
: undefined;
|
|
||||||
if (!defaultQualificationFeatureName) return prev;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[createQualificationFilterKey(
|
|
||||||
defaultQualificationFeatureName,
|
|
||||||
qualificationFilterIdRef.current++
|
|
||||||
)]: [
|
|
||||||
defaultQualificationFeature?.histogram?.min ?? defaultQualificationFeature?.min ?? 0,
|
|
||||||
defaultQualificationFeature?.histogram?.max ??
|
|
||||||
defaultQualificationFeature?.max ??
|
|
||||||
100,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (name === TENURE_FILTER_NAME) {
|
|
||||||
const defaultTenureFeatureName = getDefaultTenureFeatureName(features);
|
|
||||||
const defaultTenureFeature = defaultTenureFeatureName
|
|
||||||
? features.find((feature) => feature.name === defaultTenureFeatureName)
|
|
||||||
: undefined;
|
|
||||||
if (!defaultTenureFeatureName) return prev;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[createTenureFilterKey(defaultTenureFeatureName, tenureFilterIdRef.current++)]: [
|
|
||||||
defaultTenureFeature?.histogram?.min ?? defaultTenureFeature?.min ?? 0,
|
|
||||||
defaultTenureFeature?.histogram?.max ?? defaultTenureFeature?.max ?? 100,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
||||||
const poiFilterName = name as PoiFilterName;
|
const poiFilterName = name as PoiFilterName;
|
||||||
const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName);
|
const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName);
|
||||||
|
|
@ -515,23 +381,6 @@ export function useFilters({
|
||||||
if (replaced) return normalizeFilters(next);
|
if (replaced) return normalizeFilters(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const crimeSeverityKeyId = getCrimeSeverityFilterKeyId(name);
|
|
||||||
if (crimeSeverityKeyId != null) {
|
|
||||||
let replaced = false;
|
|
||||||
const next: FeatureFilters = {};
|
|
||||||
for (const [existingName, existingValue] of Object.entries(prev)) {
|
|
||||||
if (getCrimeSeverityFilterKeyId(existingName) === crimeSeverityKeyId) {
|
|
||||||
if (!replaced) {
|
|
||||||
next[name] = value;
|
|
||||||
replaced = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next[existingName] = existingValue;
|
|
||||||
}
|
|
||||||
if (replaced) return normalizeFilters(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ethnicityKeyId = getEthnicityFilterKeyId(name);
|
const ethnicityKeyId = getEthnicityFilterKeyId(name);
|
||||||
if (ethnicityKeyId != null) {
|
if (ethnicityKeyId != null) {
|
||||||
let replaced = false;
|
let replaced = false;
|
||||||
|
|
@ -549,40 +398,6 @@ export function useFilters({
|
||||||
if (replaced) return normalizeFilters(next);
|
if (replaced) return normalizeFilters(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualificationKeyId = getQualificationFilterKeyId(name);
|
|
||||||
if (qualificationKeyId != null) {
|
|
||||||
let replaced = false;
|
|
||||||
const next: FeatureFilters = {};
|
|
||||||
for (const [existingName, existingValue] of Object.entries(prev)) {
|
|
||||||
if (getQualificationFilterKeyId(existingName) === qualificationKeyId) {
|
|
||||||
if (!replaced) {
|
|
||||||
next[name] = value;
|
|
||||||
replaced = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next[existingName] = existingValue;
|
|
||||||
}
|
|
||||||
if (replaced) return normalizeFilters(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenureKeyId = getTenureFilterKeyId(name);
|
|
||||||
if (tenureKeyId != null) {
|
|
||||||
let replaced = false;
|
|
||||||
const next: FeatureFilters = {};
|
|
||||||
for (const [existingName, existingValue] of Object.entries(prev)) {
|
|
||||||
if (getTenureFilterKeyId(existingName) === tenureKeyId) {
|
|
||||||
if (!replaced) {
|
|
||||||
next[name] = value;
|
|
||||||
replaced = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next[existingName] = existingValue;
|
|
||||||
}
|
|
||||||
if (replaced) return normalizeFilters(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name);
|
const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name);
|
||||||
if (electionVoteShareKeyId != null) {
|
if (electionVoteShareKeyId != null) {
|
||||||
let replaced = false;
|
let replaced = false;
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,7 @@ import type {
|
||||||
HexagonPropertiesResponse,
|
HexagonPropertiesResponse,
|
||||||
HexagonStatsResponse,
|
HexagonStatsResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import {
|
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
|
||||||
buildFilterString,
|
|
||||||
apiUrl,
|
|
||||||
assertOk,
|
|
||||||
logNonAbortError,
|
|
||||||
authHeaders,
|
|
||||||
fetchCrimeRecords,
|
|
||||||
} from '../lib/api';
|
|
||||||
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
|
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
|
||||||
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
|
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
|
||||||
import type { TravelTimeEntry } from './useTravelTime';
|
import type { TravelTimeEntry } from './useTravelTime';
|
||||||
|
|
@ -334,23 +327,6 @@ export function useHexagonSelection({
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lazily fetch a page of individual crimes for the current selection. The
|
|
||||||
// records are an attribute of the area (filter-independent), so this carries
|
|
||||||
// no filter string — only the selection identity and share code.
|
|
||||||
const loadCrimeRecords = useCallback(
|
|
||||||
(offset: number) => {
|
|
||||||
if (!selectedHexagon) {
|
|
||||||
return Promise.resolve({ records: [], total: 0, offset: 0, truncated: false });
|
|
||||||
}
|
|
||||||
const selection =
|
|
||||||
selectedHexagon.type === 'postcode'
|
|
||||||
? { postcode: selectedHexagon.id }
|
|
||||||
: { h3: selectedHexagon.id, resolution: selectedHexagon.resolution };
|
|
||||||
return fetchCrimeRecords(selection, offset, shareCode);
|
|
||||||
},
|
|
||||||
[selectedHexagon, shareCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleHexagonClick = useCallback(
|
const handleHexagonClick = useCallback(
|
||||||
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
|
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
|
||||||
if (selectedHexagon?.id === id) {
|
if (selectedHexagon?.id === id) {
|
||||||
|
|
@ -830,7 +806,6 @@ export function useHexagonSelection({
|
||||||
handlePropertiesTabClick,
|
handlePropertiesTabClick,
|
||||||
handleLoadMoreProperties,
|
handleLoadMoreProperties,
|
||||||
handleCloseSelection,
|
handleCloseSelection,
|
||||||
loadCrimeRecords,
|
|
||||||
selectedPostcodeGeometry,
|
selectedPostcodeGeometry,
|
||||||
handleLocationSearch,
|
handleLocationSearch,
|
||||||
handleCurrentLocationSearch,
|
handleCurrentLocationSearch,
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,37 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { apiUrl, authHeaders, assertOk } from '../lib/api';
|
import { apiUrl, authHeaders, assertOk } from '../lib/api';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
|
|
||||||
export function useLicense() {
|
export function useLicense() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [checkingOut, setCheckingOut] = useState(false);
|
const [checkingOut, setCheckingOut] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const startCheckout = useCallback(
|
const startCheckout = useCallback(async (returnPath?: string) => {
|
||||||
async (returnPath?: string) => {
|
trackEvent('Checkout Start', { has_referral: 'false' });
|
||||||
trackEvent('Checkout Start', { has_referral: 'false' });
|
setCheckingOut(true);
|
||||||
setCheckingOut(true);
|
setError(null);
|
||||||
setError(null);
|
try {
|
||||||
try {
|
const res = await fetch(apiUrl('checkout'), {
|
||||||
const res = await fetch(apiUrl('checkout'), {
|
method: 'POST',
|
||||||
method: 'POST',
|
...authHeaders({
|
||||||
...authHeaders({
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify(returnPath ? { return_path: returnPath } : {}),
|
||||||
body: JSON.stringify(returnPath ? { return_path: returnPath } : {}),
|
}),
|
||||||
}),
|
});
|
||||||
});
|
assertOk(res, 'Checkout');
|
||||||
assertOk(res, 'Checkout');
|
const data = await res.json();
|
||||||
const data = await res.json();
|
if (data.url) {
|
||||||
if (data.url) {
|
trackEvent('Checkout Redirect');
|
||||||
trackEvent('Checkout Redirect');
|
window.location.href = data.url;
|
||||||
window.location.href = data.url;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : t('upgrade.checkoutFailed');
|
|
||||||
setError(msg);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setCheckingOut(false);
|
|
||||||
}
|
}
|
||||||
},
|
} catch (err) {
|
||||||
[t]
|
const msg = err instanceof Error ? err.message : 'Checkout failed';
|
||||||
);
|
setError(msg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setCheckingOut(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return { startCheckout, checkingOut, error };
|
return { startCheckout, checkingOut, error };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,6 @@ import Supercluster from 'supercluster';
|
||||||
|
|
||||||
import type { ActualListing } from '../types';
|
import type { ActualListing } from '../types';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
import { useClickedListings } from './useClickedListings';
|
|
||||||
|
|
||||||
/** Default red pin vs. the violet used once a user has opened that listing. */
|
|
||||||
const LISTING_PIN_FILL: [number, number, number, number] = [231, 76, 60, 240];
|
|
||||||
const LISTING_PIN_VISITED_FILL: [number, number, number, number] = [139, 92, 246, 240];
|
|
||||||
const EXPANDED_PIN_FILL: [number, number, number, number] = [231, 76, 60, 245];
|
|
||||||
const EXPANDED_PIN_VISITED_FILL: [number, number, number, number] = [139, 92, 246, 245];
|
|
||||||
|
|
||||||
const PRICE_LABEL_MIN_ZOOM = 14;
|
const PRICE_LABEL_MIN_ZOOM = 14;
|
||||||
const ADDRESS_LABEL_MIN_ZOOM = 16;
|
const ADDRESS_LABEL_MIN_ZOOM = 16;
|
||||||
|
|
@ -120,7 +113,6 @@ function spiderfyPosition(
|
||||||
export function useListingLayers({ listings, zoom, isDark }: UseListingLayersProps) {
|
export function useListingLayers({ listings, zoom, isDark }: UseListingLayersProps) {
|
||||||
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
|
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
|
||||||
const [selectedCluster, setSelectedCluster] = useState<ListingClusterPoint | null>(null);
|
const [selectedCluster, setSelectedCluster] = useState<ListingClusterPoint | null>(null);
|
||||||
const { clickedUrls, markClicked } = useClickedListings();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Each refetch returns a fresh listings array and rebuilds the cluster index, so a
|
// Each refetch returns a fresh listings array and rebuilds the cluster index, so a
|
||||||
|
|
@ -220,16 +212,12 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
[clearUnlockedPopup]
|
[clearUnlockedPopup]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback((info: PickingInfo<ActualListing>) => {
|
||||||
(info: PickingInfo<ActualListing>) => {
|
const url = info.object?.listing_url;
|
||||||
const url = info.object?.listing_url;
|
if (!url) return;
|
||||||
if (!url) return;
|
trackEvent('Actual Listing Click', { url });
|
||||||
markClicked(url);
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
trackEvent('Actual Listing Click', { url });
|
}, []);
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
},
|
|
||||||
[markClicked]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleHoverRef = useRef(handleHover);
|
const handleHoverRef = useRef(handleHover);
|
||||||
handleHoverRef.current = handleHover;
|
handleHoverRef.current = handleHover;
|
||||||
|
|
@ -256,16 +244,12 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
[clearUnlockedPopup]
|
[clearUnlockedPopup]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleExpandedClick = useCallback(
|
const handleExpandedClick = useCallback((info: PickingInfo<ExpandedListingMarker>) => {
|
||||||
(info: PickingInfo<ExpandedListingMarker>) => {
|
const url = info.object?.listing.listing_url;
|
||||||
const url = info.object?.listing.listing_url;
|
if (!url) return;
|
||||||
if (!url) return;
|
trackEvent('Actual Listing Click', { url, source: 'cluster_expanded' });
|
||||||
markClicked(url);
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
trackEvent('Actual Listing Click', { url, source: 'cluster_expanded' });
|
}, []);
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
},
|
|
||||||
[markClicked]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExpandedHoverRef = useRef(handleExpandedHover);
|
const handleExpandedHoverRef = useRef(handleExpandedHover);
|
||||||
handleExpandedHoverRef.current = handleExpandedHover;
|
handleExpandedHoverRef.current = handleExpandedHover;
|
||||||
|
|
@ -361,8 +345,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
getPosition: (d) => [d.lon, d.lat],
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
getRadius: 7,
|
getRadius: 7,
|
||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
getFillColor: (d) =>
|
getFillColor: [231, 76, 60, 240],
|
||||||
clickedUrls.has(d.listing_url) ? LISTING_PIN_VISITED_FILL : LISTING_PIN_FILL,
|
|
||||||
getLineColor: [255, 255, 255, 255],
|
getLineColor: [255, 255, 255, 255],
|
||||||
getLineWidth: 1.5,
|
getLineWidth: 1.5,
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
|
|
@ -372,9 +355,8 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
highlightColor: [29, 228, 195, 220],
|
highlightColor: [29, 228, 195, 220],
|
||||||
onHover: stableHover,
|
onHover: stableHover,
|
||||||
onClick: stableClick,
|
onClick: stableClick,
|
||||||
updateTriggers: { getFillColor: clickedUrls },
|
|
||||||
}),
|
}),
|
||||||
[visibleListings, clickedUrls, stableHover, stableClick]
|
[visibleListings, stableHover, stableClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clusterShadowLayer = useMemo(
|
const clusterShadowLayer = useMemo(
|
||||||
|
|
@ -459,8 +441,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getRadius: 6,
|
getRadius: 6,
|
||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
getFillColor: (d) =>
|
getFillColor: [231, 76, 60, 245],
|
||||||
clickedUrls.has(d.listing.listing_url) ? EXPANDED_PIN_VISITED_FILL : EXPANDED_PIN_FILL,
|
|
||||||
getLineColor: [255, 255, 255, 255],
|
getLineColor: [255, 255, 255, 255],
|
||||||
getLineWidth: 1.5,
|
getLineWidth: 1.5,
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
|
|
@ -470,9 +451,8 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
highlightColor: [29, 228, 195, 220],
|
highlightColor: [29, 228, 195, 220],
|
||||||
onHover: stableExpandedHover,
|
onHover: stableExpandedHover,
|
||||||
onClick: stableExpandedClick,
|
onClick: stableExpandedClick,
|
||||||
updateTriggers: { getFillColor: clickedUrls },
|
|
||||||
}),
|
}),
|
||||||
[expandedListings, clickedUrls, stableExpandedHover, stableExpandedClick]
|
[expandedListings, stableExpandedHover, stableExpandedClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
const priceLabelLayer = useMemo(() => {
|
const priceLabelLayer = useMemo(() => {
|
||||||
|
|
@ -564,11 +544,5 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
setSelectedCluster(null);
|
setSelectedCluster(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return { listingLayers, listingPopup: popupInfo, clearListingPopup };
|
||||||
listingLayers,
|
|
||||||
listingPopup: popupInfo,
|
|
||||||
clearListingPopup,
|
|
||||||
clickedListingUrls: clickedUrls,
|
|
||||||
markListingClicked: markClicked,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,8 @@ import {
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { getSchoolBackendFeatureName } from '../lib/school-filter';
|
import { getSchoolBackendFeatureName } from '../lib/school-filter';
|
||||||
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
|
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
|
||||||
import { getCrimeSeverityFeatureName } from '../lib/crime-severity-filter';
|
|
||||||
import { getElectionVoteShareFeatureName } from '../lib/election-filter';
|
import { getElectionVoteShareFeatureName } from '../lib/election-filter';
|
||||||
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
|
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
|
||||||
import { getQualificationFeatureName } from '../lib/qualification-filter';
|
|
||||||
import { getTenureFeatureName } from '../lib/tenure-filter';
|
|
||||||
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
|
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
|
||||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||||
|
|
@ -50,8 +47,8 @@ interface UseMapDataOptions {
|
||||||
pinnedFeature: string | null;
|
pinnedFeature: string | null;
|
||||||
filterRange?: [number, number] | null;
|
filterRange?: [number, number] | null;
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
/** Legacy share-link code from the URL; still echoed on data fetches but no
|
/** Share-link code from the URL; appended to data fetches so the backend
|
||||||
* longer grants any special access (the region lock was removed). */
|
* grants bbox-scoped access for unlicensed recipients. */
|
||||||
shareCode?: string;
|
shareCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +97,9 @@ export function useMapData({
|
||||||
longitude: number;
|
longitude: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [licenseRequired, setLicenseRequired] = useState(false);
|
||||||
|
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
|
||||||
|
|
||||||
// Drag preview state
|
// Drag preview state
|
||||||
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
||||||
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
|
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
|
||||||
|
|
@ -119,11 +119,8 @@ export function useMapData({
|
||||||
(name: string) =>
|
(name: string) =>
|
||||||
getSchoolBackendFeatureName(name) ??
|
getSchoolBackendFeatureName(name) ??
|
||||||
getSpecificCrimeFeatureName(name) ??
|
getSpecificCrimeFeatureName(name) ??
|
||||||
getCrimeSeverityFeatureName(name) ??
|
|
||||||
getElectionVoteShareFeatureName(name) ??
|
getElectionVoteShareFeatureName(name) ??
|
||||||
getEthnicityFeatureName(name) ??
|
getEthnicityFeatureName(name) ??
|
||||||
getQualificationFeatureName(name) ??
|
|
||||||
getTenureFeatureName(name) ??
|
|
||||||
getPoiDistanceFeatureName(name) ??
|
getPoiDistanceFeatureName(name) ??
|
||||||
name,
|
name,
|
||||||
[]
|
[]
|
||||||
|
|
@ -389,8 +386,19 @@ export function useMapData({
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (res.status === 403) {
|
||||||
|
const errBody = await res.json();
|
||||||
|
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||||
|
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||||||
|
setLicenseRequired(true);
|
||||||
|
setFreeZone(errBody.free_zone);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
assertOk(res, 'postcodes');
|
assertOk(res, 'postcodes');
|
||||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||||
|
setLicenseRequired(false);
|
||||||
const json: { features: PostcodeFeature[] } = await res.json();
|
const json: { features: PostcodeFeature[] } = await res.json();
|
||||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||||
setPostcodeData(json.features);
|
setPostcodeData(json.features);
|
||||||
|
|
@ -417,8 +425,19 @@ export function useMapData({
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (res.status === 403) {
|
||||||
|
const errBody = await res.json();
|
||||||
|
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||||
|
if (errBody.error === 'license_required' && errBody.free_zone) {
|
||||||
|
setLicenseRequired(true);
|
||||||
|
setFreeZone(errBody.free_zone);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
assertOk(res, 'hexagons');
|
assertOk(res, 'hexagons');
|
||||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||||
|
setLicenseRequired(false);
|
||||||
const json: ApiResponse = await res.json();
|
const json: ApiResponse = await res.json();
|
||||||
if (requestKey !== latestDataRequestKeyRef.current) return;
|
if (requestKey !== latestDataRequestKeyRef.current) return;
|
||||||
setRawData(json.features);
|
setRawData(json.features);
|
||||||
|
|
@ -708,7 +727,8 @@ export function useMapData({
|
||||||
// Treat the map as loading whenever the rendered hexagons don't match the
|
// Treat the map as loading whenever the rendered hexagons don't match the
|
||||||
// current request — covers the brief window between a slider release and
|
// current request — covers the brief window between a slider release and
|
||||||
// the main fetch effect actually firing setLoading(true).
|
// the main fetch effect actually firing setLoading(true).
|
||||||
const isLoading = loading || (bounds != null && loadedDataKey !== dataRequestKey);
|
const isLoading =
|
||||||
|
loading || (bounds != null && !licenseRequired && loadedDataKey !== dataRequestKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|
@ -727,5 +747,7 @@ export function useMapData({
|
||||||
handleResetPreviewScale,
|
handleResetPreviewScale,
|
||||||
handleViewChange,
|
handleViewChange,
|
||||||
setInitialView,
|
setInitialView,
|
||||||
|
licenseRequired,
|
||||||
|
freeZone,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,9 @@
|
||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { POI } from '../types';
|
import type { POI } from '../types';
|
||||||
import { usePoiLayers } from './usePoiLayers';
|
import { usePoiLayers } from './usePoiLayers';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string) => {
|
|
||||||
if (key === 'common.places') return 'places';
|
|
||||||
if (key === 'map.poi.zoomInToSeeDetails') return 'Zoom in to see details';
|
|
||||||
return key;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const supermarket: POI = {
|
const supermarket: POI = {
|
||||||
id: 'poi-1',
|
id: 'poi-1',
|
||||||
name: 'Market Hall',
|
name: 'Market Hall',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
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';
|
||||||
|
|
@ -66,7 +65,6 @@ function getPoiIconSize(poi: POI): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
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.
|
// Dismiss a lingering hover/cluster popup once the POIs behind it are gone — e.g.
|
||||||
|
|
@ -105,26 +103,23 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
handlePoiHoverRef.current(info);
|
handlePoiHoverRef.current(info);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClusterHover = useCallback(
|
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||||
(info: PickingInfo<ClusterPoint>) => {
|
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
setPopupInfo({
|
||||||
setPopupInfo({
|
x: info.x,
|
||||||
x: info.x,
|
y: info.y,
|
||||||
y: info.y,
|
name: `${info.object.count} places`,
|
||||||
name: `${info.object.count} ${t('common.places')}`,
|
category: 'Zoom in to see details',
|
||||||
category: t('map.poi.zoomInToSeeDetails'),
|
group: '',
|
||||||
group: '',
|
emoji: '',
|
||||||
emoji: '',
|
id: '',
|
||||||
id: '',
|
isCluster: true,
|
||||||
isCluster: true,
|
clusterCount: info.object.count,
|
||||||
clusterCount: info.object.count,
|
});
|
||||||
});
|
} else {
|
||||||
} else {
|
setPopupInfo(null);
|
||||||
setPopupInfo(null);
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClusterHoverRef = useRef(handleClusterHover);
|
const handleClusterHoverRef = useRef(handleClusterHover);
|
||||||
handleClusterHoverRef.current = handleClusterHover;
|
handleClusterHoverRef.current = handleClusterHover;
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useRevealOnExpand } from './useRevealOnExpand';
|
|
||||||
|
|
||||||
function GroupList() {
|
|
||||||
const { setContainer, registerGroup, onToggle } = useRevealOnExpand();
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
return (
|
|
||||||
<div ref={setContainer} data-testid="container">
|
|
||||||
<div ref={registerGroup('g1')} data-testid="group">
|
|
||||||
<button
|
|
||||||
data-testid="toggle"
|
|
||||||
onClick={() => {
|
|
||||||
const willExpand = !expanded;
|
|
||||||
setExpanded(willExpand);
|
|
||||||
onToggle('g1', willExpand);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
toggle
|
|
||||||
</button>
|
|
||||||
{expanded && <div style={{ height: 1000 }} />}
|
|
||||||
</div>
|
|
||||||
<div style={{ height: 2000 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useRevealOnExpand', () => {
|
|
||||||
const original = {
|
|
||||||
resizeObserver: globalThis.ResizeObserver,
|
|
||||||
scrollTo: HTMLElement.prototype.scrollTo,
|
|
||||||
getBoundingClientRect: HTMLElement.prototype.getBoundingClientRect,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.ResizeObserver = class {
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
} as unknown as typeof ResizeObserver;
|
|
||||||
HTMLElement.prototype.scrollTo = vi.fn();
|
|
||||||
// The opened group's bottom (800) sits below the container's bottom (500),
|
|
||||||
// so it needs a 300px downward scroll to come fully into view.
|
|
||||||
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
|
|
||||||
const id = this.dataset?.testid;
|
|
||||||
const bottom = id === 'group' ? 800 : id === 'container' ? 500 : 0;
|
|
||||||
return { bottom } as DOMRect;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
globalThis.ResizeObserver = original.resizeObserver;
|
|
||||||
HTMLElement.prototype.scrollTo = original.scrollTo;
|
|
||||||
HTMLElement.prototype.getBoundingClientRect = original.getBoundingClientRect;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scrolls a freshly expanded group into view', () => {
|
|
||||||
const view = render(<GroupList />);
|
|
||||||
const scrollTo = view.getByTestId('container').scrollTo as ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
fireEvent.click(view.getByTestId('toggle'));
|
|
||||||
|
|
||||||
expect(scrollTo).toHaveBeenCalledWith({ top: 300 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not scroll when a group is collapsed', () => {
|
|
||||||
const view = render(<GroupList />);
|
|
||||||
const scrollTo = view.getByTestId('container').scrollTo as ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
fireEvent.click(view.getByTestId('toggle')); // expand
|
|
||||||
scrollTo.mockClear();
|
|
||||||
fireEvent.click(view.getByTestId('toggle')); // collapse
|
|
||||||
|
|
||||||
expect(scrollTo).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-scrolls a freshly expanded collapsible group into view inside a scroll
|
|
||||||
* container, so opening a group near the bottom reveals its content without
|
|
||||||
* manual scrolling. The (sticky) group header stays pinned at the top.
|
|
||||||
*
|
|
||||||
* Wiring:
|
|
||||||
* const { setContainer, registerGroup, onToggle } = useRevealOnExpand();
|
|
||||||
* <div ref={setContainer} className="overflow-y-auto">
|
|
||||||
* {groups.map((g) => (
|
|
||||||
* <div key={g.name} ref={registerGroup(g.name)}>
|
|
||||||
* <Header
|
|
||||||
* onToggle={() => {
|
|
||||||
* const willExpand = !isExpanded(g.name);
|
|
||||||
* toggle(g.name);
|
|
||||||
* onToggle(g.name, willExpand);
|
|
||||||
* }}
|
|
||||||
* />
|
|
||||||
* {isExpanded(g.name) && <Body />}
|
|
||||||
* </div>
|
|
||||||
* ))}
|
|
||||||
* </div>
|
|
||||||
*
|
|
||||||
* When the container element is also driven by another ref (e.g. the callback
|
|
||||||
* ref from useRetainedScrollTop), compose them by calling both inside a single
|
|
||||||
* callback ref.
|
|
||||||
*/
|
|
||||||
export function useRevealOnExpand<T extends HTMLElement = HTMLDivElement>() {
|
|
||||||
const containerRef = useRef<T | null>(null);
|
|
||||||
const groupRefs = useRef<Map<string, T>>(new Map());
|
|
||||||
const groupRefCallbacks = useRef<Map<string, (node: T | null) => void>>(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 setContainer = useCallback((node: T | null) => {
|
|
||||||
containerRef.current = node;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Returns a stable ref callback per group name so React doesn't churn the map
|
|
||||||
// (cleanup + re-register) on every render.
|
|
||||||
const registerGroup = useCallback((name: string) => {
|
|
||||||
const existing = groupRefCallbacks.current.get(name);
|
|
||||||
if (existing) return existing;
|
|
||||||
const cb = (node: T | null) => {
|
|
||||||
if (node) groupRefs.current.set(name, node);
|
|
||||||
else groupRefs.current.delete(name);
|
|
||||||
};
|
|
||||||
groupRefCallbacks.current.set(name, cb);
|
|
||||||
return cb;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Call from a group's toggle handler: reveals the group when it is being
|
|
||||||
// expanded, and cancels any pending reveal when it is being collapsed.
|
|
||||||
const onToggle = useCallback((name: string, willExpand: boolean) => {
|
|
||||||
setGroupToReveal(willExpand ? { name } : null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!groupToReveal) return;
|
|
||||||
const el = groupRefs.current.get(groupToReveal.name);
|
|
||||||
const container = containerRef.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]);
|
|
||||||
|
|
||||||
return { setContainer, registerGroup, onToggle };
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import pb from '../lib/pocketbase';
|
import pb from '../lib/pocketbase';
|
||||||
import { apiUrl, authHeaders } from '../lib/api';
|
import { apiUrl, authHeaders } from '../lib/api';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
|
|
@ -25,7 +24,6 @@ function nextPollDelay(attempt: number): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSavedSearches(userId: string | null) {
|
export function useSavedSearches(userId: string | null) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -129,11 +127,11 @@ export function useSavedSearches(userId: string | null) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('savedPage.loadFailed'));
|
setError(err instanceof Error ? err.message : 'Failed to load searches');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [userId, fetchRecords, startPolling, stopPolling, t]);
|
}, [userId, fetchRecords, startPolling, stopPolling]);
|
||||||
|
|
||||||
const saveSearch = useCallback(
|
const saveSearch = useCallback(
|
||||||
async (name: string, paramsOverride?: string) => {
|
async (name: string, paramsOverride?: string) => {
|
||||||
|
|
@ -171,53 +169,44 @@ export function useSavedSearches(userId: string | null) {
|
||||||
console.warn('Background screenshot failed:', err);
|
console.warn('Background screenshot failed:', err);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : t('savedPage.saveFailed');
|
const msg = err instanceof Error ? err.message : 'Failed to save search';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[userId, fetchSearches, t]
|
[userId, fetchSearches]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteSearch = useCallback(
|
const deleteSearch = useCallback(async (id: string) => {
|
||||||
async (id: string) => {
|
setError(null);
|
||||||
setError(null);
|
try {
|
||||||
try {
|
await pb.collection('saved_searches').delete(id);
|
||||||
await pb.collection('saved_searches').delete(id);
|
trackEvent('Search Delete');
|
||||||
trackEvent('Search Delete');
|
setSearches((prev) => prev.filter((s) => s.id !== id));
|
||||||
setSearches((prev) => prev.filter((s) => s.id !== id));
|
} catch (err) {
|
||||||
} catch (err) {
|
setError(err instanceof Error ? err.message : 'Failed to delete search');
|
||||||
setError(err instanceof Error ? err.message : t('savedPage.deleteFailed'));
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSearchNotes = useCallback(
|
const updateSearchNotes = useCallback(async (id: string, notes: string) => {
|
||||||
async (id: string, notes: string) => {
|
try {
|
||||||
try {
|
await pb.collection('saved_searches').update(id, { notes });
|
||||||
await pb.collection('saved_searches').update(id, { notes });
|
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
|
||||||
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
|
} catch (err) {
|
||||||
} catch (err) {
|
setError(err instanceof Error ? err.message : 'Failed to update notes');
|
||||||
setError(err instanceof Error ? err.message : t('savedPage.updateNotesFailed'));
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSearchName = useCallback(
|
const updateSearchName = useCallback(async (id: string, name: string) => {
|
||||||
async (id: string, name: string) => {
|
try {
|
||||||
try {
|
await pb.collection('saved_searches').update(id, { name });
|
||||||
await pb.collection('saved_searches').update(id, { name });
|
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
|
||||||
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
|
} catch (err) {
|
||||||
} catch (err) {
|
setError(err instanceof Error ? err.message : 'Failed to update name');
|
||||||
setError(err instanceof Error ? err.message : t('savedPage.updateNameFailed'));
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSearchParams = useCallback(
|
const updateSearchParams = useCallback(
|
||||||
async (id: string, params: string) => {
|
async (id: string, params: string) => {
|
||||||
|
|
@ -249,14 +238,14 @@ export function useSavedSearches(userId: string | null) {
|
||||||
console.warn('Background screenshot failed:', err);
|
console.warn('Background screenshot failed:', err);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : t('savedPage.updateFailed');
|
const msg = err instanceof Error ? err.message : 'Failed to update search';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[userId, fetchSearches, t]
|
[userId, fetchSearches]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,6 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
|
||||||
placement: 'left' as const,
|
placement: 'left' as const,
|
||||||
skipBeacon: true,
|
skipBeacon: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
target: '[data-tutorial="overlays-button"]',
|
|
||||||
title: t('tutorial.step7Title'),
|
|
||||||
content: t('tutorial.step7Content'),
|
|
||||||
placement: 'left' as const,
|
|
||||||
skipBeacon: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Part des ménages propriétaires de leur logement, intégralement ou avec un crédit',
|
'Part des ménages propriétaires de leur logement, intégralement ou avec un crédit',
|
||||||
'% Social rent':
|
'% Social rent':
|
||||||
"Part des ménages locataires auprès d'une collectivité locale ou d'un bailleur social",
|
"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',
|
'% 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',
|
||||||
|
|
@ -203,7 +204,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Anteil der Haushalte, die ihre Wohnung besitzen, schuldenfrei oder mit Hypothek',
|
'Anteil der Haushalte, die ihre Wohnung besitzen, schuldenfrei oder mit Hypothek',
|
||||||
'% Social rent':
|
'% Social rent':
|
||||||
'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten',
|
'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten',
|
||||||
'% Private rent': 'Anteil der Haushalte, die privat mieten oder mietfrei wohnen',
|
'% 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',
|
||||||
|
|
@ -377,9 +379,12 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'% Degree or higher': 'डिग्री स्तर या उससे ऊंची योग्यता वाले निवासियों (16+) का हिस्सा',
|
'% Degree or higher': 'डिग्री स्तर या उससे ऊंची योग्यता वाले निवासियों (16+) का हिस्सा',
|
||||||
'% Other qualifications':
|
'% Other qualifications':
|
||||||
'अन्य योग्यताओं वाले निवासियों (16+) का हिस्सा, जिनमें व्यावसायिक या विदेश में प्राप्त योग्यताएं शामिल हैं',
|
'अन्य योग्यताओं वाले निवासियों (16+) का हिस्सा, जिनमें व्यावसायिक या विदेश में प्राप्त योग्यताएं शामिल हैं',
|
||||||
'% Owner occupied': 'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ',
|
'% Owner occupied':
|
||||||
'% Social rent': 'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा',
|
'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ',
|
||||||
'% Private rent': 'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा',
|
'% Social rent':
|
||||||
|
'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा',
|
||||||
|
'% Private rent':
|
||||||
|
'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा',
|
||||||
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
'% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||||
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
'% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||||
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
'% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत',
|
||||||
|
|
@ -511,18 +516,6 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* The crime features carry a window suffix ("Burglary (/yr, 7y)"), but the
|
|
||||||
* per-locale description/detail/icon maps are keyed by the bare type's legacy
|
|
||||||
* "(avg/yr)" name — and the text is the same for both windows. Map a crime
|
|
||||||
* feature name back to that legacy key so the existing translations resolve
|
|
||||||
* without duplicating every entry per window.
|
|
||||||
*/
|
|
||||||
export function legacyCrimeFeatureKey(featureName: string): string {
|
|
||||||
const match = featureName.match(/^(.*) \(\/yr, \d+y\)$/);
|
|
||||||
return match ? `${match[1]} (avg/yr)` : featureName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate a feature description.
|
* Translate a feature description.
|
||||||
* - English: returns the server-provided description (single source of truth)
|
* - English: returns the server-provided description (single source of truth)
|
||||||
|
|
@ -531,8 +524,7 @@ export function legacyCrimeFeatureKey(featureName: string): string {
|
||||||
export function tsDesc(featureName: string, englishFromServer: string): string {
|
export function tsDesc(featureName: string, englishFromServer: string): string {
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
if (lang === 'en') return englishFromServer;
|
if (lang === 'en') return englishFromServer;
|
||||||
const key = legacyCrimeFeatureKey(featureName);
|
return descriptions[lang]?.[featureName] ?? englishFromServer;
|
||||||
return descriptions[lang]?.[key] ?? englishFromServer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -542,6 +534,5 @@ export function tsDesc(featureName: string, englishFromServer: string): string {
|
||||||
export function tsDetail(featureName: string, englishFromServer: string): string {
|
export function tsDetail(featureName: string, englishFromServer: string): string {
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
if (lang === 'en') return englishFromServer;
|
if (lang === 'en') return englishFromServer;
|
||||||
const key = legacyCrimeFeatureKey(featureName);
|
return details[lang]?.[featureName] ?? englishFromServer;
|
||||||
return details[lang]?.[key] ?? englishFromServer;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@ const de: Translations = {
|
||||||
copy: 'Kopieren',
|
copy: 'Kopieren',
|
||||||
copied: 'Kopiert!',
|
copied: 'Kopiert!',
|
||||||
copiedToClipboard: 'In die Zwischenablage kopiert',
|
copiedToClipboard: 'In die Zwischenablage kopiert',
|
||||||
captions: 'Untertitel',
|
|
||||||
sqm: 'm²',
|
|
||||||
loading: 'Wird geladen...',
|
loading: 'Wird geladen...',
|
||||||
loadMore: 'Mehr anzeigen',
|
loadMore: 'Mehr anzeigen',
|
||||||
remaining: 'noch {{count}}',
|
remaining: 'noch {{count}}',
|
||||||
|
|
@ -624,17 +622,13 @@ const de: Translations = {
|
||||||
backToLogin: 'Zurück zur Anmeldung',
|
backToLogin: 'Zurück zur Anmeldung',
|
||||||
registerConsent:
|
registerConsent:
|
||||||
'Mit der Kontoerstellung akzeptierst du unsere <terms>Nutzungsbedingungen</terms> und unsere <privacy>Datenschutzerklärung</privacy>.',
|
'Mit der Kontoerstellung akzeptierst du unsere <terms>Nutzungsbedingungen</terms> und unsere <privacy>Datenschutzerklärung</privacy>.',
|
||||||
loginFailed: 'Anmeldung fehlgeschlagen',
|
|
||||||
registrationFailed: 'Registrierung fehlgeschlagen',
|
|
||||||
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
|
|
||||||
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'Unbegrenzte Filter freischalten',
|
title: 'Jeden passenden Postcode finden',
|
||||||
description:
|
description:
|
||||||
'Kostenlose Konten können bis zu 3 Filter gleichzeitig kombinieren. Erhalte lebenslangen Zugang, um unbegrenzt Filter über jeden Postcode und jede Nachbarschaft in England hinweg zu kombinieren. Einmal zahlen, für immer nutzen.',
|
'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jedem Postcode, jedem Filter und jeder Nachbarschaft in England. Einmal zahlen, dauerhaft nutzen.',
|
||||||
free: 'Kostenlos',
|
free: 'Kostenlos',
|
||||||
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
||||||
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
|
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
|
||||||
|
|
@ -643,7 +637,10 @@ const de: Translations = {
|
||||||
upgradeFor: 'Upgrade für {{price}}',
|
upgradeFor: 'Upgrade für {{price}}',
|
||||||
registerAndUpgrade: 'Registrieren & upgraden',
|
registerAndUpgrade: 'Registrieren & upgraden',
|
||||||
alreadyHaveAccount: 'Bereits ein Konto? Anmelden',
|
alreadyHaveAccount: 'Bereits ein Konto? Anmelden',
|
||||||
continueFree: 'Mit 3 Filtern fortfahren',
|
continueWithDemo: 'Mit Demo fortfahren',
|
||||||
|
backToSharedArea: 'Zurück zum geteilten Gebiet',
|
||||||
|
sharedAreaDescription:
|
||||||
|
'Du siehst ein geteiltes Gebiet. Um darüber hinaus zu suchen, sichere dir lebenslangen Zugriff auf jeden Postcode, jeden Filter und jede Nachbarschaft in England.',
|
||||||
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -721,11 +718,6 @@ const de: Translations = {
|
||||||
outstanding: 'Outstanding',
|
outstanding: 'Outstanding',
|
||||||
distance: 'Entfernung',
|
distance: 'Entfernung',
|
||||||
crimeType: 'Deliktart',
|
crimeType: 'Deliktart',
|
||||||
qualificationLevel: 'Qualifikationsniveau',
|
|
||||||
tenureType: 'Wohnform',
|
|
||||||
crimeWindow: 'Zeitraum',
|
|
||||||
crimeWindow7y: '7 Jahre',
|
|
||||||
crimeWindow2y: '2 Jahre',
|
|
||||||
ethnicity: 'Ethnie',
|
ethnicity: 'Ethnie',
|
||||||
poiType: 'POI-Typ',
|
poiType: 'POI-Typ',
|
||||||
party: 'Partei',
|
party: 'Partei',
|
||||||
|
|
@ -816,7 +808,6 @@ const de: Translations = {
|
||||||
refiningResults: 'Ergebnisse werden verfeinert...',
|
refiningResults: 'Ergebnisse werden verfeinert...',
|
||||||
weeklyLimitReached:
|
weeklyLimitReached:
|
||||||
'Du hast das wöchentliche KI-Limit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
|
'Du hast das wöchentliche KI-Limit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
|
||||||
generateFailed: 'Filter konnten nicht generiert werden',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
|
|
@ -836,18 +827,6 @@ const de: Translations = {
|
||||||
ogSchools: 'Schulen',
|
ogSchools: 'Schulen',
|
||||||
ogCrimeStats: 'Kriminalitätsdaten',
|
ogCrimeStats: 'Kriminalitätsdaten',
|
||||||
ogTransport: 'Transport',
|
ogTransport: 'Transport',
|
||||||
basemap: { standard: 'Karte', satellite: 'Satellit' },
|
|
||||||
error: {
|
|
||||||
heading: 'Bei der Karte ist ein Problem aufgetreten',
|
|
||||||
body: 'Das kann passieren, wenn der Grafikkontext deines Browsers unterbrochen wird.',
|
|
||||||
reload: 'Karte neu laden',
|
|
||||||
},
|
|
||||||
actualListings: {
|
|
||||||
label: 'Inserate',
|
|
||||||
show: 'Tatsächliche Inserate anzeigen',
|
|
||||||
hide: 'Tatsächliche Inserate ausblenden',
|
|
||||||
},
|
|
||||||
poi: { zoomInToSeeDetails: 'Für Details hineinzoomen' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Properties Pane ────────────────────────────────
|
// ── Properties Pane ────────────────────────────────
|
||||||
|
|
@ -927,20 +906,6 @@ const de: Translations = {
|
||||||
crimeDataEnds: 'Polizeidaten für dieses Gebiet enden {{year}}',
|
crimeDataEnds: 'Polizeidaten für dieses Gebiet enden {{year}}',
|
||||||
residents: 'Einwohner',
|
residents: 'Einwohner',
|
||||||
residentsTooltip: 'Wohnbevölkerung (ONS-Zensus 2021)',
|
residentsTooltip: 'Wohnbevölkerung (ONS-Zensus 2021)',
|
||||||
crimeWindow7y: 'Letzte 7 Jahre',
|
|
||||||
crimeWindow2y: 'Letzte 2 Jahre',
|
|
||||||
crimeWindowLabel: 'Zeitraum',
|
|
||||||
crimeCardTitle: '{{name}} pro Jahr',
|
|
||||||
crimeRecordsToggle: 'Einzelne Straftaten anzeigen',
|
|
||||||
crimeRecordsHide: 'Einzelne Straftaten ausblenden',
|
|
||||||
crimeRecordsCount: '{{count}} Straftaten',
|
|
||||||
crimeRecordsLoading: 'Straftaten werden geladen…',
|
|
||||||
crimeRecordsEmpty: 'Keine einzelnen Straftaten erfasst',
|
|
||||||
crimeRecordsError: 'Straftaten konnten nicht geladen werden',
|
|
||||||
crimeRecordsLoadMore: 'Mehr laden',
|
|
||||||
crimeNoLocation: 'Ort nicht angegeben',
|
|
||||||
crimeOutcomeUnknown: 'Ergebnis nicht erfasst',
|
|
||||||
crimeYearPointTooltip: '{{year}}: {{rate}}/Jahr',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -965,7 +930,6 @@ const de: Translations = {
|
||||||
searchOn: 'Im Umkreis von {{radius}} suchen bei',
|
searchOn: 'Im Umkreis von {{radius}} suchen bei',
|
||||||
exact: 'genau',
|
exact: 'genau',
|
||||||
outcodeNotRecognised: 'Outcode nicht erkannt',
|
outcodeNotRecognised: 'Outcode nicht erkannt',
|
||||||
radiusMi: 'Umkreis {{count}} mi',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Location Search ────────────────────────────────
|
// ── Location Search ────────────────────────────────
|
||||||
|
|
@ -980,6 +944,14 @@ const de: Translations = {
|
||||||
geolocationFailed: 'Standort konnte nicht ermittelt werden',
|
geolocationFailed: 'Standort konnte nicht ermittelt werden',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
demoLocation: {
|
||||||
|
title: 'Ihre Umgebung erkunden',
|
||||||
|
body: 'Möchten Sie Immobiliendaten in Ihrer Nähe sehen? Verwenden Sie Ihren Standort oder starten Sie die Demo in Canary Wharf.',
|
||||||
|
useLocation: 'Meinen Standort verwenden',
|
||||||
|
locating: 'Standort wird ermittelt…',
|
||||||
|
useDefault: 'Canary Wharf anzeigen',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Mobile Drawer ──────────────────────────────────
|
// ── Mobile Drawer ──────────────────────────────────
|
||||||
mobileDrawer: {
|
mobileDrawer: {
|
||||||
closeDrawer: 'Drawer schließen',
|
closeDrawer: 'Drawer schließen',
|
||||||
|
|
@ -1014,7 +986,6 @@ const de: Translations = {
|
||||||
showcaseJourneyRoutes: 'Routen',
|
showcaseJourneyRoutes: 'Routen',
|
||||||
showcaseNearby: '{{value}} in der Nähe',
|
showcaseNearby: '{{value}} in der Nähe',
|
||||||
showcasePoliticalVoteShare: 'Politischer Stimmenanteil',
|
showcasePoliticalVoteShare: 'Politischer Stimmenanteil',
|
||||||
showcaseGe2024: 'Unterhauswahl 2024',
|
|
||||||
showcaseLotsMore: '...und vieles mehr',
|
showcaseLotsMore: '...und vieles mehr',
|
||||||
showcaseMinutes: '{{count}} Min.',
|
showcaseMinutes: '{{count}} Min.',
|
||||||
showcaseSendShortlist: 'Shortlist senden',
|
showcaseSendShortlist: 'Shortlist senden',
|
||||||
|
|
@ -1397,7 +1368,7 @@ const de: Translations = {
|
||||||
'Lebenslanger Zugang bedeutet, dass eine Zahlung deinem Konto laufenden Zugriff auf die kostenpflichtige Perfect-Postcode-Karte für die Lebensdauer des Dienstes gibt. Es ist kein Monats- oder Jahresabo, und normale Datenaktualisierungen sind enthalten. Du kannst es während dieser Suche nutzen, später zurückkommen und weiterhin Zugriff haben, wenn du erneut umziehst.',
|
'Lebenslanger Zugang bedeutet, dass eine Zahlung deinem Konto laufenden Zugriff auf die kostenpflichtige Perfect-Postcode-Karte für die Lebensdauer des Dienstes gibt. Es ist kein Monats- oder Jahresabo, und normale Datenaktualisierungen sind enthalten. Du kannst es während dieser Suche nutzen, später zurückkommen und weiterhin Zugriff haben, wenn du erneut umziehst.',
|
||||||
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
|
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
|
||||||
faqPricing3A:
|
faqPricing3A:
|
||||||
'Kostenlose Nutzer erhalten die vollständige Perfect-Postcode-Karte für ganz England – jeden Postcode und jede Funktion – und können bis zu 3 Filter gleichzeitig anwenden. Der lebenslange Zugang hebt diese Grenze auf, sodass du unbegrenzt Filter kombinieren kannst.',
|
'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigst du den lebenslangen Zugang.',
|
||||||
|
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'Wie sehe ich eine Filtervorschau auf der Karte?',
|
faqTips1Q: 'Wie sehe ich eine Filtervorschau auf der Karte?',
|
||||||
|
|
@ -1447,8 +1418,6 @@ const de: Translations = {
|
||||||
noShareLinksYet: 'Noch keine geteilten Links',
|
noShareLinksYet: 'Noch keine geteilten Links',
|
||||||
copyShareLink: 'Geteilten Link kopieren',
|
copyShareLink: 'Geteilten Link kopieren',
|
||||||
clicksLabel: 'Klicks',
|
clicksLabel: 'Klicks',
|
||||||
fetchShareLinksError: 'Geteilte Links konnten nicht abgerufen werden',
|
|
||||||
updateNewsletterError: 'Newsletter konnte nicht aktualisiert werden',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Saved Page ─────────────────────────────────────
|
// ── Saved Page ─────────────────────────────────────
|
||||||
|
|
@ -1464,12 +1433,6 @@ const de: Translations = {
|
||||||
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||||
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
|
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
|
||||||
updating: 'Wird aktualisiert...',
|
updating: 'Wird aktualisiert...',
|
||||||
loadFailed: 'Suchen konnten nicht geladen werden',
|
|
||||||
saveFailed: 'Suche konnte nicht gespeichert werden',
|
|
||||||
deleteFailed: 'Suche konnte nicht gelöscht werden',
|
|
||||||
updateNotesFailed: 'Notizen konnten nicht aktualisiert werden',
|
|
||||||
updateNameFailed: 'Name konnte nicht aktualisiert werden',
|
|
||||||
updateFailed: 'Suche konnte nicht aktualisiert werden',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invites Page ───────────────────────────────────
|
// ── Invites Page ───────────────────────────────────
|
||||||
|
|
@ -1489,7 +1452,6 @@ const de: Translations = {
|
||||||
created: 'Erstellt',
|
created: 'Erstellt',
|
||||||
redeemed: 'Eingelöst',
|
redeemed: 'Eingelöst',
|
||||||
pending: 'Ausstehend',
|
pending: 'Ausstehend',
|
||||||
createInviteError: 'Einladung konnte nicht erstellt werden',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invite Page ────────────────────────────────────
|
// ── Invite Page ────────────────────────────────────
|
||||||
|
|
@ -1516,99 +1478,6 @@ const de: Translations = {
|
||||||
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
|
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
|
||||||
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
|
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
|
||||||
failedToValidate: 'Einladungslink konnte nicht geprüft werden',
|
failedToValidate: 'Einladungslink konnte nicht geprüft werden',
|
||||||
redeemFailed: 'Einladung konnte nicht eingelöst werden',
|
|
||||||
},
|
|
||||||
|
|
||||||
errors: {
|
|
||||||
appCrashTitle: 'Etwas ist schiefgelaufen',
|
|
||||||
appCrashBody: 'Lade die Seite neu, um es erneut zu versuchen.',
|
|
||||||
},
|
|
||||||
|
|
||||||
listing: {
|
|
||||||
viewed: 'Angesehen',
|
|
||||||
openListing: 'Inserat öffnen',
|
|
||||||
listings: 'Inserate',
|
|
||||||
listing: 'Inserat',
|
|
||||||
showingOf: '{{visible}} von {{count}} werden angezeigt',
|
|
||||||
groupedNear: 'Gruppiert an dieser Kartenposition',
|
|
||||||
},
|
|
||||||
|
|
||||||
overlays: {
|
|
||||||
heading: 'Overlays',
|
|
||||||
baseMap: 'Basiskarte',
|
|
||||||
colourOpacity: 'Farbdeckkraft',
|
|
||||||
dataOverlays: 'Daten-Overlays',
|
|
||||||
about: 'Über {{name}}',
|
|
||||||
zoomWarning: 'Zoome weiter hinein, um das ausgewählte Overlay zu sehen.',
|
|
||||||
zoomWarning_other: 'Zoome weiter hinein, um die ausgewählten Overlays zu sehen.',
|
|
||||||
noise: {
|
|
||||||
label: 'Lärm',
|
|
||||||
detail:
|
|
||||||
'Defra Strategic Noise Mapping Round 4 (2022), kombiniert Quellen aus Straße, Schiene und Flughäfen. Die Werte verwenden die EU-Standardgröße Lden (gewichteter 24-Stunden-Mittelwert für Tag, Abend und Nacht), modelliert auf einem 10-m-Raster in 4 m Höhe über Grund. Hellere Bereiche zeigen höhere modellierte Lärmwerte an. Lizenziert unter der Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
crimeHotspots: {
|
|
||||||
label: 'Kriminalitäts-Hotspots',
|
|
||||||
detail:
|
|
||||||
'Clientseitige Heatmap der von police.uk veröffentlichten Straftaten auf Straßenebene der letzten Monate. Die Koordinaten von police.uk sind anonymisierte, auf ein Raster gerundete Punkte und keine exakten Tatorte. Die Heatmap sollte daher als Näherung der relativen Dichte und nicht als präzise Karte einzelner Vorfälle gelesen werden.',
|
|
||||||
},
|
|
||||||
treesOutsideWoodlands: {
|
|
||||||
label: 'Bäume & Wald',
|
|
||||||
detail:
|
|
||||||
'Canopy-Polygone aus Forest Research Trees Outside Woodland (TOW) v1 — Einzelbäume und Baumgruppen — vereint mit Waldflächen des National Forest Inventory (NFI) (≥0,5 ha), die TOW bewusst ausschließt. Zusammen erfassen sie sowohl Straßenbäume als auch tatsächliche Wälder. Die Deckkraft der Polygone skaliert mit der Kronenfläche.',
|
|
||||||
},
|
|
||||||
propertyBorders: {
|
|
||||||
label: 'Grundstücksgrenzen',
|
|
||||||
detail:
|
|
||||||
'HM Land Registry INSPIRE Index Polygons — die Lage und ungefähre Ausdehnung von im Freehold eingetragenem Eigentum in England & Wales, dargestellt als Umrisse auf Straßenebene. Dies sind „general boundaries“ nur zur Orientierung und nicht die genaue rechtliche Grundstücksgrenze; sie schließen reine Leasehold-Interessen und nicht eingetragenes Land aus (rund 85–90 % des Freehold-Lands sind erfasst). Diese Informationen unterliegen dem Crown copyright und den Datenbankrechten 2026 und werden mit Genehmigung des HM Land Registry wiedergegeben. Die Polygone (einschließlich der zugehörigen Geometrie, namentlich x-, y-Koordinaten) unterliegen dem Crown copyright und den Datenbankrechten 2026 Ordnance Survey AC0000851063. Lizenziert unter der Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
newDevelopments: {
|
|
||||||
label: 'Neubauprojekte',
|
|
||||||
detail:
|
|
||||||
'Eine vorausschauende Pipeline neuer Wohnbauten. Blaue Marker kennzeichnen Standorte in den gesetzlichen MHCLG Brownfield Land Registers — jeweils mit einer geschätzten Netto-Wohnungskapazität und einem Status der Baugenehmigung — zusammen mit Veräußerungsstandorten des Homes England Land Hub. Sie zeigen, wo neue Wohnungen geplant sind, oft Jahre bevor sie in EPC- oder Verkaufsdaten auftauchen. Die Wohnungszahlen sind Kapazitätsschätzungen, keine Zusagen, und ein Standort in einem Register ist eine Gelegenheit und keine Garantie für eine Bebauung. Lizenziert unter der Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
crimeTypes: {
|
|
||||||
violenceAndSexualOffences: 'Gewalt- & Sexualdelikte',
|
|
||||||
antiSocialBehaviour: 'Antisoziales Verhalten',
|
|
||||||
criminalDamageAndArson: 'Sachbeschädigung & Brandstiftung',
|
|
||||||
publicOrder: 'Öffentliche Ordnung',
|
|
||||||
shoplifting: 'Ladendiebstahl',
|
|
||||||
vehicleCrime: 'Fahrzeugkriminalität',
|
|
||||||
burglary: 'Einbruch',
|
|
||||||
otherTheft: 'Sonstiger Diebstahl',
|
|
||||||
theftFromThePerson: 'Diebstahl an Personen',
|
|
||||||
bicycleTheft: 'Fahrraddiebstahl',
|
|
||||||
drugs: 'Drogen',
|
|
||||||
robbery: 'Raub',
|
|
||||||
possessionOfWeapons: 'Waffenbesitz',
|
|
||||||
otherCrime: 'Sonstige Kriminalität',
|
|
||||||
},
|
|
||||||
|
|
||||||
journey: {
|
|
||||||
bus: 'Bus',
|
|
||||||
lineSuffix: 'Linie',
|
|
||||||
},
|
|
||||||
|
|
||||||
poiPopup: {
|
|
||||||
school: {
|
|
||||||
capacity: 'Kapazität',
|
|
||||||
type: 'Art',
|
|
||||||
ages: 'Alter',
|
|
||||||
gender: 'Geschlecht',
|
|
||||||
pupilsLabel: 'Schüler',
|
|
||||||
pupils: 'Schüler',
|
|
||||||
freeMeal: 'Kostenloses Essen',
|
|
||||||
ofsted: 'Ofsted',
|
|
||||||
sixthForm: 'Oberstufe',
|
|
||||||
religion: 'Religion',
|
|
||||||
admissions: 'Aufnahme',
|
|
||||||
trust: 'Trust',
|
|
||||||
address: 'Adresse',
|
|
||||||
localAuthority: 'LA',
|
|
||||||
head: 'Schulleitung',
|
|
||||||
website: 'Website',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Format / Time ──────────────────────────────────
|
// ── Format / Time ──────────────────────────────────
|
||||||
|
|
@ -1650,9 +1519,6 @@ const de: Translations = {
|
||||||
step6Title: 'Was ist in der Nähe?',
|
step6Title: 'Was ist in der Nähe?',
|
||||||
step6Content:
|
step6Content:
|
||||||
'Blende Schulen, Geschäfte, Bahnhöfe, Parks und Restaurants auf der Karte ein, um zu sehen, was erreichbar ist.',
|
'Blende Schulen, Geschäfte, Bahnhöfe, Parks und Restaurants auf der Karte ein, um zu sehen, was erreichbar ist.',
|
||||||
step7Title: 'Mehr Kontext einblenden',
|
|
||||||
step7Content:
|
|
||||||
'Blende Overlays wie Kriminalitäts-Hotspots, Lärm, Baumbestand, Grundstücksgrenzen und Neubaugebiete auf der Karte ein und wechsle die Basiskarte nach Bedarf.',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Server-derived values ──────────────────────────
|
// ── Server-derived values ──────────────────────────
|
||||||
|
|
@ -1675,11 +1541,11 @@ const de: Translations = {
|
||||||
'Property type': 'Immobilientyp',
|
'Property type': 'Immobilientyp',
|
||||||
'Leasehold/Freehold': 'Leasehold/Freehold',
|
'Leasehold/Freehold': 'Leasehold/Freehold',
|
||||||
'Last known price': 'Letzter bekannter Preis',
|
'Last known price': 'Letzter bekannter Preis',
|
||||||
'Estimated price': 'Gesch. Preis',
|
'Estimated price': 'Geschätzter Preis',
|
||||||
'Estimated current price': 'Gesch. heutiger Preis',
|
'Estimated current price': 'Geschätzter heutiger Preis',
|
||||||
'Price per sqm': 'Preis pro m²',
|
'Price per sqm': 'Preis pro m²',
|
||||||
'Est. price per sqm': 'Gesch. Preis pro m²',
|
'Est. price per sqm': 'Gesch. Preis pro m²',
|
||||||
'Estimated monthly rent': 'Gesch. Monatsmiete',
|
'Estimated monthly rent': 'Geschätzte Monatsmiete',
|
||||||
'Total floor area (sqm)': 'Gesamtwohnfläche (m²)',
|
'Total floor area (sqm)': 'Gesamtwohnfläche (m²)',
|
||||||
'Number of bedrooms & living rooms': 'Anzahl Schlaf- und Wohnzimmer',
|
'Number of bedrooms & living rooms': 'Anzahl Schlaf- und Wohnzimmer',
|
||||||
'Construction year': 'Baujahr',
|
'Construction year': 'Baujahr',
|
||||||
|
|
@ -1712,54 +1578,23 @@ const de: Translations = {
|
||||||
'Housing Conditions Score': 'Wert der Wohnbedingungen',
|
'Housing Conditions Score': 'Wert der Wohnbedingungen',
|
||||||
'Air Quality and Road Safety Score': 'Wert für Luftqualität und Verkehrssicherheit',
|
'Air Quality and Road Safety Score': 'Wert für Luftqualität und Verkehrssicherheit',
|
||||||
|
|
||||||
// ─ Feature names (Crime) — bevölkerungsnormierte Rate, 7 Jahre & 2 Jahre ─
|
// ─ Feature names (Crime) ─
|
||||||
'Serious crime (/yr, 7y)': 'Schwere Straftaten (pro Jahr, 7 J.)',
|
'Serious crime (avg/yr)': 'Schwere Straftaten (Dichte)',
|
||||||
'Serious crime (/yr, 2y)': 'Schwere Straftaten (pro Jahr, 2 J.)',
|
'Minor crime (avg/yr)': 'Leichte Straftaten (Dichte)',
|
||||||
'Minor crime (/yr, 7y)': 'Leichte Straftaten (pro Jahr, 7 J.)',
|
'Violence and sexual offences (avg/yr)': 'Gewalt- und Sexualdelikte (Dichte)',
|
||||||
'Minor crime (/yr, 2y)': 'Leichte Straftaten (pro Jahr, 2 J.)',
|
'Burglary (avg/yr)': 'Einbrüche (Dichte)',
|
||||||
'Violence and sexual offences (/yr, 7y)': 'Gewalt- und Sexualdelikte (pro Jahr, 7 J.)',
|
'Robbery (avg/yr)': 'Raubüberfälle (Dichte)',
|
||||||
'Violence and sexual offences (/yr, 2y)': 'Gewalt- und Sexualdelikte (pro Jahr, 2 J.)',
|
'Vehicle crime (avg/yr)': 'Fahrzeugkriminalität (Dichte)',
|
||||||
'Burglary (/yr, 7y)': 'Einbrüche (pro Jahr, 7 J.)',
|
'Anti-social behaviour (avg/yr)': 'Antisoziales Verhalten (Dichte)',
|
||||||
'Burglary (/yr, 2y)': 'Einbrüche (pro Jahr, 2 J.)',
|
'Criminal damage and arson (avg/yr)': 'Sachbeschädigung und Brandstiftung (Dichte)',
|
||||||
'Robbery (/yr, 7y)': 'Raubüberfälle (pro Jahr, 7 J.)',
|
'Other theft (avg/yr)': 'Sonstiger Diebstahl (Dichte)',
|
||||||
'Robbery (/yr, 2y)': 'Raubüberfälle (pro Jahr, 2 J.)',
|
'Theft from the person (avg/yr)': 'Diebstahl von der Person (Dichte)',
|
||||||
'Vehicle crime (/yr, 7y)': 'Fahrzeugkriminalität (pro Jahr, 7 J.)',
|
'Shoplifting (avg/yr)': 'Ladendiebstahl (Dichte)',
|
||||||
'Vehicle crime (/yr, 2y)': 'Fahrzeugkriminalität (pro Jahr, 2 J.)',
|
'Bicycle theft (avg/yr)': 'Fahrraddiebstahl (Dichte)',
|
||||||
'Anti-social behaviour (/yr, 7y)': 'Antisoziales Verhalten (pro Jahr, 7 J.)',
|
'Drugs (avg/yr)': 'Drogendelikte (Dichte)',
|
||||||
'Anti-social behaviour (/yr, 2y)': 'Antisoziales Verhalten (pro Jahr, 2 J.)',
|
'Possession of weapons (avg/yr)': 'Waffenbesitz (Dichte)',
|
||||||
'Criminal damage and arson (/yr, 7y)': 'Sachbeschädigung und Brandstiftung (pro Jahr, 7 J.)',
|
'Public order (avg/yr)': 'Störung der öffentlichen Ordnung (Dichte)',
|
||||||
'Criminal damage and arson (/yr, 2y)': 'Sachbeschädigung und Brandstiftung (pro Jahr, 2 J.)',
|
'Other crime (avg/yr)': 'Sonstige Straftaten (Dichte)',
|
||||||
'Other theft (/yr, 7y)': 'Sonstiger Diebstahl (pro Jahr, 7 J.)',
|
|
||||||
'Other theft (/yr, 2y)': 'Sonstiger Diebstahl (pro Jahr, 2 J.)',
|
|
||||||
'Theft from the person (/yr, 7y)': 'Diebstahl von der Person (pro Jahr, 7 J.)',
|
|
||||||
'Theft from the person (/yr, 2y)': 'Diebstahl von der Person (pro Jahr, 2 J.)',
|
|
||||||
'Shoplifting (/yr, 7y)': 'Ladendiebstahl (pro Jahr, 7 J.)',
|
|
||||||
'Shoplifting (/yr, 2y)': 'Ladendiebstahl (pro Jahr, 2 J.)',
|
|
||||||
'Bicycle theft (/yr, 7y)': 'Fahrraddiebstahl (pro Jahr, 7 J.)',
|
|
||||||
'Bicycle theft (/yr, 2y)': 'Fahrraddiebstahl (pro Jahr, 2 J.)',
|
|
||||||
'Drugs (/yr, 7y)': 'Drogendelikte (pro Jahr, 7 J.)',
|
|
||||||
'Drugs (/yr, 2y)': 'Drogendelikte (pro Jahr, 2 J.)',
|
|
||||||
'Possession of weapons (/yr, 7y)': 'Waffenbesitz (pro Jahr, 7 J.)',
|
|
||||||
'Possession of weapons (/yr, 2y)': 'Waffenbesitz (pro Jahr, 2 J.)',
|
|
||||||
'Public order (/yr, 7y)': 'Störung der öffentlichen Ordnung (pro Jahr, 7 J.)',
|
|
||||||
'Public order (/yr, 2y)': 'Störung der öffentlichen Ordnung (pro Jahr, 2 J.)',
|
|
||||||
'Other crime (/yr, 7y)': 'Sonstige Straftaten (pro Jahr, 7 J.)',
|
|
||||||
'Other crime (/yr, 2y)': 'Sonstige Straftaten (pro Jahr, 2 J.)',
|
|
||||||
// Reine Deliktbezeichnungen (Aufschlüsselung nach Art + Einzeldatensätze).
|
|
||||||
// („Burglary“ ist bereits bei den spezifischen Delikten unten definiert.)
|
|
||||||
'Violence and sexual offences': 'Gewalt- und Sexualdelikte',
|
|
||||||
Robbery: 'Raubüberfälle',
|
|
||||||
'Vehicle crime': 'Fahrzeugkriminalität',
|
|
||||||
'Anti-social behaviour': 'Antisoziales Verhalten',
|
|
||||||
'Criminal damage and arson': 'Sachbeschädigung und Brandstiftung',
|
|
||||||
'Other theft': 'Sonstiger Diebstahl',
|
|
||||||
'Theft from the person': 'Diebstahl von der Person',
|
|
||||||
Shoplifting: 'Ladendiebstahl',
|
|
||||||
'Bicycle theft': 'Fahrraddiebstahl',
|
|
||||||
Drugs: 'Drogendelikte',
|
|
||||||
'Possession of weapons': 'Waffenbesitz',
|
|
||||||
'Public order': 'Störung der öffentlichen Ordnung',
|
|
||||||
'Other crime': 'Sonstige Straftaten',
|
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': 'Medianalter',
|
'Median age': 'Medianalter',
|
||||||
|
|
@ -1847,7 +1682,6 @@ const de: Translations = {
|
||||||
'Bus station': 'Busbahnhof',
|
'Bus station': 'Busbahnhof',
|
||||||
'Taxi rank': 'Taxistand',
|
'Taxi rank': 'Taxistand',
|
||||||
'Tube station': 'U-Bahn-Station',
|
'Tube station': 'U-Bahn-Station',
|
||||||
'DLR station': 'DLR-Station',
|
|
||||||
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
|
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
|
||||||
Café: 'Café',
|
Café: 'Café',
|
||||||
Restaurant: 'Restaurant',
|
Restaurant: 'Restaurant',
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,6 @@ const en = {
|
||||||
closePane: 'Close pane',
|
closePane: 'Close pane',
|
||||||
yes: 'Yes',
|
yes: 'Yes',
|
||||||
no: 'No',
|
no: 'No',
|
||||||
captions: 'Captions',
|
|
||||||
sqm: 'sqm',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Header / Nav ───────────────────────────────────
|
// ── Header / Nav ───────────────────────────────────
|
||||||
|
|
@ -612,17 +610,13 @@ const en = {
|
||||||
backToLogin: 'Back to login',
|
backToLogin: 'Back to login',
|
||||||
registerConsent:
|
registerConsent:
|
||||||
'By creating an account you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>.',
|
'By creating an account you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>.',
|
||||||
loginFailed: 'Login failed',
|
|
||||||
registrationFailed: 'Registration failed',
|
|
||||||
oauthFailed: 'OAuth login failed',
|
|
||||||
passwordResetFailed: 'Password reset request failed',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'Unlock unlimited filters',
|
title: 'Find every matching postcode',
|
||||||
description:
|
description:
|
||||||
'Free accounts can combine up to 3 filters at a time. Get lifetime access to stack unlimited filters across every postcode and neighbourhood in England. One payment, forever.',
|
'You’re currently exploring the demo area. Get lifetime access to every postcode, every filter, and every neighbourhood in England. One payment, forever.',
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
freeForEarly: 'Free for early adopters. No credit card required.',
|
freeForEarly: 'Free for early adopters. No credit card required.',
|
||||||
oneTimePayment: 'One-time payment. Lifetime access.',
|
oneTimePayment: 'One-time payment. Lifetime access.',
|
||||||
|
|
@ -631,7 +625,10 @@ const en = {
|
||||||
upgradeFor: 'Upgrade for {{price}}',
|
upgradeFor: 'Upgrade for {{price}}',
|
||||||
registerAndUpgrade: 'Register & Upgrade',
|
registerAndUpgrade: 'Register & Upgrade',
|
||||||
alreadyHaveAccount: 'Already have an account? Log in',
|
alreadyHaveAccount: 'Already have an account? Log in',
|
||||||
continueFree: 'Continue with 3 filters',
|
continueWithDemo: 'Continue with demo',
|
||||||
|
backToSharedArea: 'Back to shared area',
|
||||||
|
sharedAreaDescription:
|
||||||
|
'You’re viewing a shared area. To explore beyond it, get lifetime access to every postcode, every filter, and every neighbourhood in England.',
|
||||||
checkoutFailed: 'Checkout failed',
|
checkoutFailed: 'Checkout failed',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -707,11 +704,6 @@ const en = {
|
||||||
outstanding: 'Outstanding',
|
outstanding: 'Outstanding',
|
||||||
distance: 'Distance',
|
distance: 'Distance',
|
||||||
crimeType: 'Crime type',
|
crimeType: 'Crime type',
|
||||||
qualificationLevel: 'Qualification level',
|
|
||||||
tenureType: 'Tenure type',
|
|
||||||
crimeWindow: 'Averaging period',
|
|
||||||
crimeWindow7y: '7-year',
|
|
||||||
crimeWindow2y: '2-year',
|
|
||||||
ethnicity: 'Ethnicity',
|
ethnicity: 'Ethnicity',
|
||||||
poiType: 'POI type',
|
poiType: 'POI type',
|
||||||
party: 'Party',
|
party: 'Party',
|
||||||
|
|
@ -802,7 +794,6 @@ const en = {
|
||||||
refiningResults: 'Refining results...',
|
refiningResults: 'Refining results...',
|
||||||
weeklyLimitReached:
|
weeklyLimitReached:
|
||||||
'You’ve reached the weekly AI usage limit. It’ll reset automatically next week.',
|
'You’ve reached the weekly AI usage limit. It’ll reset automatically next week.',
|
||||||
generateFailed: 'Failed to generate filters',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
|
|
@ -822,23 +813,6 @@ const en = {
|
||||||
ogSchools: 'Schools',
|
ogSchools: 'Schools',
|
||||||
ogCrimeStats: 'Crime stats',
|
ogCrimeStats: 'Crime stats',
|
||||||
ogTransport: 'Transport',
|
ogTransport: 'Transport',
|
||||||
basemap: {
|
|
||||||
standard: 'Map',
|
|
||||||
satellite: 'Satellite',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
heading: 'The map ran into a problem',
|
|
||||||
body: 'This can happen when your browser’s graphics context is interrupted.',
|
|
||||||
reload: 'Reload the map',
|
|
||||||
},
|
|
||||||
actualListings: {
|
|
||||||
label: 'Listings',
|
|
||||||
show: 'Show actual listings',
|
|
||||||
hide: 'Hide actual listings',
|
|
||||||
},
|
|
||||||
poi: {
|
|
||||||
zoomInToSeeDetails: 'Zoom in to see details',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Properties Pane ────────────────────────────────
|
// ── Properties Pane ────────────────────────────────
|
||||||
|
|
@ -916,20 +890,6 @@ const en = {
|
||||||
crimeDataEnds: 'Police data for this area ends {{year}}',
|
crimeDataEnds: 'Police data for this area ends {{year}}',
|
||||||
residents: 'Residents',
|
residents: 'Residents',
|
||||||
residentsTooltip: 'Usual residents (ONS Census 2021)',
|
residentsTooltip: 'Usual residents (ONS Census 2021)',
|
||||||
crimeWindow7y: 'Last 7 years',
|
|
||||||
crimeWindow2y: 'Last 2 years',
|
|
||||||
crimeWindowLabel: 'Time period',
|
|
||||||
crimeCardTitle: '{{name}} per year',
|
|
||||||
crimeRecordsToggle: 'Show individual crimes',
|
|
||||||
crimeRecordsHide: 'Hide individual crimes',
|
|
||||||
crimeRecordsCount: '{{count}} crimes',
|
|
||||||
crimeRecordsLoading: 'Loading crimes…',
|
|
||||||
crimeRecordsEmpty: 'No individual crimes recorded',
|
|
||||||
crimeRecordsError: 'Could not load crimes',
|
|
||||||
crimeRecordsLoadMore: 'Load more',
|
|
||||||
crimeNoLocation: 'Location withheld',
|
|
||||||
crimeOutcomeUnknown: 'Outcome not recorded',
|
|
||||||
crimeYearPointTooltip: '{{year}}: {{rate}}/yr',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -954,7 +914,6 @@ const en = {
|
||||||
searchOn: 'Search {{radius}} on',
|
searchOn: 'Search {{radius}} on',
|
||||||
exact: 'exact',
|
exact: 'exact',
|
||||||
outcodeNotRecognised: 'Outcode not recognised',
|
outcodeNotRecognised: 'Outcode not recognised',
|
||||||
radiusMi: '{{count}} mi radius',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Location Search ────────────────────────────────
|
// ── Location Search ────────────────────────────────
|
||||||
|
|
@ -969,6 +928,15 @@ const en = {
|
||||||
geolocationFailed: 'Couldn’t determine your location',
|
geolocationFailed: 'Couldn’t determine your location',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Demo location prompt ───────────────────────────
|
||||||
|
demoLocation: {
|
||||||
|
title: 'Explore your area',
|
||||||
|
body: 'Want to see property data around you? Use your location, or start the demo at Canary Wharf.',
|
||||||
|
useLocation: 'Use my location',
|
||||||
|
locating: 'Locating…',
|
||||||
|
useDefault: 'Show London',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Mobile Drawer ──────────────────────────────────
|
// ── Mobile Drawer ──────────────────────────────────
|
||||||
mobileDrawer: {
|
mobileDrawer: {
|
||||||
closeDrawer: 'Close drawer',
|
closeDrawer: 'Close drawer',
|
||||||
|
|
@ -1003,7 +971,6 @@ const en = {
|
||||||
showcaseJourneyRoutes: 'Journey routes',
|
showcaseJourneyRoutes: 'Journey routes',
|
||||||
showcaseNearby: '{{value}} nearby',
|
showcaseNearby: '{{value}} nearby',
|
||||||
showcasePoliticalVoteShare: 'Political vote share',
|
showcasePoliticalVoteShare: 'Political vote share',
|
||||||
showcaseGe2024: '2024 GE',
|
|
||||||
showcaseLotsMore: '...and lots more',
|
showcaseLotsMore: '...and lots more',
|
||||||
showcaseMinutes: '{{count}} min',
|
showcaseMinutes: '{{count}} min',
|
||||||
showcaseSendShortlist: 'Send the shortlist',
|
showcaseSendShortlist: 'Send the shortlist',
|
||||||
|
|
@ -1380,7 +1347,7 @@ const en = {
|
||||||
'Lifetime access means one payment gives your account ongoing access to the paid Perfect Postcode map for as long as the service runs. It isn’t a monthly or annual subscription, and normal data updates are included. You can use it during this search, come back later, and still have access if you move again.',
|
'Lifetime access means one payment gives your account ongoing access to the paid Perfect Postcode map for as long as the service runs. It isn’t a monthly or annual subscription, and normal data updates are included. You can use it during this search, come back later, and still have access if you move again.',
|
||||||
faqPricing3Q: 'What can I access on the free tier?',
|
faqPricing3Q: 'What can I access on the free tier?',
|
||||||
faqPricing3A:
|
faqPricing3A:
|
||||||
'Free users get the full Perfect Postcode map across all of England — every postcode and every feature — and can apply up to 3 filters at a time. Lifetime access removes that limit so you can stack unlimited filters.',
|
'Free users can explore all features within the demo area: inner London, roughly zones 1 to 2. To access data for the rest of England, you need lifetime access.',
|
||||||
|
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'How do I preview a filter on the map?',
|
faqTips1Q: 'How do I preview a filter on the map?',
|
||||||
|
|
@ -1428,8 +1395,6 @@ const en = {
|
||||||
noShareLinksYet: 'No shared links yet',
|
noShareLinksYet: 'No shared links yet',
|
||||||
copyShareLink: 'Copy shared link',
|
copyShareLink: 'Copy shared link',
|
||||||
clicksLabel: 'clicks',
|
clicksLabel: 'clicks',
|
||||||
fetchShareLinksError: 'Failed to fetch share links',
|
|
||||||
updateNewsletterError: 'Failed to update newsletter',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Saved Page ─────────────────────────────────────
|
// ── Saved Page ─────────────────────────────────────
|
||||||
|
|
@ -1444,12 +1409,6 @@ const en = {
|
||||||
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This can’t be undone.',
|
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This can’t be undone.',
|
||||||
isBeingUpdated: '<strong>{{name}}</strong> is being updated',
|
isBeingUpdated: '<strong>{{name}}</strong> is being updated',
|
||||||
updating: 'Updating...',
|
updating: 'Updating...',
|
||||||
loadFailed: 'Failed to load searches',
|
|
||||||
saveFailed: 'Failed to save search',
|
|
||||||
deleteFailed: 'Failed to delete search',
|
|
||||||
updateNotesFailed: 'Failed to update notes',
|
|
||||||
updateNameFailed: 'Failed to update name',
|
|
||||||
updateFailed: 'Failed to update search',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invites Page ───────────────────────────────────
|
// ── Invites Page ───────────────────────────────────
|
||||||
|
|
@ -1469,7 +1428,6 @@ const en = {
|
||||||
created: 'Created',
|
created: 'Created',
|
||||||
redeemed: 'Redeemed',
|
redeemed: 'Redeemed',
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
createInviteError: 'Failed to create invite',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invite Page ────────────────────────────────────
|
// ── Invite Page ────────────────────────────────────
|
||||||
|
|
@ -1496,105 +1454,6 @@ const en = {
|
||||||
youAlreadyHaveLicense: 'You already have a license',
|
youAlreadyHaveLicense: 'You already have a license',
|
||||||
accountHasFullAccess: 'Your account already has full access.',
|
accountHasFullAccess: 'Your account already has full access.',
|
||||||
failedToValidate: 'Failed to validate invite link',
|
failedToValidate: 'Failed to validate invite link',
|
||||||
redeemFailed: 'Failed to redeem invite',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Error Boundaries ───────────────────────────────
|
|
||||||
errors: {
|
|
||||||
appCrashTitle: 'Something went wrong',
|
|
||||||
appCrashBody: 'Refresh the page to try again.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Listing Popups ─────────────────────────────────
|
|
||||||
listing: {
|
|
||||||
viewed: 'Viewed',
|
|
||||||
openListing: 'Open listing',
|
|
||||||
listings: 'listings',
|
|
||||||
listing: 'Listing',
|
|
||||||
showingOf: 'Showing {{visible}} of {{count}}',
|
|
||||||
groupedNear: 'Grouped near this map position',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Map Overlays Pane ──────────────────────────────
|
|
||||||
overlays: {
|
|
||||||
heading: 'Overlays',
|
|
||||||
baseMap: 'Base map',
|
|
||||||
colourOpacity: 'Colour opacity',
|
|
||||||
dataOverlays: 'Data overlays',
|
|
||||||
about: 'About {{name}}',
|
|
||||||
zoomWarning: 'Zoom in further to see the selected overlay.',
|
|
||||||
zoomWarning_other: 'Zoom in further to see the selected overlays.',
|
|
||||||
noise: {
|
|
||||||
label: 'Noise',
|
|
||||||
detail:
|
|
||||||
'Defra Strategic Noise Mapping Round 4 (2022), combining road, rail, and airport sources. Values are the EU-standard Lden metric (day-evening-night 24-hour weighted average), modelled on a 10m grid at 4m above ground. Brighter areas indicate higher modelled noise. Licensed under the Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
crimeHotspots: {
|
|
||||||
label: 'Crime hotspots',
|
|
||||||
detail:
|
|
||||||
'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
|
|
||||||
},
|
|
||||||
treesOutsideWoodlands: {
|
|
||||||
label: 'Trees & woodland',
|
|
||||||
detail:
|
|
||||||
'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — unioned with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.',
|
|
||||||
},
|
|
||||||
propertyBorders: {
|
|
||||||
label: 'Property borders',
|
|
||||||
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.',
|
|
||||||
},
|
|
||||||
newDevelopments: {
|
|
||||||
label: 'New developments',
|
|
||||||
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.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Crime-type overlay selector ────────────────────
|
|
||||||
crimeTypes: {
|
|
||||||
violenceAndSexualOffences: 'Violence & sexual offences',
|
|
||||||
antiSocialBehaviour: 'Anti-social behaviour',
|
|
||||||
criminalDamageAndArson: 'Criminal damage & arson',
|
|
||||||
publicOrder: 'Public order',
|
|
||||||
shoplifting: 'Shoplifting',
|
|
||||||
vehicleCrime: 'Vehicle crime',
|
|
||||||
burglary: 'Burglary',
|
|
||||||
otherTheft: 'Other theft',
|
|
||||||
theftFromThePerson: 'Theft from the person',
|
|
||||||
bicycleTheft: 'Bicycle theft',
|
|
||||||
drugs: 'Drugs',
|
|
||||||
robbery: 'Robbery',
|
|
||||||
possessionOfWeapons: 'Possession of weapons',
|
|
||||||
otherCrime: 'Other crime',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Journey Instructions ───────────────────────────
|
|
||||||
journey: {
|
|
||||||
bus: 'Bus',
|
|
||||||
lineSuffix: 'line',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── POI Popups ─────────────────────────────────────
|
|
||||||
poiPopup: {
|
|
||||||
school: {
|
|
||||||
capacity: 'Capacity',
|
|
||||||
type: 'Type',
|
|
||||||
ages: 'Ages',
|
|
||||||
gender: 'Gender',
|
|
||||||
pupilsLabel: 'Pupils',
|
|
||||||
pupils: 'pupils',
|
|
||||||
freeMeal: 'Free meal',
|
|
||||||
ofsted: 'Ofsted',
|
|
||||||
sixthForm: 'Sixth form',
|
|
||||||
religion: 'Religion',
|
|
||||||
admissions: 'Admissions',
|
|
||||||
trust: 'Trust',
|
|
||||||
address: 'Address',
|
|
||||||
localAuthority: 'LA',
|
|
||||||
head: 'Head',
|
|
||||||
website: 'Website',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Format / Time ──────────────────────────────────
|
// ── Format / Time ──────────────────────────────────
|
||||||
|
|
@ -1636,9 +1495,6 @@ const en = {
|
||||||
step6Title: 'What’s nearby?',
|
step6Title: 'What’s nearby?',
|
||||||
step6Content:
|
step6Content:
|
||||||
'Toggle schools, shops, stations, parks, and restaurants on the map to see what’s within reach.',
|
'Toggle schools, shops, stations, parks, and restaurants on the map to see what’s within reach.',
|
||||||
step7Title: 'Layer on more context',
|
|
||||||
step7Content:
|
|
||||||
'Toggle overlays like crime hotspots, noise, tree cover, property boundaries, and new-build sites on the map, and switch the basemap to suit.',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Server-derived values ──────────────────────────
|
// ── Server-derived values ──────────────────────────
|
||||||
|
|
@ -1661,11 +1517,11 @@ const en = {
|
||||||
'Property type': 'Property type',
|
'Property type': 'Property type',
|
||||||
'Leasehold/Freehold': 'Leasehold/Freehold',
|
'Leasehold/Freehold': 'Leasehold/Freehold',
|
||||||
'Last known price': 'Last known price',
|
'Last known price': 'Last known price',
|
||||||
'Estimated price': 'Est. price',
|
'Estimated price': 'Estimated price',
|
||||||
'Estimated current price': 'Est. current price',
|
'Estimated current price': 'Estimated current price',
|
||||||
'Price per sqm': 'Price per sqm',
|
'Price per sqm': 'Price per sqm',
|
||||||
'Est. price per sqm': 'Est. price per sqm',
|
'Est. price per sqm': 'Est. price per sqm',
|
||||||
'Estimated monthly rent': 'Est. monthly rent',
|
'Estimated monthly rent': 'Estimated monthly rent',
|
||||||
'Total floor area (sqm)': 'Total floor area (sqm)',
|
'Total floor area (sqm)': 'Total floor area (sqm)',
|
||||||
'Number of bedrooms & living rooms': 'Number of bedrooms & living rooms',
|
'Number of bedrooms & living rooms': 'Number of bedrooms & living rooms',
|
||||||
'Construction year': 'Construction year',
|
'Construction year': 'Construction year',
|
||||||
|
|
@ -1696,54 +1552,23 @@ const en = {
|
||||||
'Housing Conditions Score': 'Housing Conditions Score',
|
'Housing Conditions Score': 'Housing Conditions Score',
|
||||||
'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score',
|
'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score',
|
||||||
|
|
||||||
// ─ Feature names (Crime) — population-normalised rate, 7-year & 2-year ─
|
// ─ Feature names (Crime) ─
|
||||||
'Serious crime (/yr, 7y)': 'Serious crime (per year, 7yr)',
|
'Serious crime (avg/yr)': 'Serious crime (density)',
|
||||||
'Serious crime (/yr, 2y)': 'Serious crime (per year, 2yr)',
|
'Minor crime (avg/yr)': 'Minor crime (density)',
|
||||||
'Minor crime (/yr, 7y)': 'Minor crime (per year, 7yr)',
|
'Violence and sexual offences (avg/yr)': 'Violence and sexual offences (density)',
|
||||||
'Minor crime (/yr, 2y)': 'Minor crime (per year, 2yr)',
|
'Burglary (avg/yr)': 'Burglary (density)',
|
||||||
'Violence and sexual offences (/yr, 7y)': 'Violence & sexual offences (per year, 7yr)',
|
'Robbery (avg/yr)': 'Robbery (density)',
|
||||||
'Violence and sexual offences (/yr, 2y)': 'Violence & sexual offences (per year, 2yr)',
|
'Vehicle crime (avg/yr)': 'Vehicle crime (density)',
|
||||||
'Burglary (/yr, 7y)': 'Burglary (per year, 7yr)',
|
'Anti-social behaviour (avg/yr)': 'Anti-social behaviour (density)',
|
||||||
'Burglary (/yr, 2y)': 'Burglary (per year, 2yr)',
|
'Criminal damage and arson (avg/yr)': 'Criminal damage and arson (density)',
|
||||||
'Robbery (/yr, 7y)': 'Robbery (per year, 7yr)',
|
'Other theft (avg/yr)': 'Other theft (density)',
|
||||||
'Robbery (/yr, 2y)': 'Robbery (per year, 2yr)',
|
'Theft from the person (avg/yr)': 'Theft from the person (density)',
|
||||||
'Vehicle crime (/yr, 7y)': 'Vehicle crime (per year, 7yr)',
|
'Shoplifting (avg/yr)': 'Shoplifting (density)',
|
||||||
'Vehicle crime (/yr, 2y)': 'Vehicle crime (per year, 2yr)',
|
'Bicycle theft (avg/yr)': 'Bicycle theft (density)',
|
||||||
'Anti-social behaviour (/yr, 7y)': 'Anti-social behaviour (per year, 7yr)',
|
'Drugs (avg/yr)': 'Drugs (density)',
|
||||||
'Anti-social behaviour (/yr, 2y)': 'Anti-social behaviour (per year, 2yr)',
|
'Possession of weapons (avg/yr)': 'Possession of weapons (density)',
|
||||||
'Criminal damage and arson (/yr, 7y)': 'Criminal damage & arson (per year, 7yr)',
|
'Public order (avg/yr)': 'Public order (density)',
|
||||||
'Criminal damage and arson (/yr, 2y)': 'Criminal damage & arson (per year, 2yr)',
|
'Other crime (avg/yr)': 'Other crime (density)',
|
||||||
'Other theft (/yr, 7y)': 'Other theft (per year, 7yr)',
|
|
||||||
'Other theft (/yr, 2y)': 'Other theft (per year, 2yr)',
|
|
||||||
'Theft from the person (/yr, 7y)': 'Theft from the person (per year, 7yr)',
|
|
||||||
'Theft from the person (/yr, 2y)': 'Theft from the person (per year, 2yr)',
|
|
||||||
'Shoplifting (/yr, 7y)': 'Shoplifting (per year, 7yr)',
|
|
||||||
'Shoplifting (/yr, 2y)': 'Shoplifting (per year, 2yr)',
|
|
||||||
'Bicycle theft (/yr, 7y)': 'Bicycle theft (per year, 7yr)',
|
|
||||||
'Bicycle theft (/yr, 2y)': 'Bicycle theft (per year, 2yr)',
|
|
||||||
'Drugs (/yr, 7y)': 'Drugs (per year, 7yr)',
|
|
||||||
'Drugs (/yr, 2y)': 'Drugs (per year, 2yr)',
|
|
||||||
'Possession of weapons (/yr, 7y)': 'Possession of weapons (per year, 7yr)',
|
|
||||||
'Possession of weapons (/yr, 2y)': 'Possession of weapons (per year, 2yr)',
|
|
||||||
'Public order (/yr, 7y)': 'Public order (per year, 7yr)',
|
|
||||||
'Public order (/yr, 2y)': 'Public order (per year, 2yr)',
|
|
||||||
'Other crime (/yr, 7y)': 'Other crime (per year, 7yr)',
|
|
||||||
'Other crime (/yr, 2y)': 'Other crime (per year, 2yr)',
|
|
||||||
// Bare crime-type labels (per-type breakdown rows + records list).
|
|
||||||
// ("Burglary" is already defined in the specific-crime overflow keys below.)
|
|
||||||
'Violence and sexual offences': 'Violence & sexual offences',
|
|
||||||
Robbery: 'Robbery',
|
|
||||||
'Vehicle crime': 'Vehicle crime',
|
|
||||||
'Anti-social behaviour': 'Anti-social behaviour',
|
|
||||||
'Criminal damage and arson': 'Criminal damage & arson',
|
|
||||||
'Other theft': 'Other theft',
|
|
||||||
'Theft from the person': 'Theft from the person',
|
|
||||||
Shoplifting: 'Shoplifting',
|
|
||||||
'Bicycle theft': 'Bicycle theft',
|
|
||||||
Drugs: 'Drugs',
|
|
||||||
'Possession of weapons': 'Possession of weapons',
|
|
||||||
'Public order': 'Public order',
|
|
||||||
'Other crime': 'Other crime',
|
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': 'Median age',
|
'Median age': 'Median age',
|
||||||
|
|
@ -1831,7 +1656,6 @@ const en = {
|
||||||
'Bus station': 'Bus station',
|
'Bus station': 'Bus station',
|
||||||
'Taxi rank': 'Taxi rank',
|
'Taxi rank': 'Taxi rank',
|
||||||
'Tube station': 'Tube station',
|
'Tube station': 'Tube station',
|
||||||
'DLR station': 'DLR station',
|
|
||||||
'Tram & Metro stop': 'Tram & Metro stop',
|
'Tram & Metro stop': 'Tram & Metro stop',
|
||||||
Café: 'Café',
|
Café: 'Café',
|
||||||
Restaurant: 'Restaurant',
|
Restaurant: 'Restaurant',
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,6 @@ const fr: Translations = {
|
||||||
closePane: 'Fermer le panneau',
|
closePane: 'Fermer le panneau',
|
||||||
yes: 'Oui',
|
yes: 'Oui',
|
||||||
no: 'Non',
|
no: 'Non',
|
||||||
captions: 'Légendes',
|
|
||||||
sqm: 'm²',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Header / Nav ───────────────────────────────────
|
// ── Header / Nav ───────────────────────────────────
|
||||||
|
|
@ -637,17 +635,13 @@ const fr: Translations = {
|
||||||
backToLogin: 'Retour à la connexion',
|
backToLogin: 'Retour à la connexion',
|
||||||
registerConsent:
|
registerConsent:
|
||||||
'En créant un compte, vous acceptez nos <terms>Conditions d’utilisation</terms> et notre <privacy>Politique de confidentialité</privacy>.',
|
'En créant un compte, vous acceptez nos <terms>Conditions d’utilisation</terms> et notre <privacy>Politique de confidentialité</privacy>.',
|
||||||
loginFailed: 'Échec de la connexion',
|
|
||||||
registrationFailed: 'Échec de l’inscription',
|
|
||||||
oauthFailed: 'Échec de la connexion OAuth',
|
|
||||||
passwordResetFailed: 'Échec de la demande de réinitialisation du mot de passe',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'Débloquez les filtres illimités',
|
title: 'Trouvez tous les codes postaux compatibles',
|
||||||
description:
|
description:
|
||||||
'Les comptes gratuits peuvent combiner jusqu’à 3 filtres à la fois. Obtenez un accès à vie pour superposer un nombre illimité de filtres sur tous les codes postaux et tous les quartiers d’Angleterre. Un seul paiement, pour toujours.',
|
'Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à tous les codes postaux, tous les filtres et tous les quartiers d’Angleterre. Un seul paiement, pour toujours.',
|
||||||
free: 'Gratuit',
|
free: 'Gratuit',
|
||||||
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
||||||
oneTimePayment: 'Paiement unique. Accès à vie.',
|
oneTimePayment: 'Paiement unique. Accès à vie.',
|
||||||
|
|
@ -656,7 +650,10 @@ const fr: Translations = {
|
||||||
upgradeFor: 'Passer à la version complète pour {{price}}',
|
upgradeFor: 'Passer à la version complète pour {{price}}',
|
||||||
registerAndUpgrade: 'S’inscrire et passer à la version complète',
|
registerAndUpgrade: 'S’inscrire et passer à la version complète',
|
||||||
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
||||||
continueFree: 'Continuer avec 3 filtres',
|
continueWithDemo: 'Continuer avec la démo',
|
||||||
|
backToSharedArea: 'Retour au secteur partagé',
|
||||||
|
sharedAreaDescription:
|
||||||
|
'Vous consultez un secteur partagé. Pour explorer au-delà, obtenez un accès à vie à tous les codes postaux, tous les filtres et tous les quartiers d’Angleterre.',
|
||||||
checkoutFailed: 'Le paiement a échoué',
|
checkoutFailed: 'Le paiement a échoué',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -735,11 +732,6 @@ const fr: Translations = {
|
||||||
outstanding: 'Outstanding',
|
outstanding: 'Outstanding',
|
||||||
distance: 'Distance',
|
distance: 'Distance',
|
||||||
crimeType: 'Type d’infraction',
|
crimeType: 'Type d’infraction',
|
||||||
qualificationLevel: 'Niveau de qualification',
|
|
||||||
tenureType: 'Statut d’occupation',
|
|
||||||
crimeWindow: 'Période',
|
|
||||||
crimeWindow7y: '7 ans',
|
|
||||||
crimeWindow2y: '2 ans',
|
|
||||||
ethnicity: 'Origine ethnique',
|
ethnicity: 'Origine ethnique',
|
||||||
poiType: 'Type de point d’intérêt',
|
poiType: 'Type de point d’intérêt',
|
||||||
party: 'Parti',
|
party: 'Parti',
|
||||||
|
|
@ -830,7 +822,6 @@ const fr: Translations = {
|
||||||
refiningResults: 'Affinage des résultats...',
|
refiningResults: 'Affinage des résultats...',
|
||||||
weeklyLimitReached:
|
weeklyLimitReached:
|
||||||
'Vous avez atteint la limite hebdomadaire d’utilisation de l’IA. Elle se réinitialisera automatiquement la semaine prochaine.',
|
'Vous avez atteint la limite hebdomadaire d’utilisation de l’IA. Elle se réinitialisera automatiquement la semaine prochaine.',
|
||||||
generateFailed: 'Échec de la génération des filtres',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
|
|
@ -850,18 +841,6 @@ const fr: Translations = {
|
||||||
ogSchools: 'Écoles',
|
ogSchools: 'Écoles',
|
||||||
ogCrimeStats: 'Criminalité',
|
ogCrimeStats: 'Criminalité',
|
||||||
ogTransport: 'Transports',
|
ogTransport: 'Transports',
|
||||||
basemap: { standard: 'Carte', satellite: 'Satellite' },
|
|
||||||
error: {
|
|
||||||
heading: 'La carte a rencontré un problème',
|
|
||||||
body: 'Cela peut se produire lorsque le contexte graphique de votre navigateur est interrompu.',
|
|
||||||
reload: 'Recharger la carte',
|
|
||||||
},
|
|
||||||
actualListings: {
|
|
||||||
label: 'Annonces',
|
|
||||||
show: 'Afficher les annonces réelles',
|
|
||||||
hide: 'Masquer les annonces réelles',
|
|
||||||
},
|
|
||||||
poi: { zoomInToSeeDetails: 'Zoomez pour voir les détails' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Properties Pane ────────────────────────────────
|
// ── Properties Pane ────────────────────────────────
|
||||||
|
|
@ -940,20 +919,6 @@ const fr: Translations = {
|
||||||
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',
|
residents: 'Habitants',
|
||||||
residentsTooltip: 'Résidents habituels (recensement ONS 2021)',
|
residentsTooltip: 'Résidents habituels (recensement ONS 2021)',
|
||||||
crimeWindow7y: 'Ces 7 dernières années',
|
|
||||||
crimeWindow2y: 'Ces 2 dernières années',
|
|
||||||
crimeWindowLabel: 'Période',
|
|
||||||
crimeCardTitle: '{{name}} par an',
|
|
||||||
crimeRecordsToggle: 'Afficher les infractions individuelles',
|
|
||||||
crimeRecordsHide: 'Masquer les infractions individuelles',
|
|
||||||
crimeRecordsCount: '{{count}} infractions',
|
|
||||||
crimeRecordsLoading: 'Chargement des infractions…',
|
|
||||||
crimeRecordsEmpty: 'Aucune infraction individuelle enregistrée',
|
|
||||||
crimeRecordsError: 'Impossible de charger les infractions',
|
|
||||||
crimeRecordsLoadMore: 'Charger plus',
|
|
||||||
crimeNoLocation: 'Lieu non communiqué',
|
|
||||||
crimeOutcomeUnknown: 'Résultat non enregistré',
|
|
||||||
crimeYearPointTooltip: '{{year}} : {{rate}}/an',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -978,7 +943,6 @@ const fr: Translations = {
|
||||||
searchOn: 'Rechercher dans un rayon de {{radius}} sur',
|
searchOn: 'Rechercher dans un rayon de {{radius}} sur',
|
||||||
exact: 'exact',
|
exact: 'exact',
|
||||||
outcodeNotRecognised: 'Code postal non reconnu',
|
outcodeNotRecognised: 'Code postal non reconnu',
|
||||||
radiusMi: 'rayon de {{count}} mi',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Location Search ────────────────────────────────
|
// ── Location Search ────────────────────────────────
|
||||||
|
|
@ -993,6 +957,14 @@ const fr: Translations = {
|
||||||
geolocationFailed: 'Impossible de déterminer votre position',
|
geolocationFailed: 'Impossible de déterminer votre position',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
demoLocation: {
|
||||||
|
title: 'Explorez votre quartier',
|
||||||
|
body: 'Envie de voir les données immobilières autour de vous ? Utilisez votre position, ou commencez la démo à Canary Wharf.',
|
||||||
|
useLocation: 'Utiliser ma position',
|
||||||
|
locating: 'Localisation…',
|
||||||
|
useDefault: 'Afficher Canary Wharf',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Mobile Drawer ──────────────────────────────────
|
// ── Mobile Drawer ──────────────────────────────────
|
||||||
mobileDrawer: {
|
mobileDrawer: {
|
||||||
closeDrawer: 'Fermer le tiroir',
|
closeDrawer: 'Fermer le tiroir',
|
||||||
|
|
@ -1027,7 +999,6 @@ const fr: Translations = {
|
||||||
showcaseJourneyRoutes: 'Itinéraires',
|
showcaseJourneyRoutes: 'Itinéraires',
|
||||||
showcaseNearby: '{{value}} à proximité',
|
showcaseNearby: '{{value}} à proximité',
|
||||||
showcasePoliticalVoteShare: 'Répartition des voix',
|
showcasePoliticalVoteShare: 'Répartition des voix',
|
||||||
showcaseGe2024: 'Législatives 2024',
|
|
||||||
showcaseLotsMore: '...et bien plus',
|
showcaseLotsMore: '...et bien plus',
|
||||||
showcaseMinutes: '{{count}} min',
|
showcaseMinutes: '{{count}} min',
|
||||||
showcaseSendShortlist: 'Envoyer la sélection',
|
showcaseSendShortlist: 'Envoyer la sélection',
|
||||||
|
|
@ -1413,7 +1384,7 @@ const fr: Translations = {
|
||||||
'L’accès à vie signifie qu’un paiement donne à votre compte un accès continu à la carte payante Perfect Postcode pendant la durée de vie du service. Ce n’est pas un abonnement mensuel ou annuel, et les mises à jour normales des données sont incluses. Vous pouvez l’utiliser pendant cette recherche, revenir plus tard et conserver l’accès si vous déménagez à nouveau.',
|
'L’accès à vie signifie qu’un paiement donne à votre compte un accès continu à la carte payante Perfect Postcode pendant la durée de vie du service. Ce n’est pas un abonnement mensuel ou annuel, et les mises à jour normales des données sont incluses. Vous pouvez l’utiliser pendant cette recherche, revenir plus tard et conserver l’accès si vous déménagez à nouveau.',
|
||||||
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
|
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
|
||||||
faqPricing3A:
|
faqPricing3A:
|
||||||
'Les utilisateurs gratuits bénéficient de la carte Perfect Postcode complète sur toute l’Angleterre — tous les codes postaux et toutes les fonctionnalités — et peuvent appliquer jusqu’à 3 filtres à la fois. L’accès à vie supprime cette limite pour vous permettre de superposer un nombre illimité de filtres.',
|
'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de l’Angleterre, il faut l’accès à vie.',
|
||||||
|
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'Comment prévisualiser un filtre sur la carte ?',
|
faqTips1Q: 'Comment prévisualiser un filtre sur la carte ?',
|
||||||
|
|
@ -1464,8 +1435,6 @@ const fr: Translations = {
|
||||||
noShareLinksYet: 'Aucun lien partagé pour l’instant',
|
noShareLinksYet: 'Aucun lien partagé pour l’instant',
|
||||||
copyShareLink: 'Copier le lien partagé',
|
copyShareLink: 'Copier le lien partagé',
|
||||||
clicksLabel: 'clics',
|
clicksLabel: 'clics',
|
||||||
fetchShareLinksError: 'Échec de la récupération des liens partagés',
|
|
||||||
updateNewsletterError: 'Échec de la mise à jour de la newsletter',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Saved Page ─────────────────────────────────────
|
// ── Saved Page ─────────────────────────────────────
|
||||||
|
|
@ -1481,12 +1450,6 @@ const fr: Translations = {
|
||||||
'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.',
|
'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.',
|
||||||
isBeingUpdated: 'Mise à jour de <strong>{{name}}</strong>',
|
isBeingUpdated: 'Mise à jour de <strong>{{name}}</strong>',
|
||||||
updating: 'Mise à jour...',
|
updating: 'Mise à jour...',
|
||||||
loadFailed: 'Échec du chargement des recherches',
|
|
||||||
saveFailed: 'Échec de l’enregistrement de la recherche',
|
|
||||||
deleteFailed: 'Échec de la suppression de la recherche',
|
|
||||||
updateNotesFailed: 'Échec de la mise à jour des notes',
|
|
||||||
updateNameFailed: 'Échec de la mise à jour du nom',
|
|
||||||
updateFailed: 'Échec de la mise à jour de la recherche',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invites Page ───────────────────────────────────
|
// ── Invites Page ───────────────────────────────────
|
||||||
|
|
@ -1507,7 +1470,6 @@ const fr: Translations = {
|
||||||
created: 'Créé',
|
created: 'Créé',
|
||||||
redeemed: 'Utilisé',
|
redeemed: 'Utilisé',
|
||||||
pending: 'En attente',
|
pending: 'En attente',
|
||||||
createInviteError: 'Échec de la création de l’invitation',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invite Page ────────────────────────────────────
|
// ── Invite Page ────────────────────────────────────
|
||||||
|
|
@ -1534,105 +1496,6 @@ const fr: Translations = {
|
||||||
youAlreadyHaveLicense: 'Vous avez déjà une licence',
|
youAlreadyHaveLicense: 'Vous avez déjà une licence',
|
||||||
accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.',
|
accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.',
|
||||||
failedToValidate: 'Échec de la validation du lien d’invitation',
|
failedToValidate: 'Échec de la validation du lien d’invitation',
|
||||||
redeemFailed: 'Échec de l’utilisation de l’invitation',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Errors ─────────────────────────────────────────
|
|
||||||
errors: {
|
|
||||||
appCrashTitle: 'Une erreur est survenue',
|
|
||||||
appCrashBody: 'Actualisez la page pour réessayer.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Listing ────────────────────────────────────────
|
|
||||||
listing: {
|
|
||||||
viewed: 'Consulté',
|
|
||||||
openListing: 'Ouvrir l’annonce',
|
|
||||||
listings: 'annonces',
|
|
||||||
listing: 'Annonce',
|
|
||||||
showingOf: 'Affichage de {{visible}} sur {{count}}',
|
|
||||||
groupedNear: 'Regroupées près de cette position sur la carte',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Overlays ───────────────────────────────────────
|
|
||||||
overlays: {
|
|
||||||
heading: 'Calques',
|
|
||||||
baseMap: 'Fond de carte',
|
|
||||||
colourOpacity: 'Opacité des couleurs',
|
|
||||||
dataOverlays: 'Calques de données',
|
|
||||||
about: 'À propos de {{name}}',
|
|
||||||
zoomWarning: 'Zoomez davantage pour voir le calque sélectionné.',
|
|
||||||
zoomWarning_other: 'Zoomez davantage pour voir les calques sélectionnés.',
|
|
||||||
noise: {
|
|
||||||
label: 'Bruit',
|
|
||||||
detail:
|
|
||||||
'Cartographie stratégique du bruit Defra, 4ᵉ cycle (2022), combinant les sources routières, ferroviaires et aéroportuaires. Les valeurs correspondent à l’indicateur Lden standard de l’EU (moyenne pondérée sur 24 heures jour-soir-nuit), modélisé sur une grille de 10 m à 4 m au-dessus du sol. Les zones plus claires indiquent un bruit modélisé plus élevé. Sous licence Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
crimeHotspots: {
|
|
||||||
label: 'Points chauds de criminalité',
|
|
||||||
detail:
|
|
||||||
'Carte de chaleur générée côté client des délits de proximité publiés par police.uk au cours des mois les plus récents. Les coordonnées de police.uk sont des points anonymisés calés sur une grille, et non les lieux exacts des infractions ; la carte de chaleur doit donc être lue comme une approximation de la densité relative plutôt que comme une carte précise des incidents.',
|
|
||||||
},
|
|
||||||
treesOutsideWoodlands: {
|
|
||||||
label: 'Arbres et boisements',
|
|
||||||
detail:
|
|
||||||
'Polygones de canopée Forest Research Trees Outside Woodland (TOW) v1 — arbres isolés et groupes d’arbres — fusionnés avec les blocs boisés du National Forest Inventory (NFI) (≥0,5 ha) que TOW exclut délibérément. Ensemble, ils couvrent à la fois les arbres de rue et les véritables bois. L’opacité des polygones varie selon la surface de canopée.',
|
|
||||||
},
|
|
||||||
propertyBorders: {
|
|
||||||
label: 'Limites de propriété',
|
|
||||||
detail:
|
|
||||||
'INSPIRE Index Polygons du HM Land Registry — la position et l’étendue indicative des biens en pleine propriété (freehold) enregistrés en Angleterre et au pays de Galles, tracés en contours au niveau de la rue. Il s’agit de « limites générales » fournies à titre indicatif uniquement, et non de la limite légale précise d’un bien ; elles excluent les intérêts uniquement en location longue durée (leasehold) et les terrains non enregistrés (environ 85 à 90 % des terrains en pleine propriété sont couverts). Ces informations sont soumises au Crown copyright et aux droits sur les bases de données 2026 et sont reproduites avec l’autorisation du HM Land Registry. Les polygones (y compris la géométrie associée, à savoir les coordonnées x, y) sont soumis au Crown copyright et aux droits sur les bases de données 2026 Ordnance Survey AC0000851063. Sous licence Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
newDevelopments: {
|
|
||||||
label: 'Nouveaux développements',
|
|
||||||
detail:
|
|
||||||
'Un pipeline prospectif de nouveaux logements. Les repères bleus signalent les sites des registres statutaires des terrains en friche (Brownfield Land) du MHCLG — chacun assorti d’une capacité nette estimée en logements et d’un statut de permis de construire — ainsi que les sites de cession du Homes England Land Hub. Ils indiquent où de nouveaux logements sont prévus, souvent des années avant qu’ils n’apparaissent dans les registres EPC ou de ventes. Les chiffres de logements sont des estimations de capacité, et non des engagements, et un site inscrit à un registre est une opportunité plutôt qu’une garantie de construction. Sous licence Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Crime Types ────────────────────────────────────
|
|
||||||
crimeTypes: {
|
|
||||||
violenceAndSexualOffences: 'Violences et infractions sexuelles',
|
|
||||||
antiSocialBehaviour: 'Comportement antisocial',
|
|
||||||
criminalDamageAndArson: 'Dégradations et incendies volontaires',
|
|
||||||
publicOrder: 'Atteintes à l’ordre public',
|
|
||||||
shoplifting: 'Vol à l’étalage',
|
|
||||||
vehicleCrime: 'Délits liés aux véhicules',
|
|
||||||
burglary: 'Cambriolage',
|
|
||||||
otherTheft: 'Autres vols',
|
|
||||||
theftFromThePerson: 'Vol à la tire',
|
|
||||||
bicycleTheft: 'Vol de vélo',
|
|
||||||
drugs: 'Stupéfiants',
|
|
||||||
robbery: 'Vol avec violence',
|
|
||||||
possessionOfWeapons: 'Port d’armes',
|
|
||||||
otherCrime: 'Autres délits',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Journey ────────────────────────────────────────
|
|
||||||
journey: {
|
|
||||||
bus: 'Bus',
|
|
||||||
lineSuffix: 'ligne',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── POI Popup ──────────────────────────────────────
|
|
||||||
poiPopup: {
|
|
||||||
school: {
|
|
||||||
capacity: 'Capacité',
|
|
||||||
type: 'Type',
|
|
||||||
ages: 'Âges',
|
|
||||||
gender: 'Mixité',
|
|
||||||
pupilsLabel: 'Élèves',
|
|
||||||
pupils: 'élèves',
|
|
||||||
freeMeal: 'Repas gratuit',
|
|
||||||
ofsted: 'Ofsted',
|
|
||||||
sixthForm: 'Lycée (sixth form)',
|
|
||||||
religion: 'Religion',
|
|
||||||
admissions: 'Admissions',
|
|
||||||
trust: 'Trust',
|
|
||||||
address: 'Adresse',
|
|
||||||
localAuthority: 'LA',
|
|
||||||
head: 'Directeur',
|
|
||||||
website: 'Site web',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Format / Time ──────────────────────────────────
|
// ── Format / Time ──────────────────────────────────
|
||||||
|
|
@ -1675,9 +1538,6 @@ const fr: Translations = {
|
||||||
step6Title: 'Qu’y a-t-il à proximité ?',
|
step6Title: 'Qu’y a-t-il à proximité ?',
|
||||||
step6Content:
|
step6Content:
|
||||||
'Activez les écoles, commerces, gares, parcs et restaurants sur la carte pour voir ce qui est à portée.',
|
'Activez les écoles, commerces, gares, parcs et restaurants sur la carte pour voir ce qui est à portée.',
|
||||||
step7Title: 'Ajoutez du contexte',
|
|
||||||
step7Content:
|
|
||||||
'Activez des calques comme les points chauds de criminalité, le bruit, la couverture arborée, les limites de propriété et les nouveaux programmes immobiliers, et changez de fond de carte selon vos besoins.',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Server-derived values ──────────────────────────
|
// ── Server-derived values ──────────────────────────
|
||||||
|
|
@ -1735,54 +1595,23 @@ const fr: Translations = {
|
||||||
'Housing Conditions Score': 'Score des conditions de logement',
|
'Housing Conditions Score': 'Score des conditions de logement',
|
||||||
'Air Quality and Road Safety Score': 'Score qualité de l’air et sécurité routière',
|
'Air Quality and Road Safety Score': 'Score qualité de l’air et sécurité routière',
|
||||||
|
|
||||||
// ─ Feature names (Crime) — taux normalisé par population, 7 ans & 2 ans ─
|
// ─ Feature names (Crime) ─
|
||||||
'Serious crime (/yr, 7y)': 'Infractions graves (par an, 7 ans)',
|
'Serious crime (avg/yr)': 'Infractions graves (densité)',
|
||||||
'Serious crime (/yr, 2y)': 'Infractions graves (par an, 2 ans)',
|
'Minor crime (avg/yr)': 'Infractions mineures (densité)',
|
||||||
'Minor crime (/yr, 7y)': 'Infractions mineures (par an, 7 ans)',
|
'Violence and sexual offences (avg/yr)': 'Violences et infractions sexuelles (densité)',
|
||||||
'Minor crime (/yr, 2y)': 'Infractions mineures (par an, 2 ans)',
|
'Burglary (avg/yr)': 'Cambriolages (densité)',
|
||||||
'Violence and sexual offences (/yr, 7y)': 'Violences et infractions sexuelles (par an, 7 ans)',
|
'Robbery (avg/yr)': 'Vols avec violence (densité)',
|
||||||
'Violence and sexual offences (/yr, 2y)': 'Violences et infractions sexuelles (par an, 2 ans)',
|
'Vehicle crime (avg/yr)': 'Infractions liées aux véhicules (densité)',
|
||||||
'Burglary (/yr, 7y)': 'Cambriolages (par an, 7 ans)',
|
'Anti-social behaviour (avg/yr)': 'Comportements antisociaux (densité)',
|
||||||
'Burglary (/yr, 2y)': 'Cambriolages (par an, 2 ans)',
|
'Criminal damage and arson (avg/yr)': 'Dégradations et incendies criminels (densité)',
|
||||||
'Robbery (/yr, 7y)': 'Vols avec violence (par an, 7 ans)',
|
'Other theft (avg/yr)': 'Autres vols (densité)',
|
||||||
'Robbery (/yr, 2y)': 'Vols avec violence (par an, 2 ans)',
|
'Theft from the person (avg/yr)': 'Vols à la personne (densité)',
|
||||||
'Vehicle crime (/yr, 7y)': 'Infractions liées aux véhicules (par an, 7 ans)',
|
'Shoplifting (avg/yr)': 'Vols à l’étalage (densité)',
|
||||||
'Vehicle crime (/yr, 2y)': 'Infractions liées aux véhicules (par an, 2 ans)',
|
'Bicycle theft (avg/yr)': 'Vols de vélos (densité)',
|
||||||
'Anti-social behaviour (/yr, 7y)': 'Comportements antisociaux (par an, 7 ans)',
|
'Drugs (avg/yr)': 'Infractions liées aux stupéfiants (densité)',
|
||||||
'Anti-social behaviour (/yr, 2y)': 'Comportements antisociaux (par an, 2 ans)',
|
'Possession of weapons (avg/yr)': 'Possession d’armes (densité)',
|
||||||
'Criminal damage and arson (/yr, 7y)': 'Dégradations et incendies criminels (par an, 7 ans)',
|
'Public order (avg/yr)': 'Troubles à l’ordre public (densité)',
|
||||||
'Criminal damage and arson (/yr, 2y)': 'Dégradations et incendies criminels (par an, 2 ans)',
|
'Other crime (avg/yr)': 'Autres infractions (densité)',
|
||||||
'Other theft (/yr, 7y)': 'Autres vols (par an, 7 ans)',
|
|
||||||
'Other theft (/yr, 2y)': 'Autres vols (par an, 2 ans)',
|
|
||||||
'Theft from the person (/yr, 7y)': 'Vols à la personne (par an, 7 ans)',
|
|
||||||
'Theft from the person (/yr, 2y)': 'Vols à la personne (par an, 2 ans)',
|
|
||||||
'Shoplifting (/yr, 7y)': 'Vols à l’étalage (par an, 7 ans)',
|
|
||||||
'Shoplifting (/yr, 2y)': 'Vols à l’étalage (par an, 2 ans)',
|
|
||||||
'Bicycle theft (/yr, 7y)': 'Vols de vélos (par an, 7 ans)',
|
|
||||||
'Bicycle theft (/yr, 2y)': 'Vols de vélos (par an, 2 ans)',
|
|
||||||
'Drugs (/yr, 7y)': 'Infractions liées aux stupéfiants (par an, 7 ans)',
|
|
||||||
'Drugs (/yr, 2y)': 'Infractions liées aux stupéfiants (par an, 2 ans)',
|
|
||||||
'Possession of weapons (/yr, 7y)': 'Possession d’armes (par an, 7 ans)',
|
|
||||||
'Possession of weapons (/yr, 2y)': 'Possession d’armes (par an, 2 ans)',
|
|
||||||
'Public order (/yr, 7y)': 'Troubles à l’ordre public (par an, 7 ans)',
|
|
||||||
'Public order (/yr, 2y)': 'Troubles à l’ordre public (par an, 2 ans)',
|
|
||||||
'Other crime (/yr, 7y)': 'Autres infractions (par an, 7 ans)',
|
|
||||||
'Other crime (/yr, 2y)': 'Autres infractions (par an, 2 ans)',
|
|
||||||
// Libellés de type d’infraction seuls (ventilation par type + liste des enregistrements).
|
|
||||||
// (« Burglary » est déjà défini parmi les infractions spécifiques ci-dessous.)
|
|
||||||
'Violence and sexual offences': 'Violences et infractions sexuelles',
|
|
||||||
Robbery: 'Vols avec violence',
|
|
||||||
'Vehicle crime': 'Infractions liées aux véhicules',
|
|
||||||
'Anti-social behaviour': 'Comportements antisociaux',
|
|
||||||
'Criminal damage and arson': 'Dégradations et incendies criminels',
|
|
||||||
'Other theft': 'Autres vols',
|
|
||||||
'Theft from the person': 'Vols à la personne',
|
|
||||||
Shoplifting: 'Vols à l’étalage',
|
|
||||||
'Bicycle theft': 'Vols de vélos',
|
|
||||||
Drugs: 'Infractions liées aux stupéfiants',
|
|
||||||
'Possession of weapons': 'Possession d’armes',
|
|
||||||
'Public order': 'Troubles à l’ordre public',
|
|
||||||
'Other crime': 'Autres infractions',
|
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': 'Âge médian',
|
'Median age': 'Âge médian',
|
||||||
|
|
@ -1870,7 +1699,6 @@ const fr: Translations = {
|
||||||
'Bus station': 'Gare routière',
|
'Bus station': 'Gare routière',
|
||||||
'Taxi rank': 'Station de taxi',
|
'Taxi rank': 'Station de taxi',
|
||||||
'Tube station': 'Station de métro londonien',
|
'Tube station': 'Station de métro londonien',
|
||||||
'DLR station': 'Station DLR',
|
|
||||||
'Tram & Metro stop': 'Arrêt de tramway et métro',
|
'Tram & Metro stop': 'Arrêt de tramway et métro',
|
||||||
Café: 'Café',
|
Café: 'Café',
|
||||||
Restaurant: 'Restaurant',
|
Restaurant: 'Restaurant',
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,6 @@ const hi: Translations = {
|
||||||
closePane: 'पैन बंद करें',
|
closePane: 'पैन बंद करें',
|
||||||
yes: 'हाँ',
|
yes: 'हाँ',
|
||||||
no: 'नहीं',
|
no: 'नहीं',
|
||||||
captions: 'कैप्शन',
|
|
||||||
sqm: 'वर्ग मीटर',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
|
|
@ -610,16 +608,12 @@ const hi: Translations = {
|
||||||
backToLogin: 'लॉग इन पर वापस जाएं',
|
backToLogin: 'लॉग इन पर वापस जाएं',
|
||||||
registerConsent:
|
registerConsent:
|
||||||
'खाता बनाकर आप हमारी <terms>सेवा की शर्तों</terms> और <privacy>गोपनीयता नीति</privacy> से सहमत होते हैं.',
|
'खाता बनाकर आप हमारी <terms>सेवा की शर्तों</terms> और <privacy>गोपनीयता नीति</privacy> से सहमत होते हैं.',
|
||||||
loginFailed: 'लॉग इन विफल रहा',
|
|
||||||
registrationFailed: 'पंजीकरण विफल रहा',
|
|
||||||
oauthFailed: 'OAuth लॉग इन विफल रहा',
|
|
||||||
passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'असीमित फ़िल्टर अनलॉक करें',
|
title: 'हर मेल खाने वाला पोस्टकोड खोजें',
|
||||||
description:
|
description:
|
||||||
'मुफ्त खाते एक बार में 3 फ़िल्टर तक जोड़ सकते हैं. इंग्लैंड के हर पोस्टकोड और पड़ोस में असीमित फ़िल्टर लगाने के लिए आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.',
|
'आप अभी डेमो क्षेत्र देख रहे हैं. इंग्लैंड के हर पोस्टकोड, हर फ़िल्टर और हर पड़ोस की आजीवन पहुँच पाएं. एक भुगतान, हमेशा के लिए.',
|
||||||
free: 'मुफ्त',
|
free: 'मुफ्त',
|
||||||
freeForEarly: 'शुरुआती उपयोगकर्ताओं के लिए मुफ्त. क्रेडिट कार्ड की जरूरत नहीं.',
|
freeForEarly: 'शुरुआती उपयोगकर्ताओं के लिए मुफ्त. क्रेडिट कार्ड की जरूरत नहीं.',
|
||||||
oneTimePayment: 'एक बार भुगतान. आजीवन पहुँच.',
|
oneTimePayment: 'एक बार भुगतान. आजीवन पहुँच.',
|
||||||
|
|
@ -628,7 +622,10 @@ const hi: Translations = {
|
||||||
upgradeFor: '{{price}} में अपग्रेड करें',
|
upgradeFor: '{{price}} में अपग्रेड करें',
|
||||||
registerAndUpgrade: 'पंजीकरण करें और अपग्रेड करें',
|
registerAndUpgrade: 'पंजीकरण करें और अपग्रेड करें',
|
||||||
alreadyHaveAccount: 'पहले से खाता है? लॉग इन करें',
|
alreadyHaveAccount: 'पहले से खाता है? लॉग इन करें',
|
||||||
continueFree: '3 फ़िल्टर के साथ जारी रखें',
|
continueWithDemo: 'डेमो जारी रखें',
|
||||||
|
backToSharedArea: 'साझा क्षेत्र पर वापस जाएं',
|
||||||
|
sharedAreaDescription:
|
||||||
|
'आप एक साझा क्षेत्र देख रहे हैं. इससे आगे देखने के लिए इंग्लैंड के हर पोस्टकोड, हर फ़िल्टर और हर पड़ोस की आजीवन पहुँच लें.',
|
||||||
checkoutFailed: 'चेकआउट विफल रहा',
|
checkoutFailed: 'चेकआउट विफल रहा',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -702,11 +699,6 @@ const hi: Translations = {
|
||||||
outstanding: 'उत्कृष्ट',
|
outstanding: 'उत्कृष्ट',
|
||||||
distance: 'दूरी',
|
distance: 'दूरी',
|
||||||
crimeType: 'अपराध प्रकार',
|
crimeType: 'अपराध प्रकार',
|
||||||
qualificationLevel: 'योग्यता स्तर',
|
|
||||||
tenureType: 'आवास स्वामित्व',
|
|
||||||
crimeWindow: 'अवधि',
|
|
||||||
crimeWindow7y: '7 वर्ष',
|
|
||||||
crimeWindow2y: '2 वर्ष',
|
|
||||||
ethnicity: 'जातीय समूह',
|
ethnicity: 'जातीय समूह',
|
||||||
poiType: 'रुचि-स्थल प्रकार',
|
poiType: 'रुचि-स्थल प्रकार',
|
||||||
party: 'पार्टी',
|
party: 'पार्टी',
|
||||||
|
|
@ -793,7 +785,6 @@ const hi: Translations = {
|
||||||
refiningResults: 'परिणाम सुधारे जा रहे हैं...',
|
refiningResults: 'परिणाम सुधारे जा रहे हैं...',
|
||||||
weeklyLimitReached:
|
weeklyLimitReached:
|
||||||
'आप साप्ताहिक AI उपयोग सीमा तक पहुंच गए हैं. यह अगले सप्ताह अपने आप फिर से शुरू हो जाएगी.',
|
'आप साप्ताहिक AI उपयोग सीमा तक पहुंच गए हैं. यह अगले सप्ताह अपने आप फिर से शुरू हो जाएगी.',
|
||||||
generateFailed: 'फ़िल्टर बनाने में विफल',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
mapLegend: {
|
mapLegend: {
|
||||||
|
|
@ -811,18 +802,6 @@ const hi: Translations = {
|
||||||
ogSchools: 'स्कूल',
|
ogSchools: 'स्कूल',
|
||||||
ogCrimeStats: 'अपराध आंकड़े',
|
ogCrimeStats: 'अपराध आंकड़े',
|
||||||
ogTransport: 'परिवहन',
|
ogTransport: 'परिवहन',
|
||||||
basemap: { standard: 'मैप', satellite: 'सैटेलाइट' },
|
|
||||||
error: {
|
|
||||||
heading: 'मैप में एक समस्या आ गई',
|
|
||||||
body: 'ऐसा तब हो सकता है जब आपके ब्राउज़र का ग्राफिक्स संदर्भ बाधित हो जाए.',
|
|
||||||
reload: 'मैप फिर से लोड करें',
|
|
||||||
},
|
|
||||||
actualListings: {
|
|
||||||
label: 'लिस्टिंग',
|
|
||||||
show: 'वास्तविक लिस्टिंग दिखाएं',
|
|
||||||
hide: 'वास्तविक लिस्टिंग छिपाएं',
|
|
||||||
},
|
|
||||||
poi: { zoomInToSeeDetails: 'विवरण देखने के लिए ज़ूम इन करें' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
propertyCard: {
|
propertyCard: {
|
||||||
|
|
@ -899,20 +878,6 @@ const hi: Translations = {
|
||||||
crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है',
|
crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है',
|
||||||
residents: 'निवासी',
|
residents: 'निवासी',
|
||||||
residentsTooltip: 'सामान्य निवासी (ONS जनगणना 2021)',
|
residentsTooltip: 'सामान्य निवासी (ONS जनगणना 2021)',
|
||||||
crimeWindow7y: 'पिछले 7 वर्ष',
|
|
||||||
crimeWindow2y: 'पिछले 2 वर्ष',
|
|
||||||
crimeWindowLabel: 'समयावधि',
|
|
||||||
crimeCardTitle: '{{name}} प्रति वर्ष',
|
|
||||||
crimeRecordsToggle: 'अलग-अलग अपराध दिखाएँ',
|
|
||||||
crimeRecordsHide: 'अलग-अलग अपराध छिपाएँ',
|
|
||||||
crimeRecordsCount: '{{count}} अपराध',
|
|
||||||
crimeRecordsLoading: 'अपराध लोड हो रहे हैं…',
|
|
||||||
crimeRecordsEmpty: 'कोई अलग-अलग अपराध दर्ज नहीं',
|
|
||||||
crimeRecordsError: 'अपराध लोड नहीं हो सके',
|
|
||||||
crimeRecordsLoadMore: 'और लोड करें',
|
|
||||||
crimeNoLocation: 'स्थान नहीं बताया गया',
|
|
||||||
crimeOutcomeUnknown: 'परिणाम दर्ज नहीं',
|
|
||||||
crimeYearPointTooltip: '{{year}}: {{rate}}/वर्ष',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
streetView: {
|
streetView: {
|
||||||
|
|
@ -934,7 +899,6 @@ const hi: Translations = {
|
||||||
searchOn: 'इन पर {{radius}} खोजें:',
|
searchOn: 'इन पर {{radius}} खोजें:',
|
||||||
exact: 'सटीक',
|
exact: 'सटीक',
|
||||||
outcodeNotRecognised: 'आउटकोड पहचाना नहीं गया',
|
outcodeNotRecognised: 'आउटकोड पहचाना नहीं गया',
|
||||||
radiusMi: '{{count}} मील की त्रिज्या',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
locationSearch: {
|
locationSearch: {
|
||||||
|
|
@ -948,6 +912,14 @@ const hi: Translations = {
|
||||||
geolocationFailed: 'आपका स्थान निर्धारित नहीं किया जा सका',
|
geolocationFailed: 'आपका स्थान निर्धारित नहीं किया जा सका',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
demoLocation: {
|
||||||
|
title: 'अपना क्षेत्र देखें',
|
||||||
|
body: 'क्या आप अपने आसपास की संपत्ति डेटा देखना चाहते हैं? अपना स्थान उपयोग करें, या Canary Wharf से डेमो शुरू करें।',
|
||||||
|
useLocation: 'मेरा स्थान उपयोग करें',
|
||||||
|
locating: 'स्थान खोजा जा रहा है…',
|
||||||
|
useDefault: 'Canary Wharf दिखाएं',
|
||||||
|
},
|
||||||
|
|
||||||
mobileDrawer: {
|
mobileDrawer: {
|
||||||
closeDrawer: 'ड्रॉअर बंद करें',
|
closeDrawer: 'ड्रॉअर बंद करें',
|
||||||
},
|
},
|
||||||
|
|
@ -980,7 +952,6 @@ const hi: Translations = {
|
||||||
showcaseJourneyRoutes: 'यात्रा मार्ग',
|
showcaseJourneyRoutes: 'यात्रा मार्ग',
|
||||||
showcaseNearby: '{{value}} पास में',
|
showcaseNearby: '{{value}} पास में',
|
||||||
showcasePoliticalVoteShare: 'राजनीतिक वोट हिस्सेदारी',
|
showcasePoliticalVoteShare: 'राजनीतिक वोट हिस्सेदारी',
|
||||||
showcaseGe2024: '2024 आम चुनाव',
|
|
||||||
showcaseLotsMore: '...और भी बहुत कुछ',
|
showcaseLotsMore: '...और भी बहुत कुछ',
|
||||||
showcaseMinutes: '{{count}} मिनट',
|
showcaseMinutes: '{{count}} मिनट',
|
||||||
showcaseSendShortlist: 'शॉर्टलिस्ट भेजें',
|
showcaseSendShortlist: 'शॉर्टलिस्ट भेजें',
|
||||||
|
|
@ -1343,7 +1314,7 @@ const hi: Translations = {
|
||||||
'आजीवन पहुँच का मतलब है कि एक भुगतान आपके खाते को सेवा चलने तक सशुल्क Perfect Postcode मानचित्र की लगातार पहुँच देता है. यह मासिक या वार्षिक सदस्यता नहीं है, और सामान्य डेटा अपडेट शामिल हैं. आप इसे इस खोज में उपयोग कर सकते हैं, बाद में लौट सकते हैं और फिर स्थान बदलने पर भी पहुँच रहेगी.',
|
'आजीवन पहुँच का मतलब है कि एक भुगतान आपके खाते को सेवा चलने तक सशुल्क Perfect Postcode मानचित्र की लगातार पहुँच देता है. यह मासिक या वार्षिक सदस्यता नहीं है, और सामान्य डेटा अपडेट शामिल हैं. आप इसे इस खोज में उपयोग कर सकते हैं, बाद में लौट सकते हैं और फिर स्थान बदलने पर भी पहुँच रहेगी.',
|
||||||
faqPricing3Q: 'मुफ्त स्तर पर मैं क्या उपयोग कर सकता हूं?',
|
faqPricing3Q: 'मुफ्त स्तर पर मैं क्या उपयोग कर सकता हूं?',
|
||||||
faqPricing3A:
|
faqPricing3A:
|
||||||
'मुफ्त उपयोगकर्ताओं को पूरे इंग्लैंड में पूरा Perfect Postcode मानचित्र मिलता है — हर पोस्टकोड और हर सुविधा — और वे एक बार में 3 फ़िल्टर तक लगा सकते हैं. आजीवन पहुँच यह सीमा हटा देती है, ताकि आप असीमित फ़िल्टर लगा सकें.',
|
'मुफ्त उपयोगकर्ता डेमो क्षेत्र (इनर लंदन, लगभग जोन 1 से 2) के अंदर सभी सुविधाएं देख सकते हैं. इंग्लैंड के बाकी डेटा के लिए आजीवन पहुँच चाहिए.',
|
||||||
faqTips1Q: 'मानचित्र पर फ़िल्टर पूर्वावलोकन कैसे करें?',
|
faqTips1Q: 'मानचित्र पर फ़िल्टर पूर्वावलोकन कैसे करें?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
'किसी फ़िल्टर या सुविधा के पास रंगें पर क्लिक करें ताकि मानचित्र उसी मद से रंग जाए. आपके सक्रिय फ़िल्टर वैसे ही रहते हैं, इसलिए आप कीमत, आवागमन समय, स्कूल, अपराध या शोर जैसी एक चीज सूची बदले बिना तुलना कर सकते हैं.',
|
'किसी फ़िल्टर या सुविधा के पास रंगें पर क्लिक करें ताकि मानचित्र उसी मद से रंग जाए. आपके सक्रिय फ़िल्टर वैसे ही रहते हैं, इसलिए आप कीमत, आवागमन समय, स्कूल, अपराध या शोर जैसी एक चीज सूची बदले बिना तुलना कर सकते हैं.',
|
||||||
|
|
@ -1388,8 +1359,6 @@ const hi: Translations = {
|
||||||
noShareLinksYet: 'अभी कोई साझा लिंक नहीं',
|
noShareLinksYet: 'अभी कोई साझा लिंक नहीं',
|
||||||
copyShareLink: 'साझा लिंक कॉपी करें',
|
copyShareLink: 'साझा लिंक कॉपी करें',
|
||||||
clicksLabel: 'क्लिक',
|
clicksLabel: 'क्लिक',
|
||||||
fetchShareLinksError: 'साझा लिंक प्राप्त करने में विफल',
|
|
||||||
updateNewsletterError: 'न्यूज़लेटर अपडेट करने में विफल',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
savedPage: {
|
savedPage: {
|
||||||
|
|
@ -1404,12 +1373,6 @@ const hi: Translations = {
|
||||||
'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
|
'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
|
||||||
isBeingUpdated: '<strong>{{name}}</strong> अपडेट हो रहा है',
|
isBeingUpdated: '<strong>{{name}}</strong> अपडेट हो रहा है',
|
||||||
updating: 'अपडेट हो रहा है...',
|
updating: 'अपडेट हो रहा है...',
|
||||||
loadFailed: 'खोजें लोड करने में विफल',
|
|
||||||
saveFailed: 'खोज सहेजने में विफल',
|
|
||||||
deleteFailed: 'खोज हटाने में विफल',
|
|
||||||
updateNotesFailed: 'नोट्स अपडेट करने में विफल',
|
|
||||||
updateNameFailed: 'नाम अपडेट करने में विफल',
|
|
||||||
updateFailed: 'खोज अपडेट करने में विफल',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
invitesPage: {
|
invitesPage: {
|
||||||
|
|
@ -1428,7 +1391,6 @@ const hi: Translations = {
|
||||||
created: 'बनाया गया',
|
created: 'बनाया गया',
|
||||||
redeemed: 'भुनाया गया',
|
redeemed: 'भुनाया गया',
|
||||||
pending: 'लंबित',
|
pending: 'लंबित',
|
||||||
createInviteError: 'आमंत्रण बनाने में विफल',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
invitePage: {
|
invitePage: {
|
||||||
|
|
@ -1454,99 +1416,6 @@ const hi: Translations = {
|
||||||
youAlreadyHaveLicense: 'आपके पास पहले से लाइसेंस है',
|
youAlreadyHaveLicense: 'आपके पास पहले से लाइसेंस है',
|
||||||
accountHasFullAccess: 'आपके खाते में पहले से पूरी पहुँच है.',
|
accountHasFullAccess: 'आपके खाते में पहले से पूरी पहुँच है.',
|
||||||
failedToValidate: 'आमंत्रण लिंक सत्यापित नहीं हो सका',
|
failedToValidate: 'आमंत्रण लिंक सत्यापित नहीं हो सका',
|
||||||
redeemFailed: 'आमंत्रण भुनाने में विफल',
|
|
||||||
},
|
|
||||||
|
|
||||||
errors: {
|
|
||||||
appCrashTitle: 'कुछ गलत हो गया',
|
|
||||||
appCrashBody: 'फिर से प्रयास करने के लिए पेज रीफ्रेश करें.',
|
|
||||||
},
|
|
||||||
|
|
||||||
listing: {
|
|
||||||
viewed: 'देखा गया',
|
|
||||||
openListing: 'लिस्टिंग खोलें',
|
|
||||||
listings: 'लिस्टिंग',
|
|
||||||
listing: 'लिस्टिंग',
|
|
||||||
showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं',
|
|
||||||
groupedNear: 'इस मैप स्थिति के पास समूहीकृत',
|
|
||||||
},
|
|
||||||
|
|
||||||
overlays: {
|
|
||||||
heading: 'ओवरले',
|
|
||||||
baseMap: 'बेस मैप',
|
|
||||||
colourOpacity: 'रंग अपारदर्शिता',
|
|
||||||
dataOverlays: 'डेटा ओवरले',
|
|
||||||
about: '{{name}} के बारे में',
|
|
||||||
zoomWarning: 'चुने गए ओवरले को देखने के लिए और ज़ूम इन करें.',
|
|
||||||
zoomWarning_other: 'चुने गए ओवरले देखने के लिए और ज़ूम इन करें.',
|
|
||||||
noise: {
|
|
||||||
label: 'शोर',
|
|
||||||
detail:
|
|
||||||
'Defra Strategic Noise Mapping Round 4 (2022), जिसमें सड़क, रेल और हवाई अड्डे के स्रोत शामिल हैं. मान EU-मानक Lden मीट्रिक (दिन-शाम-रात 24-घंटे भारित औसत) हैं, जो ज़मीन से 4 मीटर ऊपर 10 मीटर ग्रिड पर मॉडल किए गए हैं. अधिक चमकीले क्षेत्र अधिक मॉडल किए गए शोर को दर्शाते हैं. Open Government Licence v3.0 के तहत लाइसेंस प्राप्त.',
|
|
||||||
},
|
|
||||||
crimeHotspots: {
|
|
||||||
label: 'अपराध हॉटस्पॉट',
|
|
||||||
detail:
|
|
||||||
'police.uk द्वारा सबसे हाल के महीनों में प्रकाशित सड़क-स्तरीय अपराधों का क्लाइंट-साइड हीटमैप. Police.uk के निर्देशांक गुमनाम स्नैप-टू-ग्रिड बिंदु हैं, अपराध के सटीक स्थान नहीं, इसलिए हीटमैप को घटनाओं के सटीक मानचित्र के बजाय सापेक्ष घनत्व के अनुमान के रूप में पढ़ा जाना चाहिए.',
|
|
||||||
},
|
|
||||||
treesOutsideWoodlands: {
|
|
||||||
label: 'पेड़ और वुडलैंड',
|
|
||||||
detail:
|
|
||||||
'Forest Research Trees Outside Woodland (TOW) v1 कैनोपी पॉलीगॉन — अकेले पेड़ और पेड़ों के समूह — National Forest Inventory (NFI) वुडलैंड ब्लॉक्स (≥0.5 हेक्टेयर) के साथ जोड़े गए हैं जिन्हें TOW जानबूझकर बाहर रखता है. साथ में ये सड़क के पेड़ और असली जंगल दोनों को कवर करते हैं. पॉलीगॉन की अपारदर्शिता कैनोपी क्षेत्र के अनुसार बढ़ती है.',
|
|
||||||
},
|
|
||||||
propertyBorders: {
|
|
||||||
label: 'संपत्ति सीमाएं',
|
|
||||||
detail:
|
|
||||||
'HM Land Registry INSPIRE Index Polygons — इंग्लैंड और वेल्स में फ्रीहोल्ड पंजीकृत संपत्ति की स्थिति और संकेतात्मक विस्तार, सड़क स्तर पर रूपरेखा के रूप में बनाई गई. ये केवल मार्गदर्शन के लिए "सामान्य सीमाएं" हैं, किसी संपत्ति की सटीक कानूनी सीमा नहीं, और इनमें केवल-लीजहोल्ड हित और अपंजीकृत भूमि शामिल नहीं हैं (लगभग 85–90% फ्रीहोल्ड भूमि कवर है). यह जानकारी Crown copyright और डेटाबेस अधिकार 2026 के अधीन है और HM Land Registry की अनुमति से पुनरुत्पादित की गई है. पॉलीगॉन (संबंधित ज्यामिति सहित, अर्थात् x, y निर्देशांक) Crown copyright और डेटाबेस अधिकार 2026 Ordnance Survey AC0000851063 के अधीन हैं. Open Government Licence v3.0 के तहत लाइसेंस प्राप्त.',
|
|
||||||
},
|
|
||||||
newDevelopments: {
|
|
||||||
label: 'नए विकास',
|
|
||||||
detail:
|
|
||||||
'नए आवास की एक भविष्योन्मुखी पाइपलाइन. नीले मार्कर वैधानिक MHCLG Brownfield Land registers पर साइटों को चिह्नित करते हैं — प्रत्येक में अनुमानित शुद्ध-आवास क्षमता और योजना-अनुमति स्थिति होती है — साथ ही Homes England Land Hub निपटान साइटें. ये दर्शाते हैं कि नए घर कहां योजनाबद्ध हैं, अक्सर EPC या बिक्री रिकॉर्ड में दिखाई देने से वर्षों पहले. आवास आंकड़े क्षमता अनुमान हैं, प्रतिबद्धताएं नहीं, और किसी रजिस्टर पर साइट निर्माण की गारंटी के बजाय एक अवसर है. Open Government Licence v3.0 के तहत लाइसेंस प्राप्त.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
crimeTypes: {
|
|
||||||
violenceAndSexualOffences: 'हिंसा और यौन अपराध',
|
|
||||||
antiSocialBehaviour: 'असामाजिक व्यवहार',
|
|
||||||
criminalDamageAndArson: 'आपराधिक क्षति और आगजनी',
|
|
||||||
publicOrder: 'सार्वजनिक व्यवस्था',
|
|
||||||
shoplifting: 'दुकान से चोरी',
|
|
||||||
vehicleCrime: 'वाहन अपराध',
|
|
||||||
burglary: 'सेंधमारी',
|
|
||||||
otherTheft: 'अन्य चोरी',
|
|
||||||
theftFromThePerson: 'व्यक्ति से चोरी',
|
|
||||||
bicycleTheft: 'साइकिल चोरी',
|
|
||||||
drugs: 'नशीले पदार्थ',
|
|
||||||
robbery: 'डकैती',
|
|
||||||
possessionOfWeapons: 'हथियार रखना',
|
|
||||||
otherCrime: 'अन्य अपराध',
|
|
||||||
},
|
|
||||||
|
|
||||||
journey: {
|
|
||||||
bus: 'बस',
|
|
||||||
lineSuffix: 'लाइन',
|
|
||||||
},
|
|
||||||
|
|
||||||
poiPopup: {
|
|
||||||
school: {
|
|
||||||
capacity: 'क्षमता',
|
|
||||||
type: 'प्रकार',
|
|
||||||
ages: 'आयु',
|
|
||||||
gender: 'लिंग',
|
|
||||||
pupilsLabel: 'विद्यार्थी',
|
|
||||||
pupils: 'विद्यार्थी',
|
|
||||||
freeMeal: 'मुफ्त भोजन',
|
|
||||||
ofsted: 'Ofsted',
|
|
||||||
sixthForm: 'सिक्स्थ फॉर्म',
|
|
||||||
religion: 'धर्म',
|
|
||||||
admissions: 'प्रवेश',
|
|
||||||
trust: 'ट्रस्ट',
|
|
||||||
address: 'पता',
|
|
||||||
localAuthority: 'LA',
|
|
||||||
head: 'प्रधानाध्यापक',
|
|
||||||
website: 'वेबसाइट',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
format: {
|
format: {
|
||||||
|
|
@ -1586,9 +1455,6 @@ const hi: Translations = {
|
||||||
step6Title: 'पास में क्या है?',
|
step6Title: 'पास में क्या है?',
|
||||||
step6Content:
|
step6Content:
|
||||||
'मानचित्र पर स्कूल, दुकानें, स्टेशन, पार्क और रेस्तरां चालू करें और देखें क्या पहुंच में है.',
|
'मानचित्र पर स्कूल, दुकानें, स्टेशन, पार्क और रेस्तरां चालू करें और देखें क्या पहुंच में है.',
|
||||||
step7Title: 'और संदर्भ जोड़ें',
|
|
||||||
step7Content:
|
|
||||||
'मानचित्र पर अपराध हॉटस्पॉट, शोर, वृक्षावरण, संपत्ति सीमाएं और नई आवासीय परियोजनाओं जैसी ओवरले परतें चालू करें और जरूरत के अनुसार आधार मानचित्र बदलें.',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -1635,53 +1501,22 @@ const hi: Translations = {
|
||||||
'Health Deprivation and Disability Score': 'स्वास्थ्य वंचना और विकलांगता स्कोर',
|
'Health Deprivation and Disability Score': 'स्वास्थ्य वंचना और विकलांगता स्कोर',
|
||||||
'Housing Conditions Score': 'आवास स्थिति स्कोर',
|
'Housing Conditions Score': 'आवास स्थिति स्कोर',
|
||||||
'Air Quality and Road Safety Score': 'हवा की गुणवत्ता और सड़क सुरक्षा स्कोर',
|
'Air Quality and Road Safety Score': 'हवा की गुणवत्ता और सड़क सुरक्षा स्कोर',
|
||||||
'Serious crime (/yr, 7y)': 'गंभीर अपराध (प्रति वर्ष, 7 वर्ष)',
|
'Serious crime (avg/yr)': 'गंभीर अपराध (घनत्व)',
|
||||||
'Serious crime (/yr, 2y)': 'गंभीर अपराध (प्रति वर्ष, 2 वर्ष)',
|
'Minor crime (avg/yr)': 'मामूली अपराध (घनत्व)',
|
||||||
'Minor crime (/yr, 7y)': 'मामूली अपराध (प्रति वर्ष, 7 वर्ष)',
|
'Violence and sexual offences (avg/yr)': 'हिंसा और यौन अपराध (घनत्व)',
|
||||||
'Minor crime (/yr, 2y)': 'मामूली अपराध (प्रति वर्ष, 2 वर्ष)',
|
'Burglary (avg/yr)': 'सेंधमारी (घनत्व)',
|
||||||
'Violence and sexual offences (/yr, 7y)': 'हिंसा और यौन अपराध (प्रति वर्ष, 7 वर्ष)',
|
'Robbery (avg/yr)': 'लूट (घनत्व)',
|
||||||
'Violence and sexual offences (/yr, 2y)': 'हिंसा और यौन अपराध (प्रति वर्ष, 2 वर्ष)',
|
'Vehicle crime (avg/yr)': 'वाहन अपराध (घनत्व)',
|
||||||
'Burglary (/yr, 7y)': 'सेंधमारी (प्रति वर्ष, 7 वर्ष)',
|
'Anti-social behaviour (avg/yr)': 'असामाजिक व्यवहार (घनत्व)',
|
||||||
'Burglary (/yr, 2y)': 'सेंधमारी (प्रति वर्ष, 2 वर्ष)',
|
'Criminal damage and arson (avg/yr)': 'आपराधिक क्षति और आगजनी (घनत्व)',
|
||||||
'Robbery (/yr, 7y)': 'लूट (प्रति वर्ष, 7 वर्ष)',
|
'Other theft (avg/yr)': 'अन्य चोरी (घनत्व)',
|
||||||
'Robbery (/yr, 2y)': 'लूट (प्रति वर्ष, 2 वर्ष)',
|
'Theft from the person (avg/yr)': 'व्यक्ति से चोरी (घनत्व)',
|
||||||
'Vehicle crime (/yr, 7y)': 'वाहन अपराध (प्रति वर्ष, 7 वर्ष)',
|
'Shoplifting (avg/yr)': 'दुकान से चोरी (घनत्व)',
|
||||||
'Vehicle crime (/yr, 2y)': 'वाहन अपराध (प्रति वर्ष, 2 वर्ष)',
|
'Bicycle theft (avg/yr)': 'साइकिल चोरी (घनत्व)',
|
||||||
'Anti-social behaviour (/yr, 7y)': 'असामाजिक व्यवहार (प्रति वर्ष, 7 वर्ष)',
|
'Drugs (avg/yr)': 'ड्रग्स (घनत्व)',
|
||||||
'Anti-social behaviour (/yr, 2y)': 'असामाजिक व्यवहार (प्रति वर्ष, 2 वर्ष)',
|
'Possession of weapons (avg/yr)': 'हथियार रखने के अपराध (घनत्व)',
|
||||||
'Criminal damage and arson (/yr, 7y)': 'आपराधिक क्षति और आगजनी (प्रति वर्ष, 7 वर्ष)',
|
'Public order (avg/yr)': 'सार्वजनिक व्यवस्था अपराध (घनत्व)',
|
||||||
'Criminal damage and arson (/yr, 2y)': 'आपराधिक क्षति और आगजनी (प्रति वर्ष, 2 वर्ष)',
|
'Other crime (avg/yr)': 'अन्य अपराध (घनत्व)',
|
||||||
'Other theft (/yr, 7y)': 'अन्य चोरी (प्रति वर्ष, 7 वर्ष)',
|
|
||||||
'Other theft (/yr, 2y)': 'अन्य चोरी (प्रति वर्ष, 2 वर्ष)',
|
|
||||||
'Theft from the person (/yr, 7y)': 'व्यक्ति से चोरी (प्रति वर्ष, 7 वर्ष)',
|
|
||||||
'Theft from the person (/yr, 2y)': 'व्यक्ति से चोरी (प्रति वर्ष, 2 वर्ष)',
|
|
||||||
'Shoplifting (/yr, 7y)': 'दुकान से चोरी (प्रति वर्ष, 7 वर्ष)',
|
|
||||||
'Shoplifting (/yr, 2y)': 'दुकान से चोरी (प्रति वर्ष, 2 वर्ष)',
|
|
||||||
'Bicycle theft (/yr, 7y)': 'साइकिल चोरी (प्रति वर्ष, 7 वर्ष)',
|
|
||||||
'Bicycle theft (/yr, 2y)': 'साइकिल चोरी (प्रति वर्ष, 2 वर्ष)',
|
|
||||||
'Drugs (/yr, 7y)': 'ड्रग्स (प्रति वर्ष, 7 वर्ष)',
|
|
||||||
'Drugs (/yr, 2y)': 'ड्रग्स (प्रति वर्ष, 2 वर्ष)',
|
|
||||||
'Possession of weapons (/yr, 7y)': 'हथियार रखने के अपराध (प्रति वर्ष, 7 वर्ष)',
|
|
||||||
'Possession of weapons (/yr, 2y)': 'हथियार रखने के अपराध (प्रति वर्ष, 2 वर्ष)',
|
|
||||||
'Public order (/yr, 7y)': 'सार्वजनिक व्यवस्था अपराध (प्रति वर्ष, 7 वर्ष)',
|
|
||||||
'Public order (/yr, 2y)': 'सार्वजनिक व्यवस्था अपराध (प्रति वर्ष, 2 वर्ष)',
|
|
||||||
'Other crime (/yr, 7y)': 'अन्य अपराध (प्रति वर्ष, 7 वर्ष)',
|
|
||||||
'Other crime (/yr, 2y)': 'अन्य अपराध (प्रति वर्ष, 2 वर्ष)',
|
|
||||||
// केवल अपराध-प्रकार के लेबल (प्रकार-वार विवरण + रिकॉर्ड सूची)।
|
|
||||||
// ("Burglary" पहले से ही नीचे विशिष्ट अपराधों में परिभाषित है।)
|
|
||||||
'Violence and sexual offences': 'हिंसा और यौन अपराध',
|
|
||||||
Robbery: 'लूट',
|
|
||||||
'Vehicle crime': 'वाहन अपराध',
|
|
||||||
'Anti-social behaviour': 'असामाजिक व्यवहार',
|
|
||||||
'Criminal damage and arson': 'आपराधिक क्षति और आगजनी',
|
|
||||||
'Other theft': 'अन्य चोरी',
|
|
||||||
'Theft from the person': 'व्यक्ति से चोरी',
|
|
||||||
Shoplifting: 'दुकान से चोरी',
|
|
||||||
'Bicycle theft': 'साइकिल चोरी',
|
|
||||||
Drugs: 'ड्रग्स',
|
|
||||||
'Possession of weapons': 'हथियार रखने के अपराध',
|
|
||||||
'Public order': 'सार्वजनिक व्यवस्था अपराध',
|
|
||||||
'Other crime': 'अन्य अपराध',
|
|
||||||
'Median age': 'मध्य आयु',
|
'Median age': 'मध्य आयु',
|
||||||
'% No qualifications': '% कोई योग्यता नहीं',
|
'% No qualifications': '% कोई योग्यता नहीं',
|
||||||
'% Some GCSEs': '% कुछ GCSE',
|
'% Some GCSEs': '% कुछ GCSE',
|
||||||
|
|
@ -1754,7 +1589,6 @@ const hi: Translations = {
|
||||||
'Bus station': 'बस स्टेशन',
|
'Bus station': 'बस स्टेशन',
|
||||||
'Taxi rank': 'टैक्सी स्टैंड',
|
'Taxi rank': 'टैक्सी स्टैंड',
|
||||||
'Tube station': 'ट्यूब स्टेशन',
|
'Tube station': 'ट्यूब स्टेशन',
|
||||||
'DLR station': 'DLR स्टेशन',
|
|
||||||
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
|
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
|
||||||
Café: 'कैफे',
|
Café: 'कैफे',
|
||||||
Restaurant: 'रेस्तरां',
|
Restaurant: 'रेस्तरां',
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,6 @@ const hu: Translations = {
|
||||||
closePane: 'Panel bezárása',
|
closePane: 'Panel bezárása',
|
||||||
yes: 'Igen',
|
yes: 'Igen',
|
||||||
no: 'Nem',
|
no: 'Nem',
|
||||||
captions: 'Feliratok',
|
|
||||||
sqm: 'm²',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Header / Nav ───────────────────────────────────
|
// ── Header / Nav ───────────────────────────────────
|
||||||
|
|
@ -628,17 +626,13 @@ const hu: Translations = {
|
||||||
backToLogin: 'Vissza a bejelentkezéshez',
|
backToLogin: 'Vissza a bejelentkezéshez',
|
||||||
registerConsent:
|
registerConsent:
|
||||||
'A fiók létrehozásával elfogadod a <terms>Felhasználási feltételeket</terms> és az <privacy>Adatvédelmi tájékoztatót</privacy>.',
|
'A fiók létrehozásával elfogadod a <terms>Felhasználási feltételeket</terms> és az <privacy>Adatvédelmi tájékoztatót</privacy>.',
|
||||||
loginFailed: 'A bejelentkezés sikertelen',
|
|
||||||
registrationFailed: 'A regisztráció sikertelen',
|
|
||||||
oauthFailed: 'Az OAuth-bejelentkezés sikertelen',
|
|
||||||
passwordResetFailed: 'A jelszó-visszaállítási kérés sikertelen',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'Oldd fel a korlátlan szűrőket',
|
title: 'Találd meg az összes megfelelő irányítószámot',
|
||||||
description:
|
description:
|
||||||
'Az ingyenes fiókok egyszerre legfeljebb 3 szűrőt kombinálhatnak. Szerezz élethosszig tartó hozzáférést, hogy korlátlanul rétegezhesd a szűrőket Anglia minden irányítószámán és környékén. Egyetlen fizetés, örökre.',
|
'Jelenleg a demóterületet fedezed fel. Szerezz élethosszig tartó hozzáférést Anglia minden irányítószámához, minden szűrőjéhez és minden környékéhez. Egyetlen fizetés, örökre.',
|
||||||
free: 'Ingyenes',
|
free: 'Ingyenes',
|
||||||
freeForEarly: 'Ingyenes a korai felhasználóknak. Bankkártya nélkül.',
|
freeForEarly: 'Ingyenes a korai felhasználóknak. Bankkártya nélkül.',
|
||||||
oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés.',
|
oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés.',
|
||||||
|
|
@ -647,7 +641,10 @@ const hu: Translations = {
|
||||||
upgradeFor: 'Teljes hozzáférés {{price}} áron',
|
upgradeFor: 'Teljes hozzáférés {{price}} áron',
|
||||||
registerAndUpgrade: 'Regisztráció és teljes hozzáférés',
|
registerAndUpgrade: 'Regisztráció és teljes hozzáférés',
|
||||||
alreadyHaveAccount: 'Már van fiókod? Jelentkezz be',
|
alreadyHaveAccount: 'Már van fiókod? Jelentkezz be',
|
||||||
continueFree: 'Folytatás 3 szűrővel',
|
continueWithDemo: 'Folytatás demóval',
|
||||||
|
backToSharedArea: 'Vissza a megosztott területre',
|
||||||
|
sharedAreaDescription:
|
||||||
|
'Egy megosztott területet nézel. Ha tovább szeretnél felfedezni, szerezz élethosszig tartó hozzáférést Anglia minden irányítószámához, szűrőjéhez és környékéhez.',
|
||||||
checkoutFailed: 'A fizetés sikertelen',
|
checkoutFailed: 'A fizetés sikertelen',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -724,11 +721,6 @@ const hu: Translations = {
|
||||||
outstanding: 'Kiváló',
|
outstanding: 'Kiváló',
|
||||||
distance: 'Távolság',
|
distance: 'Távolság',
|
||||||
crimeType: 'Bűncselekménytípus',
|
crimeType: 'Bűncselekménytípus',
|
||||||
qualificationLevel: 'Képzettségi szint',
|
|
||||||
tenureType: 'Lakhatási forma',
|
|
||||||
crimeWindow: 'Időszak',
|
|
||||||
crimeWindow7y: '7 év',
|
|
||||||
crimeWindow2y: '2 év',
|
|
||||||
ethnicity: 'Etnikai csoport',
|
ethnicity: 'Etnikai csoport',
|
||||||
poiType: 'POI-típus',
|
poiType: 'POI-típus',
|
||||||
party: 'Párt',
|
party: 'Párt',
|
||||||
|
|
@ -818,7 +810,6 @@ const hu: Translations = {
|
||||||
generatingFilters: 'Szűrők létrehozása...',
|
generatingFilters: 'Szűrők létrehozása...',
|
||||||
refiningResults: 'Eredmények finomhangolása...',
|
refiningResults: 'Eredmények finomhangolása...',
|
||||||
weeklyLimitReached: 'Elérted a heti AI-használati limitet. Automatikusan visszaáll jövő héten.',
|
weeklyLimitReached: 'Elérted a heti AI-használati limitet. Automatikusan visszaáll jövő héten.',
|
||||||
generateFailed: 'A szűrők létrehozása sikertelen',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
|
|
@ -838,18 +829,6 @@ const hu: Translations = {
|
||||||
ogSchools: 'Iskolák',
|
ogSchools: 'Iskolák',
|
||||||
ogCrimeStats: 'Bűnözési adatok',
|
ogCrimeStats: 'Bűnözési adatok',
|
||||||
ogTransport: 'Közlekedés',
|
ogTransport: 'Közlekedés',
|
||||||
basemap: { standard: 'Térkép', satellite: 'Műhold' },
|
|
||||||
error: {
|
|
||||||
heading: 'A térkép hibába ütközött',
|
|
||||||
body: 'Ez akkor fordulhat elő, ha a böngésződ grafikus környezete megszakad.',
|
|
||||||
reload: 'Térkép újratöltése',
|
|
||||||
},
|
|
||||||
actualListings: {
|
|
||||||
label: 'Hirdetések',
|
|
||||||
show: 'Valódi hirdetések megjelenítése',
|
|
||||||
hide: 'Valódi hirdetések elrejtése',
|
|
||||||
},
|
|
||||||
poi: { zoomInToSeeDetails: 'Nagyíts rá a részletekért' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Properties Pane ────────────────────────────────
|
// ── Properties Pane ────────────────────────────────
|
||||||
|
|
@ -928,20 +907,6 @@ const hu: Translations = {
|
||||||
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',
|
residents: 'Lakosok',
|
||||||
residentsTooltip: 'Állandó lakosok (ONS 2021-es népszámlálás)',
|
residentsTooltip: 'Állandó lakosok (ONS 2021-es népszámlálás)',
|
||||||
crimeWindow7y: 'Az elmúlt 7 év',
|
|
||||||
crimeWindow2y: 'Az elmúlt 2 év',
|
|
||||||
crimeWindowLabel: 'Időszak',
|
|
||||||
crimeCardTitle: '{{name}} évente',
|
|
||||||
crimeRecordsToggle: 'Egyedi bűncselekmények megjelenítése',
|
|
||||||
crimeRecordsHide: 'Egyedi bűncselekmények elrejtése',
|
|
||||||
crimeRecordsCount: '{{count}} bűncselekmény',
|
|
||||||
crimeRecordsLoading: 'Bűncselekmények betöltése…',
|
|
||||||
crimeRecordsEmpty: 'Nincs rögzített egyedi bűncselekmény',
|
|
||||||
crimeRecordsError: 'A bűncselekményeket nem sikerült betölteni',
|
|
||||||
crimeRecordsLoadMore: 'Több betöltése',
|
|
||||||
crimeNoLocation: 'A helyszín nincs megadva',
|
|
||||||
crimeOutcomeUnknown: 'Az eredmény nincs rögzítve',
|
|
||||||
crimeYearPointTooltip: '{{year}}: {{rate}}/év',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -966,7 +931,6 @@ const hu: Translations = {
|
||||||
searchOn: 'Keresés {{radius}} sugárban ezen:',
|
searchOn: 'Keresés {{radius}} sugárban ezen:',
|
||||||
exact: 'pontos',
|
exact: 'pontos',
|
||||||
outcodeNotRecognised: 'Ismeretlen irányítószám-körzet',
|
outcodeNotRecognised: 'Ismeretlen irányítószám-körzet',
|
||||||
radiusMi: '{{count}} mérföldes sugár',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Location Search ────────────────────────────────
|
// ── Location Search ────────────────────────────────
|
||||||
|
|
@ -981,6 +945,14 @@ const hu: Translations = {
|
||||||
geolocationFailed: 'Nem sikerült meghatározni a tartózkodási helyed',
|
geolocationFailed: 'Nem sikerült meghatározni a tartózkodási helyed',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
demoLocation: {
|
||||||
|
title: 'Fedezd fel a környékedet',
|
||||||
|
body: 'Szeretnél ingatlanadatokat látni a környékeden? Használd a helyzeted, vagy indítsd a demót a Canary Wharfnál.',
|
||||||
|
useLocation: 'Saját helyzet használata',
|
||||||
|
locating: 'Helymeghatározás…',
|
||||||
|
useDefault: 'Canary Wharf megjelenítése',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Mobile Drawer ──────────────────────────────────
|
// ── Mobile Drawer ──────────────────────────────────
|
||||||
mobileDrawer: {
|
mobileDrawer: {
|
||||||
closeDrawer: 'Panel bezárása',
|
closeDrawer: 'Panel bezárása',
|
||||||
|
|
@ -1064,7 +1036,6 @@ const hu: Translations = {
|
||||||
showcaseStep4ColCommute: 'Ingázás',
|
showcaseStep4ColCommute: 'Ingázás',
|
||||||
showcaseStep4ColPrice: 'Medián eladási ár',
|
showcaseStep4ColPrice: 'Medián eladási ár',
|
||||||
showcaseStep4Conclusion: 'Innen már el tudod indítani a keresést.',
|
showcaseStep4Conclusion: 'Innen már el tudod indítani a keresést.',
|
||||||
showcaseGe2024: '2024-es választás',
|
|
||||||
statProperties: 'korábbi eladás',
|
statProperties: 'korábbi eladás',
|
||||||
statFilters: 'kombinálható szűrő',
|
statFilters: 'kombinálható szűrő',
|
||||||
statEvery: 'Minden',
|
statEvery: 'Minden',
|
||||||
|
|
@ -1395,7 +1366,7 @@ const hu: Translations = {
|
||||||
'Az élethosszig tartó hozzáférés azt jelenti, hogy egy fizetéssel a fiókod folyamatos hozzáférést kap a fizetős Perfect Postcode térképhez a szolgáltatás élettartamára. Ez nem havi vagy éves előfizetés, és a szokásos adatfrissítések benne vannak. Használhatod a mostani kereséshez, később visszatérhetsz, és akkor is hozzáférsz, ha újra költözöl.',
|
'Az élethosszig tartó hozzáférés azt jelenti, hogy egy fizetéssel a fiókod folyamatos hozzáférést kap a fizetős Perfect Postcode térképhez a szolgáltatás élettartamára. Ez nem havi vagy éves előfizetés, és a szokásos adatfrissítések benne vannak. Használhatod a mostani kereséshez, később visszatérhetsz, és akkor is hozzáférsz, ha újra költözöl.',
|
||||||
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
|
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
|
||||||
faqPricing3A:
|
faqPricing3A:
|
||||||
'Az ingyenes felhasználók a teljes Perfect Postcode térképet megkapják egész Angliában — minden irányítószámot és minden funkciót —, és egyszerre legfeljebb 3 szűrőt alkalmazhatnak. Az élethosszig tartó hozzáférés feloldja ezt a korlátot, így korlátlanul rétegezheted a szűrőket.',
|
'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.',
|
||||||
|
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'Hogyan nézhetek meg egy szűrőt a térképen?',
|
faqTips1Q: 'Hogyan nézhetek meg egy szűrőt a térképen?',
|
||||||
|
|
@ -1443,8 +1414,6 @@ const hu: Translations = {
|
||||||
noShareLinksYet: 'Még nincsenek megosztott hivatkozások',
|
noShareLinksYet: 'Még nincsenek megosztott hivatkozások',
|
||||||
copyShareLink: 'Megosztott hivatkozás másolása',
|
copyShareLink: 'Megosztott hivatkozás másolása',
|
||||||
clicksLabel: 'kattintás',
|
clicksLabel: 'kattintás',
|
||||||
fetchShareLinksError: 'A megosztott hivatkozások lekérése sikertelen',
|
|
||||||
updateNewsletterError: 'A hírlevél frissítése sikertelen',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Saved Page ─────────────────────────────────────
|
// ── Saved Page ─────────────────────────────────────
|
||||||
|
|
@ -1460,12 +1429,6 @@ const hu: Translations = {
|
||||||
'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
|
'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
|
||||||
isBeingUpdated: '<strong>{{name}}</strong> frissítése folyamatban',
|
isBeingUpdated: '<strong>{{name}}</strong> frissítése folyamatban',
|
||||||
updating: 'Frissítés...',
|
updating: 'Frissítés...',
|
||||||
loadFailed: 'A keresések betöltése sikertelen',
|
|
||||||
saveFailed: 'A keresés mentése sikertelen',
|
|
||||||
deleteFailed: 'A keresés törlése sikertelen',
|
|
||||||
updateNotesFailed: 'A jegyzetek frissítése sikertelen',
|
|
||||||
updateNameFailed: 'A név frissítése sikertelen',
|
|
||||||
updateFailed: 'A keresés frissítése sikertelen',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invites Page ───────────────────────────────────
|
// ── Invites Page ───────────────────────────────────
|
||||||
|
|
@ -1485,7 +1448,6 @@ const hu: Translations = {
|
||||||
created: 'Létrehozva',
|
created: 'Létrehozva',
|
||||||
redeemed: 'Beváltva',
|
redeemed: 'Beváltva',
|
||||||
pending: 'Függőben',
|
pending: 'Függőben',
|
||||||
createInviteError: 'A meghívó létrehozása sikertelen',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invite Page ────────────────────────────────────
|
// ── Invite Page ────────────────────────────────────
|
||||||
|
|
@ -1514,105 +1476,6 @@ const hu: Translations = {
|
||||||
youAlreadyHaveLicense: 'Már van licenced',
|
youAlreadyHaveLicense: 'Már van licenced',
|
||||||
accountHasFullAccess: 'A fiókod már teljes hozzáféréssel rendelkezik.',
|
accountHasFullAccess: 'A fiókod már teljes hozzáféréssel rendelkezik.',
|
||||||
failedToValidate: 'Nem sikerült a meghívó hivatkozás érvényesítése',
|
failedToValidate: 'Nem sikerült a meghívó hivatkozás érvényesítése',
|
||||||
redeemFailed: 'A meghívó beváltása sikertelen',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Errors ─────────────────────────────────────────
|
|
||||||
errors: {
|
|
||||||
appCrashTitle: 'Valami hiba történt',
|
|
||||||
appCrashBody: 'Frissítsd az oldalt az újrapróbálkozáshoz.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Listing ────────────────────────────────────────
|
|
||||||
listing: {
|
|
||||||
viewed: 'Megtekintve',
|
|
||||||
openListing: 'Hirdetés megnyitása',
|
|
||||||
listings: 'hirdetés',
|
|
||||||
listing: 'Hirdetés',
|
|
||||||
showingOf: '{{visible}} / {{count}} megjelenítve',
|
|
||||||
groupedNear: 'Ennél a térképponton csoportosítva',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Overlays ───────────────────────────────────────
|
|
||||||
overlays: {
|
|
||||||
heading: 'Rétegek',
|
|
||||||
baseMap: 'Alaptérkép',
|
|
||||||
colourOpacity: 'Színek átlátszósága',
|
|
||||||
dataOverlays: 'Adatrétegek',
|
|
||||||
about: 'Névjegy: {{name}}',
|
|
||||||
zoomWarning: 'Nagyíts tovább a kiválasztott réteg megtekintéséhez.',
|
|
||||||
zoomWarning_other: 'Nagyíts tovább a kiválasztott rétegek megtekintéséhez.',
|
|
||||||
noise: {
|
|
||||||
label: 'Zaj',
|
|
||||||
detail:
|
|
||||||
'Defra Strategic Noise Mapping Round 4 (2022), amely az úti, vasúti és repülőtéri forrásokat egyesíti. Az értékek az EU-szabványos Lden mutató szerintiek (nappal-este-éjjel 24 órás súlyozott átlag), 10 m-es rácson, a talaj felett 4 m-en modellezve. A világosabb területek magasabb modellezett zajt jeleznek. Licenc: Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
crimeHotspots: {
|
|
||||||
label: 'Bűnözési gócpontok',
|
|
||||||
detail:
|
|
||||||
'A police.uk által közzétett, utcaszintű bűncselekmények kliensoldali hőtérképe a legutóbbi hónapokból. A police.uk koordinátái anonimizált, rácshoz illesztett pontok, nem pontos bűncselekményi helyszínek, ezért a hőtérképet a relatív sűrűség közelítéseként érdemes olvasni, nem az esetek pontos térképeként.',
|
|
||||||
},
|
|
||||||
treesOutsideWoodlands: {
|
|
||||||
label: 'Fák és erdő',
|
|
||||||
detail:
|
|
||||||
'Forest Research Trees Outside Woodland (TOW) v1 lombkorona-poligonok — magányos fák és facsoportok — egyesítve a National Forest Inventory (NFI) erdőblokkjaival (≥0,5 ha), amelyeket a TOW szándékosan kihagy. Együtt lefedik mind az utcai fákat, mind a valódi erdőket. A poligonok átlátszatlansága a lombkorona-területtel arányosan változik.',
|
|
||||||
},
|
|
||||||
propertyBorders: {
|
|
||||||
label: 'Telekhatárok',
|
|
||||||
detail:
|
|
||||||
'HM Land Registry INSPIRE Index Polygons — a szabad tulajdonú, bejegyzett ingatlanok helyzete és tájékoztató kiterjedése Angliában és Walesben, utcaszinten körvonalként ábrázolva. Ezek csak tájékoztató „általános határok”, nem az ingatlan pontos jogi határai, és nem tartalmazzák a kizárólag bérleti jogú érdekeltségeket és a be nem jegyzett földeket (a szabad tulajdonú föld nagyjából 85–90%-a van lefedve). Ezek az információk Crown copyright és adatbázis-jogok 2026 hatálya alá tartoznak, és a HM Land Registry engedélyével kerülnek közlésre. A poligonok (beleértve a hozzájuk tartozó geometriát, azaz az x, y koordinátákat) Crown copyright és adatbázis-jogok 2026 Ordnance Survey AC0000851063 hatálya alá tartoznak. Licenc: Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
newDevelopments: {
|
|
||||||
label: 'Új fejlesztések',
|
|
||||||
detail:
|
|
||||||
'Előretekintő lista az új lakásépítésekről. A kék jelölők a törvényi MHCLG Brownfield Land nyilvántartásokban szereplő helyszíneket mutatják — mindegyik becsült nettó lakáskapacitással és építési engedélyezési státusszal —, a Homes England Land Hub értékesítési helyszíneivel együtt. Ezek megmutatják, hol terveznek új otthonokat, gyakran évekkel azelőtt, hogy megjelennének az EPC- vagy eladási nyilvántartásokban. A lakásszámok kapacitásbecslések, nem kötelezettségvállalások, és egy nyilvántartásban szereplő helyszín lehetőség, nem az építkezés garanciája. Licenc: Open Government Licence v3.0.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Crime Types ────────────────────────────────────
|
|
||||||
crimeTypes: {
|
|
||||||
violenceAndSexualOffences: 'Erőszakos és szexuális bűncselekmények',
|
|
||||||
antiSocialBehaviour: 'Közösségellenes viselkedés',
|
|
||||||
criminalDamageAndArson: 'Rongálás és gyújtogatás',
|
|
||||||
publicOrder: 'Közrend elleni cselekmény',
|
|
||||||
shoplifting: 'Bolti lopás',
|
|
||||||
vehicleCrime: 'Járművel kapcsolatos bűncselekmény',
|
|
||||||
burglary: 'Betörés',
|
|
||||||
otherTheft: 'Egyéb lopás',
|
|
||||||
theftFromThePerson: 'Személytől való lopás',
|
|
||||||
bicycleTheft: 'Kerékpárlopás',
|
|
||||||
drugs: 'Kábítószer',
|
|
||||||
robbery: 'Rablás',
|
|
||||||
possessionOfWeapons: 'Fegyvertartás',
|
|
||||||
otherCrime: 'Egyéb bűncselekmény',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Journey ────────────────────────────────────────
|
|
||||||
journey: {
|
|
||||||
bus: 'Busz',
|
|
||||||
lineSuffix: 'vonal',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── POI Popup ──────────────────────────────────────
|
|
||||||
poiPopup: {
|
|
||||||
school: {
|
|
||||||
capacity: 'Kapacitás',
|
|
||||||
type: 'Típus',
|
|
||||||
ages: 'Életkor',
|
|
||||||
gender: 'Nem',
|
|
||||||
pupilsLabel: 'Tanulók',
|
|
||||||
pupils: 'tanuló',
|
|
||||||
freeMeal: 'Ingyenes étkezés',
|
|
||||||
ofsted: 'Ofsted',
|
|
||||||
sixthForm: 'Felső középfok',
|
|
||||||
religion: 'Vallás',
|
|
||||||
admissions: 'Felvétel',
|
|
||||||
trust: 'Fenntartó',
|
|
||||||
address: 'Cím',
|
|
||||||
localAuthority: 'Önk.',
|
|
||||||
head: 'Igazgató',
|
|
||||||
website: 'Weboldal',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Format / Time ──────────────────────────────────
|
// ── Format / Time ──────────────────────────────────
|
||||||
|
|
@ -1654,9 +1517,6 @@ const hu: Translations = {
|
||||||
step6Title: 'Mi van a közelben?',
|
step6Title: 'Mi van a közelben?',
|
||||||
step6Content:
|
step6Content:
|
||||||
'Kapcsolja be az iskolákat, üzleteket, állomásokat, parkokat és éttermeket a térképen, hogy lássa, mi érhető el.',
|
'Kapcsolja be az iskolákat, üzleteket, állomásokat, parkokat és éttermeket a térképen, hogy lássa, mi érhető el.',
|
||||||
step7Title: 'Adjon hozzá több réteget',
|
|
||||||
step7Content:
|
|
||||||
'Kapcsoljon be olyan rétegeket a térképen, mint a bűnözési gócpontok, zaj, faállomány, telekhatárok és új építésű területek, és váltson alaptérképet igény szerint.',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Server-derived values ──────────────────────────
|
// ── Server-derived values ──────────────────────────
|
||||||
|
|
@ -1714,54 +1574,23 @@ const hu: Translations = {
|
||||||
'Housing Conditions Score': 'Lakáskörülmények pontszám',
|
'Housing Conditions Score': 'Lakáskörülmények pontszám',
|
||||||
'Air Quality and Road Safety Score': 'Levegőminőség és közlekedésbiztonság pontszám',
|
'Air Quality and Road Safety Score': 'Levegőminőség és közlekedésbiztonság pontszám',
|
||||||
|
|
||||||
// ─ Feature names (Crime) — népességre normált ráta, 7 év és 2 év ─
|
// ─ Feature names (Crime) ─
|
||||||
'Serious crime (/yr, 7y)': 'Súlyos bűncselekmény (évente, 7 év)',
|
'Serious crime (avg/yr)': 'Súlyos bűncselekmény (sűrűség)',
|
||||||
'Serious crime (/yr, 2y)': 'Súlyos bűncselekmény (évente, 2 év)',
|
'Minor crime (avg/yr)': 'Kisebb bűncselekmény (sűrűség)',
|
||||||
'Minor crime (/yr, 7y)': 'Kisebb bűncselekmény (évente, 7 év)',
|
'Violence and sexual offences (avg/yr)': 'Erőszak és szexuális bűncselekmények (sűrűség)',
|
||||||
'Minor crime (/yr, 2y)': 'Kisebb bűncselekmény (évente, 2 év)',
|
'Burglary (avg/yr)': 'Betörés (sűrűség)',
|
||||||
'Violence and sexual offences (/yr, 7y)': 'Erőszak és szexuális bűncselekmények (évente, 7 év)',
|
'Robbery (avg/yr)': 'Rablás (sűrűség)',
|
||||||
'Violence and sexual offences (/yr, 2y)': 'Erőszak és szexuális bűncselekmények (évente, 2 év)',
|
'Vehicle crime (avg/yr)': 'Járművel kapcsolatos bűncselekmények (sűrűség)',
|
||||||
'Burglary (/yr, 7y)': 'Betörés (évente, 7 év)',
|
'Anti-social behaviour (avg/yr)': 'Közösségellenes magatartás (sűrűség)',
|
||||||
'Burglary (/yr, 2y)': 'Betörés (évente, 2 év)',
|
'Criminal damage and arson (avg/yr)': 'Rongálás és gyújtogatás (sűrűség)',
|
||||||
'Robbery (/yr, 7y)': 'Rablás (évente, 7 év)',
|
'Other theft (avg/yr)': 'Egyéb lopás (sűrűség)',
|
||||||
'Robbery (/yr, 2y)': 'Rablás (évente, 2 év)',
|
'Theft from the person (avg/yr)': 'Személytől történő lopás (sűrűség)',
|
||||||
'Vehicle crime (/yr, 7y)': 'Járművel kapcsolatos bűncselekmények (évente, 7 év)',
|
'Shoplifting (avg/yr)': 'Bolti lopás (sűrűség)',
|
||||||
'Vehicle crime (/yr, 2y)': 'Járművel kapcsolatos bűncselekmények (évente, 2 év)',
|
'Bicycle theft (avg/yr)': 'Kerékpárlopás (sűrűség)',
|
||||||
'Anti-social behaviour (/yr, 7y)': 'Közösségellenes magatartás (évente, 7 év)',
|
'Drugs (avg/yr)': 'Kábítószer (sűrűség)',
|
||||||
'Anti-social behaviour (/yr, 2y)': 'Közösségellenes magatartás (évente, 2 év)',
|
'Possession of weapons (avg/yr)': 'Fegyverbirtoklás (sűrűség)',
|
||||||
'Criminal damage and arson (/yr, 7y)': 'Rongálás és gyújtogatás (évente, 7 év)',
|
'Public order (avg/yr)': 'Közrend (sűrűség)',
|
||||||
'Criminal damage and arson (/yr, 2y)': 'Rongálás és gyújtogatás (évente, 2 év)',
|
'Other crime (avg/yr)': 'Egyéb bűncselekmény (sűrűség)',
|
||||||
'Other theft (/yr, 7y)': 'Egyéb lopás (évente, 7 év)',
|
|
||||||
'Other theft (/yr, 2y)': 'Egyéb lopás (évente, 2 év)',
|
|
||||||
'Theft from the person (/yr, 7y)': 'Személytől történő lopás (évente, 7 év)',
|
|
||||||
'Theft from the person (/yr, 2y)': 'Személytől történő lopás (évente, 2 év)',
|
|
||||||
'Shoplifting (/yr, 7y)': 'Bolti lopás (évente, 7 év)',
|
|
||||||
'Shoplifting (/yr, 2y)': 'Bolti lopás (évente, 2 év)',
|
|
||||||
'Bicycle theft (/yr, 7y)': 'Kerékpárlopás (évente, 7 év)',
|
|
||||||
'Bicycle theft (/yr, 2y)': 'Kerékpárlopás (évente, 2 év)',
|
|
||||||
'Drugs (/yr, 7y)': 'Kábítószer (évente, 7 év)',
|
|
||||||
'Drugs (/yr, 2y)': 'Kábítószer (évente, 2 év)',
|
|
||||||
'Possession of weapons (/yr, 7y)': 'Fegyverbirtoklás (évente, 7 év)',
|
|
||||||
'Possession of weapons (/yr, 2y)': 'Fegyverbirtoklás (évente, 2 év)',
|
|
||||||
'Public order (/yr, 7y)': 'Közrend (évente, 7 év)',
|
|
||||||
'Public order (/yr, 2y)': 'Közrend (évente, 2 év)',
|
|
||||||
'Other crime (/yr, 7y)': 'Egyéb bűncselekmény (évente, 7 év)',
|
|
||||||
'Other crime (/yr, 2y)': 'Egyéb bűncselekmény (évente, 2 év)',
|
|
||||||
// Csak a bűncselekménytípus megnevezések (típusonkénti bontás + rekordlista).
|
|
||||||
// (A „Burglary” már szerepel lent a konkrét bűncselekmények között.)
|
|
||||||
'Violence and sexual offences': 'Erőszak és szexuális bűncselekmények',
|
|
||||||
Robbery: 'Rablás',
|
|
||||||
'Vehicle crime': 'Járművel kapcsolatos bűncselekmények',
|
|
||||||
'Anti-social behaviour': 'Közösségellenes magatartás',
|
|
||||||
'Criminal damage and arson': 'Rongálás és gyújtogatás',
|
|
||||||
'Other theft': 'Egyéb lopás',
|
|
||||||
'Theft from the person': 'Személytől történő lopás',
|
|
||||||
Shoplifting: 'Bolti lopás',
|
|
||||||
'Bicycle theft': 'Kerékpárlopás',
|
|
||||||
Drugs: 'Kábítószer',
|
|
||||||
'Possession of weapons': 'Fegyverbirtoklás',
|
|
||||||
'Public order': 'Közrend',
|
|
||||||
'Other crime': 'Egyéb bűncselekmény',
|
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': 'Medián életkor',
|
'Median age': 'Medián életkor',
|
||||||
|
|
@ -1849,7 +1678,6 @@ const hu: Translations = {
|
||||||
'Bus station': 'Buszpályaudvar',
|
'Bus station': 'Buszpályaudvar',
|
||||||
'Taxi rank': 'Taxiállomás',
|
'Taxi rank': 'Taxiállomás',
|
||||||
'Tube station': 'Londoni metróállomás',
|
'Tube station': 'Londoni metróállomás',
|
||||||
'DLR station': 'DLR-állomás',
|
|
||||||
'Tram & Metro stop': 'Villamos- és metrómegálló',
|
'Tram & Metro stop': 'Villamos- és metrómegálló',
|
||||||
Café: 'Kávézó',
|
Café: 'Kávézó',
|
||||||
Restaurant: 'Étterem',
|
Restaurant: 'Étterem',
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,6 @@ const zh: Translations = {
|
||||||
closePane: '关闭面板',
|
closePane: '关闭面板',
|
||||||
yes: '是',
|
yes: '是',
|
||||||
no: '否',
|
no: '否',
|
||||||
captions: '字幕',
|
|
||||||
sqm: '平方米',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Header / Nav ───────────────────────────────────
|
// ── Header / Nav ───────────────────────────────────
|
||||||
|
|
@ -576,17 +574,13 @@ const zh: Translations = {
|
||||||
backToLogin: '返回登录',
|
backToLogin: '返回登录',
|
||||||
registerConsent:
|
registerConsent:
|
||||||
'创建账户即表示您同意我们的<terms>服务条款</terms>和<privacy>隐私政策</privacy>。',
|
'创建账户即表示您同意我们的<terms>服务条款</terms>和<privacy>隐私政策</privacy>。',
|
||||||
loginFailed: '登录失败',
|
|
||||||
registrationFailed: '注册失败',
|
|
||||||
oauthFailed: 'OAuth 登录失败',
|
|
||||||
passwordResetFailed: '密码重置请求失败',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
upgrade: {
|
upgrade: {
|
||||||
title: '解锁无限筛选',
|
title: '找到每一个匹配的邮编',
|
||||||
description:
|
description:
|
||||||
'免费账户每次最多可同时组合 3 个筛选条件。获取终身访问权限,即可在英格兰每个邮编和每个社区叠加无限多个筛选条件。一次付款,永久使用。',
|
'您目前正在浏览演示区域。获取终身访问权限后,可查看英格兰每个邮编、每项筛选条件和每个社区。一次付款,永久使用。',
|
||||||
free: '免费',
|
free: '免费',
|
||||||
freeForEarly: '早期用户免费。无需信用卡。',
|
freeForEarly: '早期用户免费。无需信用卡。',
|
||||||
oneTimePayment: '一次性付款。终身访问。',
|
oneTimePayment: '一次性付款。终身访问。',
|
||||||
|
|
@ -595,7 +589,10 @@ const zh: Translations = {
|
||||||
upgradeFor: '以 {{price}} 升级',
|
upgradeFor: '以 {{price}} 升级',
|
||||||
registerAndUpgrade: '注册并升级',
|
registerAndUpgrade: '注册并升级',
|
||||||
alreadyHaveAccount: '已有账户?请登录',
|
alreadyHaveAccount: '已有账户?请登录',
|
||||||
continueFree: '继续使用 3 个筛选条件',
|
continueWithDemo: '继续使用演示版',
|
||||||
|
backToSharedArea: '返回共享区域',
|
||||||
|
sharedAreaDescription:
|
||||||
|
'您正在查看一个共享区域。若要继续探索更大范围,请获取终身访问权限,覆盖英格兰每个邮编、每项筛选条件和每个社区。',
|
||||||
checkoutFailed: '结账失败',
|
checkoutFailed: '结账失败',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -670,11 +667,6 @@ const zh: Translations = {
|
||||||
outstanding: '优秀',
|
outstanding: '优秀',
|
||||||
distance: '距离',
|
distance: '距离',
|
||||||
crimeType: '犯罪类别',
|
crimeType: '犯罪类别',
|
||||||
qualificationLevel: '学历水平',
|
|
||||||
tenureType: '住房产权类型',
|
|
||||||
crimeWindow: '统计周期',
|
|
||||||
crimeWindow7y: '7 年',
|
|
||||||
crimeWindow2y: '2 年',
|
|
||||||
ethnicity: '族裔',
|
ethnicity: '族裔',
|
||||||
poiType: '地点类型',
|
poiType: '地点类型',
|
||||||
party: '政党',
|
party: '政党',
|
||||||
|
|
@ -763,7 +755,6 @@ const zh: Translations = {
|
||||||
generatingFilters: '正在生成筛选条件...',
|
generatingFilters: '正在生成筛选条件...',
|
||||||
refiningResults: '正在优化结果...',
|
refiningResults: '正在优化结果...',
|
||||||
weeklyLimitReached: '您已达到每周 AI 使用上限。下周将自动重置。',
|
weeklyLimitReached: '您已达到每周 AI 使用上限。下周将自动重置。',
|
||||||
generateFailed: '生成筛选条件失败',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Legend ─────────────────────────────────────
|
// ── Map Legend ─────────────────────────────────────
|
||||||
|
|
@ -783,14 +774,6 @@ const zh: Translations = {
|
||||||
ogSchools: '学校',
|
ogSchools: '学校',
|
||||||
ogCrimeStats: '治安数据',
|
ogCrimeStats: '治安数据',
|
||||||
ogTransport: '交通',
|
ogTransport: '交通',
|
||||||
basemap: { standard: '地图', satellite: '卫星' },
|
|
||||||
error: {
|
|
||||||
heading: '地图遇到了问题',
|
|
||||||
body: '当您浏览器的图形上下文被中断时,可能会出现这种情况。',
|
|
||||||
reload: '重新加载地图',
|
|
||||||
},
|
|
||||||
actualListings: { label: '房源', show: '显示实际房源', hide: '隐藏实际房源' },
|
|
||||||
poi: { zoomInToSeeDetails: '放大以查看详情' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Properties Pane ────────────────────────────────
|
// ── Properties Pane ────────────────────────────────
|
||||||
|
|
@ -866,20 +849,6 @@ const zh: Translations = {
|
||||||
crimeDataEnds: '该地区的警方数据截至{{year}}年',
|
crimeDataEnds: '该地区的警方数据截至{{year}}年',
|
||||||
residents: '居民',
|
residents: '居民',
|
||||||
residentsTooltip: '常住居民(ONS 2021 年人口普查)',
|
residentsTooltip: '常住居民(ONS 2021 年人口普查)',
|
||||||
crimeWindow7y: '过去 7 年',
|
|
||||||
crimeWindow2y: '过去 2 年',
|
|
||||||
crimeWindowLabel: '时间范围',
|
|
||||||
crimeCardTitle: '{{name}}(每年)',
|
|
||||||
crimeRecordsToggle: '显示单起犯罪',
|
|
||||||
crimeRecordsHide: '隐藏单起犯罪',
|
|
||||||
crimeRecordsCount: '{{count}} 起犯罪',
|
|
||||||
crimeRecordsLoading: '正在加载犯罪记录…',
|
|
||||||
crimeRecordsEmpty: '没有单起犯罪记录',
|
|
||||||
crimeRecordsError: '无法加载犯罪记录',
|
|
||||||
crimeRecordsLoadMore: '加载更多',
|
|
||||||
crimeNoLocation: '地点未公开',
|
|
||||||
crimeOutcomeUnknown: '结果未记录',
|
|
||||||
crimeYearPointTooltip: '{{year}}: {{rate}}/年',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Street View ────────────────────────────────────
|
// ── Street View ────────────────────────────────────
|
||||||
|
|
@ -904,7 +873,6 @@ const zh: Translations = {
|
||||||
searchOn: '在 {{radius}} 范围内搜索',
|
searchOn: '在 {{radius}} 范围内搜索',
|
||||||
exact: '精确',
|
exact: '精确',
|
||||||
outcodeNotRecognised: '无法识别该邮编区域',
|
outcodeNotRecognised: '无法识别该邮编区域',
|
||||||
radiusMi: '{{count}} 英里范围内',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Location Search ────────────────────────────────
|
// ── Location Search ────────────────────────────────
|
||||||
|
|
@ -919,6 +887,14 @@ const zh: Translations = {
|
||||||
geolocationFailed: '无法确定您的位置',
|
geolocationFailed: '无法确定您的位置',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
demoLocation: {
|
||||||
|
title: '探索您的区域',
|
||||||
|
body: '想查看您周围的房产数据吗?使用您的位置,或从金丝雀码头开始演示。',
|
||||||
|
useLocation: '使用我的位置',
|
||||||
|
locating: '正在定位…',
|
||||||
|
useDefault: '显示金丝雀码头',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Mobile Drawer ──────────────────────────────────
|
// ── Mobile Drawer ──────────────────────────────────
|
||||||
mobileDrawer: {
|
mobileDrawer: {
|
||||||
closeDrawer: '关闭侧栏',
|
closeDrawer: '关闭侧栏',
|
||||||
|
|
@ -953,7 +929,6 @@ const zh: Translations = {
|
||||||
showcaseJourneyRoutes: '出行路线',
|
showcaseJourneyRoutes: '出行路线',
|
||||||
showcaseNearby: '附近 {{value}} 个',
|
showcaseNearby: '附近 {{value}} 个',
|
||||||
showcasePoliticalVoteShare: '政党得票份额',
|
showcasePoliticalVoteShare: '政党得票份额',
|
||||||
showcaseGe2024: '2024 大选',
|
|
||||||
showcaseLotsMore: '……还有更多',
|
showcaseLotsMore: '……还有更多',
|
||||||
showcaseMinutes: '{{count}} 分钟',
|
showcaseMinutes: '{{count}} 分钟',
|
||||||
showcaseSendShortlist: '发送候选名单',
|
showcaseSendShortlist: '发送候选名单',
|
||||||
|
|
@ -1318,7 +1293,7 @@ const zh: Translations = {
|
||||||
'终身访问就是一次付款后,您的账户在 Perfect Postcode 服务存续期间都能持续访问付费地图。它不是按月或按年订阅,且涵盖后续数据更新。本次找房可以用,事后回来再看也行;将来再次搬家时,访问权限依然有效。',
|
'终身访问就是一次付款后,您的账户在 Perfect Postcode 服务存续期间都能持续访问付费地图。它不是按月或按年订阅,且涵盖后续数据更新。本次找房可以用,事后回来再看也行;将来再次搬家时,访问权限依然有效。',
|
||||||
faqPricing3Q: '免费版能用哪些功能?',
|
faqPricing3Q: '免费版能用哪些功能?',
|
||||||
faqPricing3A:
|
faqPricing3A:
|
||||||
'免费用户可以使用覆盖整个英格兰的完整 Perfect Postcode 地图——每个邮编、每项功能——并且每次最多可应用 3 个筛选条件。获取终身访问权限即可解除该限制,让您叠加无限多个筛选条件。',
|
'免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内体验全部功能。要访问英格兰其他地区的数据,则需获取终身访问权限。',
|
||||||
|
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: '如何在地图上预览筛选条件?',
|
faqTips1Q: '如何在地图上预览筛选条件?',
|
||||||
|
|
@ -1365,8 +1340,6 @@ const zh: Translations = {
|
||||||
noShareLinksYet: '暂无已分享的链接',
|
noShareLinksYet: '暂无已分享的链接',
|
||||||
copyShareLink: '复制分享链接',
|
copyShareLink: '复制分享链接',
|
||||||
clicksLabel: '点击',
|
clicksLabel: '点击',
|
||||||
fetchShareLinksError: '获取分享链接失败',
|
|
||||||
updateNewsletterError: '更新新闻邮件设置失败',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Saved Page ─────────────────────────────────────
|
// ── Saved Page ─────────────────────────────────────
|
||||||
|
|
@ -1380,12 +1353,6 @@ const zh: Translations = {
|
||||||
deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。',
|
deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。',
|
||||||
isBeingUpdated: '正在更新 <strong>{{name}}</strong>',
|
isBeingUpdated: '正在更新 <strong>{{name}}</strong>',
|
||||||
updating: '更新中...',
|
updating: '更新中...',
|
||||||
loadFailed: '加载搜索失败',
|
|
||||||
saveFailed: '保存搜索失败',
|
|
||||||
deleteFailed: '删除搜索失败',
|
|
||||||
updateNotesFailed: '更新备注失败',
|
|
||||||
updateNameFailed: '更新名称失败',
|
|
||||||
updateFailed: '更新搜索失败',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invites Page ───────────────────────────────────
|
// ── Invites Page ───────────────────────────────────
|
||||||
|
|
@ -1405,7 +1372,6 @@ const zh: Translations = {
|
||||||
created: '创建时间',
|
created: '创建时间',
|
||||||
redeemed: '已兑换',
|
redeemed: '已兑换',
|
||||||
pending: '待兑换',
|
pending: '待兑换',
|
||||||
createInviteError: '创建邀请失败',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Invite Page ────────────────────────────────────
|
// ── Invite Page ────────────────────────────────────
|
||||||
|
|
@ -1432,105 +1398,6 @@ const zh: Translations = {
|
||||||
youAlreadyHaveLicense: '您已拥有授权',
|
youAlreadyHaveLicense: '您已拥有授权',
|
||||||
accountHasFullAccess: '您的账户已拥有完整访问权限。',
|
accountHasFullAccess: '您的账户已拥有完整访问权限。',
|
||||||
failedToValidate: '验证邀请链接失败',
|
failedToValidate: '验证邀请链接失败',
|
||||||
redeemFailed: '兑换邀请失败',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Errors ─────────────────────────────────────────
|
|
||||||
errors: {
|
|
||||||
appCrashTitle: '出错了',
|
|
||||||
appCrashBody: '请刷新页面后重试。',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Listing ────────────────────────────────────────
|
|
||||||
listing: {
|
|
||||||
viewed: '已查看',
|
|
||||||
openListing: '打开房源',
|
|
||||||
listings: '套房源',
|
|
||||||
listing: '房源',
|
|
||||||
showingOf: '显示 {{count}} 套中的 {{visible}} 套',
|
|
||||||
groupedNear: '按此地图位置聚合',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Overlays ───────────────────────────────────────
|
|
||||||
overlays: {
|
|
||||||
heading: '叠加图层',
|
|
||||||
baseMap: '底图',
|
|
||||||
colourOpacity: '颜色不透明度',
|
|
||||||
dataOverlays: '数据叠加层',
|
|
||||||
about: '关于{{name}}',
|
|
||||||
zoomWarning: '请进一步放大以查看所选叠加层。',
|
|
||||||
zoomWarning_other: '请进一步放大以查看所选叠加层。',
|
|
||||||
noise: {
|
|
||||||
label: '噪音',
|
|
||||||
detail:
|
|
||||||
'Defra 第 4 轮战略噪音地图(2022 年),综合道路、铁路和机场来源。数值采用 EU 标准的 Lden 指标(昼-暮-夜 24 小时加权平均),在距地面 4 米高度的 10 米网格上建模。颜色越亮表示建模噪音越高。依据 Open Government Licence v3.0 授权。',
|
|
||||||
},
|
|
||||||
crimeHotspots: {
|
|
||||||
label: '犯罪热点',
|
|
||||||
detail:
|
|
||||||
'基于 police.uk 近几个月公布的街道级犯罪数据,在客户端生成的热力图。police.uk 的坐标是经过匿名化、对齐到网格的点位,而非确切的案发地点,因此该热力图应被视为相对密度的近似呈现,而非确切的案件分布图。',
|
|
||||||
},
|
|
||||||
treesOutsideWoodlands: {
|
|
||||||
label: '树木与林地',
|
|
||||||
detail:
|
|
||||||
'Forest Research Trees Outside Woodland (TOW) v1 树冠多边形——单棵树木和树木群——与 TOW 有意排除的 National Forest Inventory (NFI) 林地地块(≥0.5 公顷)合并。二者共同覆盖行道树和真正的林地。多边形的不透明度随树冠面积变化。',
|
|
||||||
},
|
|
||||||
propertyBorders: {
|
|
||||||
label: '房产边界',
|
|
||||||
detail:
|
|
||||||
'HM Land Registry INSPIRE Index Polygons——英格兰和威尔士已登记的自由保有房产的位置和大致范围,以街道级的轮廓线绘制。这些是仅供参考的“一般边界”,并非房产的确切法律边界,且不包含仅租赁权益和未登记土地(大约覆盖 85–90% 的自由保有土地)。本信息受 Crown copyright and database rights 2026 保护,经 HM Land Registry 许可复制。这些多边形(包括相关几何信息,即 x、y 坐标)受 Crown copyright and database rights 2026 Ordnance Survey AC0000851063 保护。依据 Open Government Licence v3.0 授权。',
|
|
||||||
},
|
|
||||||
newDevelopments: {
|
|
||||||
label: '新开发项目',
|
|
||||||
detail:
|
|
||||||
'前瞻性的新住房供应管线。蓝色标记表示位于法定 MHCLG Brownfield Land 登记册上的地块——每个都附有预估的净住宅容量和规划许可状态——以及 Homes England Land Hub 处置地块。这些显示了新住房的规划地点,通常比它们出现在 EPC 或成交记录中早数年。住宅数量为容量估算值,而非承诺,登记册上的地块是机会,而非建设的保证。依据 Open Government Licence v3.0 授权。',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Crime Types ────────────────────────────────────
|
|
||||||
crimeTypes: {
|
|
||||||
violenceAndSexualOffences: '暴力与性侵犯',
|
|
||||||
antiSocialBehaviour: '反社会行为',
|
|
||||||
criminalDamageAndArson: '故意毁坏与纵火',
|
|
||||||
publicOrder: '扰乱公共秩序',
|
|
||||||
shoplifting: '入店行窃',
|
|
||||||
vehicleCrime: '车辆犯罪',
|
|
||||||
burglary: '入室盗窃',
|
|
||||||
otherTheft: '其他盗窃',
|
|
||||||
theftFromThePerson: '扒窃',
|
|
||||||
bicycleTheft: '自行车盗窃',
|
|
||||||
drugs: '毒品',
|
|
||||||
robbery: '抢劫',
|
|
||||||
possessionOfWeapons: '持有武器',
|
|
||||||
otherCrime: '其他犯罪',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Journey ────────────────────────────────────────
|
|
||||||
journey: {
|
|
||||||
bus: '公交',
|
|
||||||
lineSuffix: '线',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── POI Popup ──────────────────────────────────────
|
|
||||||
poiPopup: {
|
|
||||||
school: {
|
|
||||||
capacity: '容量',
|
|
||||||
type: '类型',
|
|
||||||
ages: '年龄段',
|
|
||||||
gender: '性别',
|
|
||||||
pupilsLabel: '学生',
|
|
||||||
pupils: '名学生',
|
|
||||||
freeMeal: '免费餐',
|
|
||||||
ofsted: 'Ofsted',
|
|
||||||
sixthForm: '六年级',
|
|
||||||
religion: '宗教',
|
|
||||||
admissions: '招生',
|
|
||||||
trust: '学校联盟',
|
|
||||||
address: '地址',
|
|
||||||
localAuthority: 'LA',
|
|
||||||
head: '校长',
|
|
||||||
website: '网站',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Format / Time ──────────────────────────────────
|
// ── Format / Time ──────────────────────────────────
|
||||||
|
|
@ -1570,9 +1437,6 @@ const zh: Translations = {
|
||||||
step5Content: '查看区域统计、直方图和单个房产记录:价格、建筑面积、能效评级等。',
|
step5Content: '查看区域统计、直方图和单个房产记录:价格、建筑面积、能效评级等。',
|
||||||
step6Title: '附近有什么?',
|
step6Title: '附近有什么?',
|
||||||
step6Content: '在地图上开启学校、商店、车站、公园和餐厅图层,查看周边设施。',
|
step6Content: '在地图上开启学校、商店、车站、公园和餐厅图层,查看周边设施。',
|
||||||
step7Title: '叠加更多信息',
|
|
||||||
step7Content:
|
|
||||||
'在地图上开启犯罪热点、噪音、树木覆盖、房产边界和新建住宅项目等叠加图层,并按需切换底图。',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Server-derived values ──────────────────────────
|
// ── Server-derived values ──────────────────────────
|
||||||
|
|
@ -1629,54 +1493,23 @@ const zh: Translations = {
|
||||||
'Housing Conditions Score': '住房状况得分',
|
'Housing Conditions Score': '住房状况得分',
|
||||||
'Air Quality and Road Safety Score': '空气质量与道路安全得分',
|
'Air Quality and Road Safety Score': '空气质量与道路安全得分',
|
||||||
|
|
||||||
// ─ Feature names (Crime) — 按人口标准化的比率,7 年与 2 年 ─
|
// ─ Feature names (Crime) ─
|
||||||
'Serious crime (/yr, 7y)': '严重犯罪(每年,7 年)',
|
'Serious crime (avg/yr)': '严重犯罪(密度)',
|
||||||
'Serious crime (/yr, 2y)': '严重犯罪(每年,2 年)',
|
'Minor crime (avg/yr)': '轻微犯罪(密度)',
|
||||||
'Minor crime (/yr, 7y)': '轻微犯罪(每年,7 年)',
|
'Violence and sexual offences (avg/yr)': '暴力和性犯罪(密度)',
|
||||||
'Minor crime (/yr, 2y)': '轻微犯罪(每年,2 年)',
|
'Burglary (avg/yr)': '入室盗窃(密度)',
|
||||||
'Violence and sexual offences (/yr, 7y)': '暴力和性犯罪(每年,7 年)',
|
'Robbery (avg/yr)': '抢劫(密度)',
|
||||||
'Violence and sexual offences (/yr, 2y)': '暴力和性犯罪(每年,2 年)',
|
'Vehicle crime (avg/yr)': '车辆犯罪(密度)',
|
||||||
'Burglary (/yr, 7y)': '入室盗窃(每年,7 年)',
|
'Anti-social behaviour (avg/yr)': '反社会行为(密度)',
|
||||||
'Burglary (/yr, 2y)': '入室盗窃(每年,2 年)',
|
'Criminal damage and arson (avg/yr)': '刑事毁坏和纵火(密度)',
|
||||||
'Robbery (/yr, 7y)': '抢劫(每年,7 年)',
|
'Other theft (avg/yr)': '其他盗窃(密度)',
|
||||||
'Robbery (/yr, 2y)': '抢劫(每年,2 年)',
|
'Theft from the person (avg/yr)': '人身盗窃(密度)',
|
||||||
'Vehicle crime (/yr, 7y)': '车辆犯罪(每年,7 年)',
|
'Shoplifting (avg/yr)': '商店盗窃(密度)',
|
||||||
'Vehicle crime (/yr, 2y)': '车辆犯罪(每年,2 年)',
|
'Bicycle theft (avg/yr)': '自行车盗窃(密度)',
|
||||||
'Anti-social behaviour (/yr, 7y)': '反社会行为(每年,7 年)',
|
'Drugs (avg/yr)': '毒品犯罪(密度)',
|
||||||
'Anti-social behaviour (/yr, 2y)': '反社会行为(每年,2 年)',
|
'Possession of weapons (avg/yr)': '非法持有武器(密度)',
|
||||||
'Criminal damage and arson (/yr, 7y)': '刑事毁坏和纵火(每年,7 年)',
|
'Public order (avg/yr)': '扰乱公共秩序(密度)',
|
||||||
'Criminal damage and arson (/yr, 2y)': '刑事毁坏和纵火(每年,2 年)',
|
'Other crime (avg/yr)': '其他犯罪(密度)',
|
||||||
'Other theft (/yr, 7y)': '其他盗窃(每年,7 年)',
|
|
||||||
'Other theft (/yr, 2y)': '其他盗窃(每年,2 年)',
|
|
||||||
'Theft from the person (/yr, 7y)': '人身盗窃(每年,7 年)',
|
|
||||||
'Theft from the person (/yr, 2y)': '人身盗窃(每年,2 年)',
|
|
||||||
'Shoplifting (/yr, 7y)': '商店盗窃(每年,7 年)',
|
|
||||||
'Shoplifting (/yr, 2y)': '商店盗窃(每年,2 年)',
|
|
||||||
'Bicycle theft (/yr, 7y)': '自行车盗窃(每年,7 年)',
|
|
||||||
'Bicycle theft (/yr, 2y)': '自行车盗窃(每年,2 年)',
|
|
||||||
'Drugs (/yr, 7y)': '毒品犯罪(每年,7 年)',
|
|
||||||
'Drugs (/yr, 2y)': '毒品犯罪(每年,2 年)',
|
|
||||||
'Possession of weapons (/yr, 7y)': '非法持有武器(每年,7 年)',
|
|
||||||
'Possession of weapons (/yr, 2y)': '非法持有武器(每年,2 年)',
|
|
||||||
'Public order (/yr, 7y)': '扰乱公共秩序(每年,7 年)',
|
|
||||||
'Public order (/yr, 2y)': '扰乱公共秩序(每年,2 年)',
|
|
||||||
'Other crime (/yr, 7y)': '其他犯罪(每年,7 年)',
|
|
||||||
'Other crime (/yr, 2y)': '其他犯罪(每年,2 年)',
|
|
||||||
// 仅犯罪类型标签(按类型细分 + 记录列表)。
|
|
||||||
// (“Burglary” 已在下方具体犯罪中定义。)
|
|
||||||
'Violence and sexual offences': '暴力和性犯罪',
|
|
||||||
Robbery: '抢劫',
|
|
||||||
'Vehicle crime': '车辆犯罪',
|
|
||||||
'Anti-social behaviour': '反社会行为',
|
|
||||||
'Criminal damage and arson': '刑事毁坏和纵火',
|
|
||||||
'Other theft': '其他盗窃',
|
|
||||||
'Theft from the person': '人身盗窃',
|
|
||||||
Shoplifting: '商店盗窃',
|
|
||||||
'Bicycle theft': '自行车盗窃',
|
|
||||||
Drugs: '毒品犯罪',
|
|
||||||
'Possession of weapons': '非法持有武器',
|
|
||||||
'Public order': '扰乱公共秩序',
|
|
||||||
'Other crime': '其他犯罪',
|
|
||||||
|
|
||||||
// ─ Feature names (Neighbours) ─
|
// ─ Feature names (Neighbours) ─
|
||||||
'Median age': '中位年龄',
|
'Median age': '中位年龄',
|
||||||
|
|
@ -1764,7 +1597,6 @@ const zh: Translations = {
|
||||||
'Bus station': '公交枢纽',
|
'Bus station': '公交枢纽',
|
||||||
'Taxi rank': '出租车站',
|
'Taxi rank': '出租车站',
|
||||||
'Tube station': '伦敦地铁站',
|
'Tube station': '伦敦地铁站',
|
||||||
'DLR station': 'DLR 轻轨站',
|
|
||||||
'Tram & Metro stop': '有轨电车与城市轨道站',
|
'Tram & Metro stop': '有轨电车与城市轨道站',
|
||||||
Café: '咖啡馆',
|
Café: '咖啡馆',
|
||||||
Restaurant: '餐厅',
|
Restaurant: '餐厅',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { i18nReady } from './i18n';
|
import { i18nReady } from './i18n';
|
||||||
import { BugsinkErrorBoundary, initBugsink } from './lib/bugsink';
|
import { BugsinkErrorBoundary, initBugsink } from './lib/bugsink';
|
||||||
|
|
@ -15,17 +14,12 @@ if (!container) {
|
||||||
const root = container;
|
const root = container;
|
||||||
|
|
||||||
function AppErrorFallback() {
|
function AppErrorFallback() {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
|
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
||||||
{t('errors.appCrashTitle', { defaultValue: 'Something went wrong' })}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
|
||||||
{t('errors.appCrashBody', {
|
Refresh the page to try again.
|
||||||
defaultValue: 'Refresh the page to try again.',
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { createSchoolFilterKey } from './school-filter';
|
||||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||||
import { createElectionVoteShareFilterKey } from './election-filter';
|
import { createElectionVoteShareFilterKey } from './election-filter';
|
||||||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||||
import { createQualificationFilterKey } from './qualification-filter';
|
|
||||||
import { createTenureFilterKey } from './tenure-filter';
|
|
||||||
import {
|
import {
|
||||||
POI_COUNT_2KM_FILTER_NAME,
|
POI_COUNT_2KM_FILTER_NAME,
|
||||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||||
|
|
@ -100,19 +98,19 @@ describe('api utilities', () => {
|
||||||
|
|
||||||
it('serializes specific crime filters using their selected backend crime feature', () => {
|
it('serializes specific crime filters using their selected backend crime feature', () => {
|
||||||
const features: FeatureMeta[] = [
|
const features: FeatureMeta[] = [
|
||||||
{ name: 'Burglary (/yr, 7y)', type: 'numeric', min: 0, max: 20 },
|
{ name: 'Burglary (avg/yr)', type: 'numeric', min: 0, max: 20 },
|
||||||
{ name: 'Vehicle crime (/yr, 7y)', type: 'numeric', min: 0, max: 30 },
|
{ name: 'Vehicle crime (avg/yr)', type: 'numeric', min: 0, max: 30 },
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
buildFilterString(
|
buildFilterString(
|
||||||
{
|
{
|
||||||
[createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 1)]: [0, 5],
|
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 1)]: [0, 5],
|
||||||
[createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 2)]: [1, 10],
|
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2)]: [1, 10],
|
||||||
},
|
},
|
||||||
features
|
features
|
||||||
)
|
)
|
||||||
).toBe('Burglary (/yr, 7y):0:5;;Vehicle crime (/yr, 7y):1:10');
|
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes election vote-share filters using their selected backend party feature', () => {
|
it('serializes election vote-share filters using their selected backend party feature', () => {
|
||||||
|
|
@ -146,40 +144,6 @@ describe('api utilities', () => {
|
||||||
).toBe('% White:20:80');
|
).toBe('% White:20:80');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes qualification filters using their selected backend band feature', () => {
|
|
||||||
const features: FeatureMeta[] = [
|
|
||||||
{ name: '% Degree or higher', type: 'numeric', min: 0, max: 100 },
|
|
||||||
{ name: '% No qualifications', type: 'numeric', min: 0, max: 100 },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(
|
|
||||||
buildFilterString(
|
|
||||||
{
|
|
||||||
[createQualificationFilterKey('% Degree or higher', 1)]: [20, 60],
|
|
||||||
[createQualificationFilterKey('% No qualifications', 2)]: [0, 25],
|
|
||||||
},
|
|
||||||
features
|
|
||||||
)
|
|
||||||
).toBe('% Degree or higher:20:60;;% No qualifications:0:25');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serializes tenure filters using their selected backend band feature', () => {
|
|
||||||
const features: FeatureMeta[] = [
|
|
||||||
{ name: '% Owner occupied', type: 'numeric', min: 0, max: 100 },
|
|
||||||
{ name: '% Private rent', type: 'numeric', min: 0, max: 100 },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(
|
|
||||||
buildFilterString(
|
|
||||||
{
|
|
||||||
[createTenureFilterKey('% Owner occupied', 1)]: [20, 60],
|
|
||||||
[createTenureFilterKey('% Private rent', 2)]: [0, 25],
|
|
||||||
},
|
|
||||||
features
|
|
||||||
)
|
|
||||||
).toBe('% Owner occupied:20:60;;% Private rent:0:25');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serializes amenity distance filters using their selected backend feature', () => {
|
it('serializes amenity distance filters using their selected backend feature', () => {
|
||||||
const features: FeatureMeta[] = [
|
const features: FeatureMeta[] = [
|
||||||
{ name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 },
|
{ name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 },
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import type { FeatureMeta, FeatureFilters, CrimeRecordsResponse } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||||
import pb from './pocketbase';
|
import pb from './pocketbase';
|
||||||
import { getSchoolBackendFeatureName } from './school-filter';
|
import { getSchoolBackendFeatureName } from './school-filter';
|
||||||
import { getSpecificCrimeFeatureName } from './crime-filter';
|
import { getSpecificCrimeFeatureName } from './crime-filter';
|
||||||
import { getCrimeSeverityFeatureName } from './crime-severity-filter';
|
|
||||||
import { getElectionVoteShareFeatureName } from './election-filter';
|
import { getElectionVoteShareFeatureName } from './election-filter';
|
||||||
import { getEthnicityFeatureName } from './ethnicity-filter';
|
import { getEthnicityFeatureName } from './ethnicity-filter';
|
||||||
import { getQualificationFeatureName } from './qualification-filter';
|
|
||||||
import { getTenureFeatureName } from './tenure-filter';
|
|
||||||
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
||||||
|
|
||||||
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
|
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
|
||||||
|
|
@ -41,9 +38,78 @@ export function authHeaders(init?: RequestInit): RequestInit {
|
||||||
return { ...init, headers: { ...existing, ...headers } };
|
return { ...init, headers: { ...existing, ...headers } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Demo location -----------------------------------------------------------
|
||||||
|
// The visitor's chosen demo centre (their GPS location, with consent) is sent to
|
||||||
|
// the server on every API request as demoLat/demoLon so the server can build the
|
||||||
|
// free-zone box around it. Held module-side and injected by apiUrl(), so every
|
||||||
|
// gated data call carries it without threading it through each hook. Unset when
|
||||||
|
// the visitor declines (the server then defaults to Canary Wharf).
|
||||||
|
|
||||||
|
const DEMO_CENTER_KEY = 'demoCenter';
|
||||||
|
|
||||||
|
export type DemoCenter = { lat: number; lon: number };
|
||||||
|
|
||||||
|
let demoCenter: DemoCenter | null = null;
|
||||||
|
|
||||||
|
/** Endpoints whose access is gated by the demo free zone — only these need the
|
||||||
|
* visitor's demo centre, so we keep the coarse location out of every other URL. */
|
||||||
|
const DEMO_GATED_ENDPOINTS = new Set([
|
||||||
|
'hexagons',
|
||||||
|
'postcodes',
|
||||||
|
'filter-counts',
|
||||||
|
'hexagon-stats',
|
||||||
|
'postcode-stats',
|
||||||
|
'postcode-properties',
|
||||||
|
'hexagon-properties',
|
||||||
|
'journey',
|
||||||
|
'actual-listings',
|
||||||
|
'developments',
|
||||||
|
'export',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Set (or clear) the demo centre sent on subsequent gated API requests. */
|
||||||
|
export function setDemoCenter(center: DemoCenter | null): void {
|
||||||
|
demoCenter = center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the visitor's demo-location choice for this browser session. */
|
||||||
|
export function persistDemoChoice(value: DemoCenter | 'declined'): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
DEMO_CENTER_KEY,
|
||||||
|
value === 'declined' ? 'declined' : JSON.stringify(value)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private mode / SSR) — re-prompting is fine.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read this session's demo-location choice: coords, 'declined', or null (unset). */
|
||||||
|
export function readDemoChoice(): DemoCenter | 'declined' | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(DEMO_CENTER_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw === 'declined') return 'declined';
|
||||||
|
const parsed = JSON.parse(raw) as Partial<DemoCenter>;
|
||||||
|
if (typeof parsed?.lat === 'number' && typeof parsed?.lon === 'number') {
|
||||||
|
return { lat: parsed.lat, lon: parsed.lon };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed/unavailable storage.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
||||||
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
||||||
const query = params?.toString();
|
const merged = new URLSearchParams(params);
|
||||||
|
if (demoCenter && DEMO_GATED_ENDPOINTS.has(endpoint)) {
|
||||||
|
// Coarsen to ~1 km (the demo free-zone box is ~25 km wide) so a precise location
|
||||||
|
// never reaches the server or its access logs.
|
||||||
|
merged.set('demoLat', demoCenter.lat.toFixed(2));
|
||||||
|
merged.set('demoLon', demoCenter.lon.toFixed(2));
|
||||||
|
}
|
||||||
|
const query = merged.toString();
|
||||||
return query ? `${path}?${query}` : path;
|
return query ? `${path}?${query}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,26 +186,6 @@ export async function shortenUrl(params: string, language?: string): Promise<str
|
||||||
return `${window.location.origin}${data.url}`;
|
return `${window.location.origin}${data.url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch one page of individual crime records for a hexagon or a postcode. The
|
|
||||||
* list is independent of property filters, so no filter string is sent. */
|
|
||||||
export async function fetchCrimeRecords(
|
|
||||||
selection: { h3: string; resolution: number } | { postcode: string },
|
|
||||||
offset: number,
|
|
||||||
shareCode?: string
|
|
||||||
): Promise<CrimeRecordsResponse> {
|
|
||||||
const params = new URLSearchParams({ offset: offset.toString() });
|
|
||||||
if ('postcode' in selection) {
|
|
||||||
params.set('postcode', selection.postcode);
|
|
||||||
} else {
|
|
||||||
params.set('h3', selection.h3);
|
|
||||||
params.set('resolution', selection.resolution.toString());
|
|
||||||
}
|
|
||||||
if (shareCode) params.set('share', shareCode);
|
|
||||||
const res = await fetch(apiUrl('crime-records', params), authHeaders());
|
|
||||||
assertOk(res, 'crime-records');
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFilterString(
|
export function buildFilterString(
|
||||||
filters: FeatureFilters,
|
filters: FeatureFilters,
|
||||||
features: FeatureMeta[],
|
features: FeatureMeta[],
|
||||||
|
|
@ -154,11 +200,8 @@ export function buildFilterString(
|
||||||
const backendName =
|
const backendName =
|
||||||
getSchoolBackendFeatureName(name) ??
|
getSchoolBackendFeatureName(name) ??
|
||||||
getSpecificCrimeFeatureName(name) ??
|
getSpecificCrimeFeatureName(name) ??
|
||||||
getCrimeSeverityFeatureName(name) ??
|
|
||||||
getElectionVoteShareFeatureName(name) ??
|
getElectionVoteShareFeatureName(name) ??
|
||||||
getEthnicityFeatureName(name) ??
|
getEthnicityFeatureName(name) ??
|
||||||
getQualificationFeatureName(name) ??
|
|
||||||
getTenureFeatureName(name) ??
|
|
||||||
getPoiDistanceFeatureName(name) ??
|
getPoiDistanceFeatureName(name) ??
|
||||||
name;
|
name;
|
||||||
const prev = merged.get(backendName);
|
const prev = merged.get(backendName);
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,16 @@ export const MAP_MIN_ZOOM = 5.5;
|
||||||
|
|
||||||
export const BUFFER_MULTIPLIER = 1;
|
export const BUFFER_MULTIPLIER = 1;
|
||||||
|
|
||||||
/** Default initial map view (Canary Wharf), used when no explicit view is in the URL. */
|
/**
|
||||||
|
* Default demo centre (Canary Wharf). Used as the initial map view and the demo
|
||||||
|
* free-zone centre when the visitor declines the "check your area" prompt. When
|
||||||
|
* they consent, their GPS location is used instead (see lib/api `setDemoCenter`).
|
||||||
|
* Keep in sync with FREE_ZONE_FALLBACK_LAT/LON in the Rust server.
|
||||||
|
*/
|
||||||
export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 };
|
export const CANARY_WHARF = { latitude: 51.5054, longitude: -0.0235 };
|
||||||
|
|
||||||
/** 10 Downing Street — geolocation fallback in dev so the "use my location" flow
|
/** 10 Downing Street — geolocation fallback in dev so the GPS flows stay testable
|
||||||
* stays testable without a real fix (e.g. over http, where browsers deny geolocation). */
|
* without a real fix (e.g. over http, where browsers deny geolocation). */
|
||||||
export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 };
|
export const DEV_LOCATION = { latitude: 51.5033635, longitude: -0.1276248 };
|
||||||
|
|
||||||
export const INITIAL_VIEW_STATE: ViewState = {
|
export const INITIAL_VIEW_STATE: ViewState = {
|
||||||
|
|
@ -27,22 +32,8 @@ export const INITIAL_VIEW_STATE: ViewState = {
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Anonymous (logged-out) users can apply at most this many filters at a time. The
|
/** Demo (unlicensed) users can apply at most this many filters simultaneously. */
|
||||||
* full dashboard is otherwise available everywhere — there is no region lock.
|
export const DEMO_MAX_FILTERS = 5;
|
||||||
* Must match the server-side `DEMO_MAX_FILTERS`. */
|
|
||||||
export const DEMO_MAX_FILTERS = 3;
|
|
||||||
|
|
||||||
/** Registered but non-paying users get a higher filter allowance than anonymous
|
|
||||||
* visitors — the carrot for creating a free account. Paying/licensed users are
|
|
||||||
* unlimited. Must match the server-side `REGISTERED_MAX_FILTERS`. */
|
|
||||||
export const REGISTERED_MAX_FILTERS = 5;
|
|
||||||
|
|
||||||
/** The simultaneous-filter cap for a user, or `null` for unlimited (paying/admin).
|
|
||||||
* Anonymous → 3, registered free → 5. */
|
|
||||||
export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): number | null {
|
|
||||||
if (filtersUnlimited) return null;
|
|
||||||
return isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zoom to H3 resolution mapping thresholds.
|
* Zoom to H3 resolution mapping thresholds.
|
||||||
|
|
@ -65,7 +56,7 @@ export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
|
||||||
// past the finest hexagon level so detail appears while still relatively
|
// past the finest hexagon level so detail appears while still relatively
|
||||||
// zoomed out. (Each overlay additionally can't render below its own tile-data
|
// zoomed out. (Each overlay additionally can't render below its own tile-data
|
||||||
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
|
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
|
||||||
export const POSTCODE_ZOOM_THRESHOLD = 13.5;
|
export const POSTCODE_ZOOM_THRESHOLD = 14;
|
||||||
export const POSTCODE_SEARCH_ZOOM = 16;
|
export const POSTCODE_SEARCH_ZOOM = 16;
|
||||||
|
|
||||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||||
|
|
@ -214,7 +205,6 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
||||||
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
|
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
|
||||||
'Off-Licence': '/assets/twemoji/1f377.png',
|
'Off-Licence': '/assets/twemoji/1f377.png',
|
||||||
'Planet Organic': '/assets/poi-icons/logos/planet_organic.svg',
|
'Planet Organic': '/assets/poi-icons/logos/planet_organic.svg',
|
||||||
'DLR station': '/assets/poi-icons/public_transport/london_dlr.svg',
|
|
||||||
'Rail station': '/assets/twemoji/1f686.png',
|
'Rail station': '/assets/twemoji/1f686.png',
|
||||||
School: '/assets/twemoji/1f3eb.png',
|
School: '/assets/twemoji/1f3eb.png',
|
||||||
'Nursery school': '/assets/twemoji/1f9f8.png',
|
'Nursery school': '/assets/twemoji/1f9f8.png',
|
||||||
|
|
@ -274,7 +264,7 @@ export const STACKED_GROUPS: Record<
|
||||||
label: string;
|
label: string;
|
||||||
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
||||||
feature?: string;
|
feature?: string;
|
||||||
/** If set, display this feature's mean as the primary value (e.g. a percentage) instead of the absolute total. */
|
/** If set, display this feature's mean as the primary value (e.g. per-1k rate) instead of the absolute total. */
|
||||||
rateFeature?: string;
|
rateFeature?: string;
|
||||||
/** Suffix shown after the total value (e.g. "avg/yr") */
|
/** Suffix shown after the total value (e.g. "avg/yr") */
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
|
@ -285,30 +275,30 @@ export const STACKED_GROUPS: Record<
|
||||||
Crime: [
|
Crime: [
|
||||||
{
|
{
|
||||||
label: 'Serious crime',
|
label: 'Serious crime',
|
||||||
feature: 'Serious crime (/yr, 7y)',
|
feature: 'Serious crime (avg/yr)',
|
||||||
unit: '',
|
unit: '',
|
||||||
components: [
|
components: [
|
||||||
'Violence and sexual offences (/yr, 7y)',
|
'Violence and sexual offences (avg/yr)',
|
||||||
'Robbery (/yr, 7y)',
|
'Robbery (avg/yr)',
|
||||||
'Burglary (/yr, 7y)',
|
'Burglary (avg/yr)',
|
||||||
'Possession of weapons (/yr, 7y)',
|
'Possession of weapons (avg/yr)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Minor crime',
|
label: 'Minor crime',
|
||||||
feature: 'Minor crime (/yr, 7y)',
|
feature: 'Minor crime (avg/yr)',
|
||||||
unit: '',
|
unit: '',
|
||||||
components: [
|
components: [
|
||||||
'Anti-social behaviour (/yr, 7y)',
|
'Anti-social behaviour (avg/yr)',
|
||||||
'Criminal damage and arson (/yr, 7y)',
|
'Criminal damage and arson (avg/yr)',
|
||||||
'Shoplifting (/yr, 7y)',
|
'Shoplifting (avg/yr)',
|
||||||
'Bicycle theft (/yr, 7y)',
|
'Bicycle theft (avg/yr)',
|
||||||
'Theft from the person (/yr, 7y)',
|
'Theft from the person (avg/yr)',
|
||||||
'Other theft (/yr, 7y)',
|
'Other theft (avg/yr)',
|
||||||
'Vehicle crime (/yr, 7y)',
|
'Vehicle crime (avg/yr)',
|
||||||
'Public order (/yr, 7y)',
|
'Public order (avg/yr)',
|
||||||
'Drugs (/yr, 7y)',
|
'Drugs (avg/yr)',
|
||||||
'Other crime (/yr, 7y)',
|
'Other crime (avg/yr)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -503,20 +493,20 @@ export function getEnumValueColor(
|
||||||
|
|
||||||
/** Explicit colors for stacked bar segments. */
|
/** Explicit colors for stacked bar segments. */
|
||||||
export const STACKED_SEGMENT_COLORS: Record<string, string> = {
|
export const STACKED_SEGMENT_COLORS: Record<string, string> = {
|
||||||
'Violence and sexual offences (/yr, 7y)': '#ef4444',
|
'Violence and sexual offences (avg/yr)': '#ef4444',
|
||||||
'Robbery (/yr, 7y)': '#f97316',
|
'Robbery (avg/yr)': '#f97316',
|
||||||
'Burglary (/yr, 7y)': '#eab308',
|
'Burglary (avg/yr)': '#eab308',
|
||||||
'Possession of weapons (/yr, 7y)': '#8b5cf6',
|
'Possession of weapons (avg/yr)': '#8b5cf6',
|
||||||
'Anti-social behaviour (/yr, 7y)': '#14b8a6',
|
'Anti-social behaviour (avg/yr)': '#14b8a6',
|
||||||
'Criminal damage and arson (/yr, 7y)': '#f97316',
|
'Criminal damage and arson (avg/yr)': '#f97316',
|
||||||
'Shoplifting (/yr, 7y)': '#ec4899',
|
'Shoplifting (avg/yr)': '#ec4899',
|
||||||
'Bicycle theft (/yr, 7y)': '#22c55e',
|
'Bicycle theft (avg/yr)': '#22c55e',
|
||||||
'Theft from the person (/yr, 7y)': '#d946ef',
|
'Theft from the person (avg/yr)': '#d946ef',
|
||||||
'Other theft (/yr, 7y)': '#06b6d4',
|
'Other theft (avg/yr)': '#06b6d4',
|
||||||
'Vehicle crime (/yr, 7y)': '#3b82f6',
|
'Vehicle crime (avg/yr)': '#3b82f6',
|
||||||
'Public order (/yr, 7y)': '#8b5cf6',
|
'Public order (avg/yr)': '#8b5cf6',
|
||||||
'Drugs (/yr, 7y)': '#22c55e',
|
'Drugs (avg/yr)': '#22c55e',
|
||||||
'Other crime (/yr, 7y)': '#6b7280',
|
'Other crime (avg/yr)': '#6b7280',
|
||||||
'% White': '#3b82f6',
|
'% White': '#3b82f6',
|
||||||
'% South Asian': '#f97316',
|
'% South Asian': '#f97316',
|
||||||
'% East Asian': '#eab308',
|
'% East Asian': '#eab308',
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { FeatureMeta } from '../types';
|
|
||||||
import {
|
|
||||||
SPECIFIC_CRIME_FEATURE_NAMES,
|
|
||||||
createSpecificCrimeFilterKey,
|
|
||||||
getDefaultSpecificCrimeFeatureName,
|
|
||||||
getSpecificCrimeFeatureName,
|
|
||||||
getSpecificCrimeFilterKeyId,
|
|
||||||
getSpecificCrimeType,
|
|
||||||
getSpecificCrimeWindow,
|
|
||||||
isSpecificCrimeFeatureName,
|
|
||||||
isSpecificCrimeFilterName,
|
|
||||||
normalizeSpecificCrimeFilters,
|
|
||||||
specificCrimeFeatureName,
|
|
||||||
withSpecificCrimeWindow,
|
|
||||||
} from './crime-filter';
|
|
||||||
|
|
||||||
const numeric = (name: string): FeatureMeta => ({ name, type: 'numeric', min: 0, max: 10 });
|
|
||||||
|
|
||||||
describe('specific-crime window helpers', () => {
|
|
||||||
it('recognizes both the 7-year and 2-year rate features', () => {
|
|
||||||
expect(isSpecificCrimeFeatureName('Burglary (/yr, 7y)')).toBe(true);
|
|
||||||
expect(isSpecificCrimeFeatureName('Burglary (/yr, 2y)')).toBe(true);
|
|
||||||
expect(isSpecificCrimeFeatureName('Burglary (avg/yr)')).toBe(false);
|
|
||||||
// The "Serious crime" / "Minor crime" aggregates stay separate sliders.
|
|
||||||
expect(isSpecificCrimeFeatureName('Serious crime (/yr, 7y)')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts the window and bare type', () => {
|
|
||||||
expect(getSpecificCrimeWindow('Burglary (/yr, 2y)')).toBe('2y');
|
|
||||||
expect(getSpecificCrimeWindow('Burglary (/yr, 7y)')).toBe('7y');
|
|
||||||
expect(getSpecificCrimeWindow('Burglary')).toBeNull();
|
|
||||||
expect(getSpecificCrimeType('Violence and sexual offences (/yr, 2y)')).toBe(
|
|
||||||
'Violence and sexual offences'
|
|
||||||
);
|
|
||||||
expect(getSpecificCrimeType('not a crime')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('swaps a feature name between windows and round-trips', () => {
|
|
||||||
expect(withSpecificCrimeWindow('Burglary (/yr, 7y)', '2y')).toBe('Burglary (/yr, 2y)');
|
|
||||||
expect(withSpecificCrimeWindow('Burglary (/yr, 2y)', '7y')).toBe('Burglary (/yr, 7y)');
|
|
||||||
// Unrecognized names pass through untouched.
|
|
||||||
expect(withSpecificCrimeWindow('Burglary', '2y')).toBe('Burglary');
|
|
||||||
expect(specificCrimeFeatureName('Drugs', '2y')).toBe('Drugs (/yr, 2y)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to the 7-year window and enumerates 7-year dropdown names', () => {
|
|
||||||
const features = [numeric('Burglary (/yr, 7y)'), numeric('Burglary (/yr, 2y)')];
|
|
||||||
expect(getDefaultSpecificCrimeFeatureName(features)).toBe('Burglary (/yr, 7y)');
|
|
||||||
expect(SPECIFIC_CRIME_FEATURE_NAMES.every((n) => n.endsWith('(/yr, 7y)'))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('specific-crime filter keys', () => {
|
|
||||||
it('resolves a 2-year filter key back to its backend column', () => {
|
|
||||||
const key = createSpecificCrimeFilterKey('Burglary (/yr, 2y)', 3);
|
|
||||||
expect(isSpecificCrimeFilterName(key)).toBe(true);
|
|
||||||
expect(getSpecificCrimeFilterKeyId(key)).toBe('3');
|
|
||||||
expect(getSpecificCrimeFeatureName(key)).toBe('Burglary (/yr, 2y)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('folds a bare 2-year crime feature into a multi-instance key', () => {
|
|
||||||
const normalized = normalizeSpecificCrimeFilters({
|
|
||||||
'Burglary (/yr, 2y)': [1, 5],
|
|
||||||
'Median price (£)': [0, 100],
|
|
||||||
});
|
|
||||||
const keys = Object.keys(normalized);
|
|
||||||
expect(keys.some((k) => isSpecificCrimeFilterName(k))).toBe(true);
|
|
||||||
const crimeKey = keys.find((k) => isSpecificCrimeFilterName(k))!;
|
|
||||||
expect(getSpecificCrimeFeatureName(crimeKey)).toBe('Burglary (/yr, 2y)');
|
|
||||||
expect(normalized[crimeKey]).toEqual([1, 5]);
|
|
||||||
// Non-crime filters are left untouched.
|
|
||||||
expect(normalized['Median price (£)']).toEqual([0, 100]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,71 +1,26 @@
|
||||||
import type { FeatureFilters, FeatureMeta } from '../types';
|
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||||
import type { VariantFilterConfig } from './variant-filter';
|
|
||||||
|
|
||||||
export const SPECIFIC_CRIMES_FILTER_NAME = 'Specific crimes';
|
export const SPECIFIC_CRIMES_FILTER_NAME = 'Specific crimes';
|
||||||
export const SPECIFIC_CRIMES_FILTER_KEY_PREFIX = `${SPECIFIC_CRIMES_FILTER_NAME}:`;
|
export const SPECIFIC_CRIMES_FILTER_KEY_PREFIX = `${SPECIFIC_CRIMES_FILTER_NAME}:`;
|
||||||
|
|
||||||
// The "pick one crime type" filter selects a single street-level category, with
|
export const SPECIFIC_CRIME_FEATURE_NAMES = [
|
||||||
// a toggle to measure it over the 7-year or the recent 2-year window. The 7-year
|
'Violence and sexual offences (avg/yr)',
|
||||||
// window is the default/primary one (also headlined in the area pane).
|
'Burglary (avg/yr)',
|
||||||
export const SPECIFIC_CRIME_TYPES = [
|
'Robbery (avg/yr)',
|
||||||
'Violence and sexual offences',
|
'Vehicle crime (avg/yr)',
|
||||||
'Burglary',
|
'Anti-social behaviour (avg/yr)',
|
||||||
'Robbery',
|
'Criminal damage and arson (avg/yr)',
|
||||||
'Vehicle crime',
|
'Other theft (avg/yr)',
|
||||||
'Anti-social behaviour',
|
'Theft from the person (avg/yr)',
|
||||||
'Criminal damage and arson',
|
'Shoplifting (avg/yr)',
|
||||||
'Other theft',
|
'Bicycle theft (avg/yr)',
|
||||||
'Theft from the person',
|
'Drugs (avg/yr)',
|
||||||
'Shoplifting',
|
'Possession of weapons (avg/yr)',
|
||||||
'Bicycle theft',
|
'Public order (avg/yr)',
|
||||||
'Drugs',
|
'Other crime (avg/yr)',
|
||||||
'Possession of weapons',
|
|
||||||
'Public order',
|
|
||||||
'Other crime',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const SPECIFIC_CRIME_WINDOW_DEFAULT = '7y';
|
const SPECIFIC_CRIME_FEATURE_NAME_SET = new Set<string>(SPECIFIC_CRIME_FEATURE_NAMES);
|
||||||
export const SPECIFIC_CRIME_WINDOWS = ['7y', '2y'] as const;
|
|
||||||
export type SpecificCrimeWindow = (typeof SPECIFIC_CRIME_WINDOWS)[number];
|
|
||||||
|
|
||||||
/** The backend rate-feature column for a crime type in a given window. */
|
|
||||||
export function specificCrimeFeatureName(type: string, window: SpecificCrimeWindow): string {
|
|
||||||
return `${type} (/yr, ${window})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Canonical dropdown enumeration: one feature name per crime type, in the
|
|
||||||
// default (7-year) window. The window toggle re-points these to the chosen window.
|
|
||||||
export const SPECIFIC_CRIME_FEATURE_NAMES = SPECIFIC_CRIME_TYPES.map((type) =>
|
|
||||||
specificCrimeFeatureName(type, SPECIFIC_CRIME_WINDOW_DEFAULT)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Every recognized specific-crime feature, across all windows. Used to detect /
|
|
||||||
// resolve a filter key's backend column for either window.
|
|
||||||
const SPECIFIC_CRIME_FEATURE_NAME_SET = new Set<string>(
|
|
||||||
SPECIFIC_CRIME_TYPES.flatMap((type) =>
|
|
||||||
SPECIFIC_CRIME_WINDOWS.map((window) => specificCrimeFeatureName(type, window))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const SPECIFIC_CRIME_WINDOW_RE = / \(\/yr, (7y|2y)\)$/;
|
|
||||||
|
|
||||||
/** The window suffix of a specific-crime feature name (e.g. "2y"), or null. */
|
|
||||||
export function getSpecificCrimeWindow(featureName: string): SpecificCrimeWindow | null {
|
|
||||||
const match = featureName.match(SPECIFIC_CRIME_WINDOW_RE);
|
|
||||||
return match ? (match[1] as SpecificCrimeWindow) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bare crime type of a specific-crime feature name (e.g. "Burglary"), or null. */
|
|
||||||
export function getSpecificCrimeType(featureName: string): string | null {
|
|
||||||
const match = featureName.match(SPECIFIC_CRIME_WINDOW_RE);
|
|
||||||
return match ? featureName.slice(0, match.index) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same crime type's feature name in a different window (no-op if unrecognized). */
|
|
||||||
export function withSpecificCrimeWindow(featureName: string, window: SpecificCrimeWindow): string {
|
|
||||||
const type = getSpecificCrimeType(featureName);
|
|
||||||
return type ? specificCrimeFeatureName(type, window) : featureName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSpecificCrimeFeatureName(name: string): boolean {
|
export function isSpecificCrimeFeatureName(name: string): boolean {
|
||||||
return SPECIFIC_CRIME_FEATURE_NAME_SET.has(name);
|
return SPECIFIC_CRIME_FEATURE_NAME_SET.has(name);
|
||||||
|
|
@ -146,7 +101,7 @@ export function getSpecificCrimeFilterMeta(features: FeatureMeta[]): FeatureMeta
|
||||||
description:
|
description:
|
||||||
'Violence, burglary, robbery, drugs, shoplifting, vehicle crime, anti-social behaviour, public order, theft, and other crime types',
|
'Violence, burglary, robbery, drugs, shoplifting, vehicle crime, anti-social behaviour, public order, theft, and other crime types',
|
||||||
detail:
|
detail:
|
||||||
'Filter by one street-level crime category at a time, as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
|
'Filter by one street-level crime category at a time using an area-normalised crime density near each postcode (not a count of incidents per year).',
|
||||||
source: 'crime',
|
source: 'crime',
|
||||||
suffix: '',
|
suffix: '',
|
||||||
};
|
};
|
||||||
|
|
@ -160,25 +115,3 @@ export function clampSpecificCrimeRange(
|
||||||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
||||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SPECIFIC_CRIME_VARIANT_CONFIG: VariantFilterConfig = {
|
|
||||||
filterName: SPECIFIC_CRIMES_FILTER_NAME,
|
|
||||||
featureNames: SPECIFIC_CRIME_FEATURE_NAMES,
|
|
||||||
dropdownLabelKey: 'filters.crimeType',
|
|
||||||
getFilterMeta: getSpecificCrimeFilterMeta,
|
|
||||||
getDefaultFeatureName: getDefaultSpecificCrimeFeatureName,
|
|
||||||
getFeatureName: getSpecificCrimeFeatureName,
|
|
||||||
replaceFilterKeySelection: replaceSpecificCrimeFilterKeySelection,
|
|
||||||
clampRange: clampSpecificCrimeRange,
|
|
||||||
// Dropdown shows the bare crime type; the window toggle carries the 7y/2y suffix.
|
|
||||||
getOptionLabelSource: (name) => getSpecificCrimeType(name) ?? name,
|
|
||||||
window: {
|
|
||||||
labelKey: 'filters.crimeWindow',
|
|
||||||
options: [
|
|
||||||
{ id: '7y', labelKey: 'filters.crimeWindow7y' },
|
|
||||||
{ id: '2y', labelKey: 'filters.crimeWindow2y' },
|
|
||||||
],
|
|
||||||
getWindow: getSpecificCrimeWindow,
|
|
||||||
withWindow: (name, windowId) => withSpecificCrimeWindow(name, windowId as SpecificCrimeWindow),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { FeatureMeta } from '../types';
|
|
||||||
import {
|
|
||||||
CRIME_SEVERITY_FILTER_NAMES,
|
|
||||||
createCrimeSeverityFilterKey,
|
|
||||||
getCrimeSeverityFeatureName,
|
|
||||||
getCrimeSeverityFilterKeyId,
|
|
||||||
getCrimeSeverityFilterName,
|
|
||||||
getCrimeSeverityVariantConfig,
|
|
||||||
getDefaultCrimeSeverityFeatureName,
|
|
||||||
isCrimeSeverityFeatureName,
|
|
||||||
isCrimeSeverityFilterName,
|
|
||||||
normalizeCrimeSeverityFilters,
|
|
||||||
} from './crime-severity-filter';
|
|
||||||
|
|
||||||
const numeric = (name: string): FeatureMeta => ({ name, type: 'numeric', min: 0, max: 10 });
|
|
||||||
|
|
||||||
describe('crime-severity feature recognition', () => {
|
|
||||||
it('recognizes Serious/Minor crime in both windows only', () => {
|
|
||||||
expect(isCrimeSeverityFeatureName('Serious crime (/yr, 7y)')).toBe(true);
|
|
||||||
expect(isCrimeSeverityFeatureName('Serious crime (/yr, 2y)')).toBe(true);
|
|
||||||
expect(isCrimeSeverityFeatureName('Minor crime (/yr, 2y)')).toBe(true);
|
|
||||||
// Detailed categories belong to "Specific crimes", not severity.
|
|
||||||
expect(isCrimeSeverityFeatureName('Burglary (/yr, 7y)')).toBe(false);
|
|
||||||
// Bare names without a window suffix are not feature columns.
|
|
||||||
expect(isCrimeSeverityFeatureName('Serious crime')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps a feature or key back to its severity filter name', () => {
|
|
||||||
expect(getCrimeSeverityFilterName('Serious crime (/yr, 2y)')).toBe('Serious crime');
|
|
||||||
expect(getCrimeSeverityFilterName('Minor crime (/yr, 7y)')).toBe('Minor crime');
|
|
||||||
const key = createCrimeSeverityFilterKey('Serious crime', 'Serious crime (/yr, 7y)', 0);
|
|
||||||
expect(getCrimeSeverityFilterName(key)).toBe('Serious crime');
|
|
||||||
expect(getCrimeSeverityFilterName('Burglary (/yr, 7y)')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('crime-severity filter keys', () => {
|
|
||||||
it('resolves a 2-year key back to its backend column and id', () => {
|
|
||||||
const key = createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 4);
|
|
||||||
expect(isCrimeSeverityFilterName(key)).toBe(true);
|
|
||||||
expect(getCrimeSeverityFilterKeyId(key)).toBe('4');
|
|
||||||
expect(getCrimeSeverityFeatureName(key)).toBe('Minor crime (/yr, 2y)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to the 7-year window when present', () => {
|
|
||||||
const features = [numeric('Serious crime (/yr, 7y)'), numeric('Serious crime (/yr, 2y)')];
|
|
||||||
expect(getDefaultCrimeSeverityFeatureName(features, 'Serious crime')).toBe(
|
|
||||||
'Serious crime (/yr, 7y)'
|
|
||||||
);
|
|
||||||
expect(getDefaultCrimeSeverityFeatureName(features, 'Minor crime')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('folds bare severity features but leaves specific crimes and others alone', () => {
|
|
||||||
const normalized = normalizeCrimeSeverityFilters({
|
|
||||||
'Serious crime (/yr, 2y)': [1, 5],
|
|
||||||
'Burglary (/yr, 7y)': [0, 3],
|
|
||||||
'Median price (£)': [0, 100],
|
|
||||||
});
|
|
||||||
const keys = Object.keys(normalized);
|
|
||||||
const severityKey = keys.find((k) => isCrimeSeverityFilterName(k))!;
|
|
||||||
expect(getCrimeSeverityFeatureName(severityKey)).toBe('Serious crime (/yr, 2y)');
|
|
||||||
expect(normalized[severityKey]).toEqual([1, 5]);
|
|
||||||
// Specific-crime + plain features pass through untouched.
|
|
||||||
expect(normalized['Burglary (/yr, 7y)']).toEqual([0, 3]);
|
|
||||||
expect(normalized['Median price (£)']).toEqual([0, 100]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('crime-severity variant config', () => {
|
|
||||||
it('exposes a single variant per severity with a 7y/2y window toggle', () => {
|
|
||||||
for (const filterName of CRIME_SEVERITY_FILTER_NAMES) {
|
|
||||||
const config = getCrimeSeverityVariantConfig(filterName);
|
|
||||||
expect(config.filterName).toBe(filterName);
|
|
||||||
expect(config.featureNames).toEqual([`${filterName} (/yr, 7y)`]);
|
|
||||||
expect(config.window?.options.map((o) => o.id)).toEqual(['7y', '2y']);
|
|
||||||
// Switching window re-points to the same severity, other window.
|
|
||||||
const sevenYear = `${filterName} (/yr, 7y)`;
|
|
||||||
expect(config.window?.withWindow(sevenYear, '2y')).toBe(`${filterName} (/yr, 2y)`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
import type { FeatureFilters, FeatureMeta } from '../types';
|
|
||||||
import type { VariantFilterConfig } from './variant-filter';
|
|
||||||
import {
|
|
||||||
SPECIFIC_CRIME_WINDOW_DEFAULT,
|
|
||||||
clampSpecificCrimeRange,
|
|
||||||
getSpecificCrimeType,
|
|
||||||
getSpecificCrimeWindow,
|
|
||||||
specificCrimeFeatureName,
|
|
||||||
withSpecificCrimeWindow,
|
|
||||||
type SpecificCrimeWindow,
|
|
||||||
} from './crime-filter';
|
|
||||||
|
|
||||||
// "Serious crime" and "Minor crime" are aggregate severity rollups (they overlap
|
|
||||||
// the 14 detailed categories, so they must stay OUT of the "Specific crimes"
|
|
||||||
// dropdown). Each is a single feature with a 7-year/2-year window toggle — the
|
|
||||||
// same folding the specific-crimes card has, but with no variant dropdown (one
|
|
||||||
// variant). Modeled on the multi-filter-name POI pattern: one lib, two filter
|
|
||||||
// names, distinguished by their key prefix / bare crime type.
|
|
||||||
|
|
||||||
export const SERIOUS_CRIME_FILTER_NAME = 'Serious crime';
|
|
||||||
export const MINOR_CRIME_FILTER_NAME = 'Minor crime';
|
|
||||||
|
|
||||||
export const CRIME_SEVERITY_FILTER_NAMES = [
|
|
||||||
SERIOUS_CRIME_FILTER_NAME,
|
|
||||||
MINOR_CRIME_FILTER_NAME,
|
|
||||||
] as const;
|
|
||||||
export type CrimeSeverityFilterName = (typeof CRIME_SEVERITY_FILTER_NAMES)[number];
|
|
||||||
|
|
||||||
const CRIME_SEVERITY_TYPE_SET = new Set<string>(CRIME_SEVERITY_FILTER_NAMES);
|
|
||||||
|
|
||||||
const CRIME_SEVERITY_DETAILS: Record<
|
|
||||||
CrimeSeverityFilterName,
|
|
||||||
{ description: string; detail: string }
|
|
||||||
> = {
|
|
||||||
[SERIOUS_CRIME_FILTER_NAME]: {
|
|
||||||
description: 'Violence, robbery, burglary and weapons possession near the postcode',
|
|
||||||
detail:
|
|
||||||
'Combined count of the more serious street-level categories (violence and sexual offences, robbery, burglary, possession of weapons), as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
|
|
||||||
},
|
|
||||||
[MINOR_CRIME_FILTER_NAME]: {
|
|
||||||
description:
|
|
||||||
'Anti-social behaviour, theft, criminal damage, drugs and public order near the postcode',
|
|
||||||
detail:
|
|
||||||
'Combined count of the lower-severity street-level categories (anti-social behaviour, theft, criminal damage and arson, drugs, public order), as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function keyPrefix(filterName: CrimeSeverityFilterName): string {
|
|
||||||
return `${filterName}:`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The severity filter a name belongs to (from its key prefix or bare feature name), or null. */
|
|
||||||
export function getCrimeSeverityFilterName(name: string): CrimeSeverityFilterName | null {
|
|
||||||
for (const filterName of CRIME_SEVERITY_FILTER_NAMES) {
|
|
||||||
if (name.startsWith(keyPrefix(filterName))) return filterName;
|
|
||||||
}
|
|
||||||
const type = getSpecificCrimeType(name);
|
|
||||||
return type && CRIME_SEVERITY_TYPE_SET.has(type) ? (type as CrimeSeverityFilterName) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCrimeSeverityFeatureName(name: string): boolean {
|
|
||||||
const type = getSpecificCrimeType(name);
|
|
||||||
return type != null && CRIME_SEVERITY_TYPE_SET.has(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCrimeSeverityFilterName(name: string): boolean {
|
|
||||||
return getCrimeSeverityFilterName(name) != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCrimeSeverityFilterKey(
|
|
||||||
filterName: CrimeSeverityFilterName,
|
|
||||||
featureName: string,
|
|
||||||
id: number | string
|
|
||||||
): string {
|
|
||||||
return `${keyPrefix(filterName)}${encodeURIComponent(featureName)}:${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCrimeSeverityFilterKeyId(name: string): string | null {
|
|
||||||
const filterName = getCrimeSeverityFilterName(name);
|
|
||||||
if (!filterName) return null;
|
|
||||||
const prefix = keyPrefix(filterName);
|
|
||||||
if (!name.startsWith(prefix)) return null; // a bare feature name has no id
|
|
||||||
const rest = name.substring(prefix.length);
|
|
||||||
const lastColon = rest.lastIndexOf(':');
|
|
||||||
return lastColon === -1 ? null : rest.substring(lastColon + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCrimeSeverityFilterKey(name: string): string | null {
|
|
||||||
const filterName = getCrimeSeverityFilterName(name);
|
|
||||||
if (!filterName) return null;
|
|
||||||
const prefix = keyPrefix(filterName);
|
|
||||||
if (!name.startsWith(prefix)) return null;
|
|
||||||
const rest = name.substring(prefix.length);
|
|
||||||
const lastColon = rest.lastIndexOf(':');
|
|
||||||
if (lastColon === -1) return null;
|
|
||||||
|
|
||||||
const decoded = decodeURIComponent(rest.substring(0, lastColon));
|
|
||||||
return isCrimeSeverityFeatureName(decoded) ? decoded : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCrimeSeverityFeatureName(name: string): string | null {
|
|
||||||
if (isCrimeSeverityFeatureName(name)) return name;
|
|
||||||
return parseCrimeSeverityFilterKey(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replaceCrimeSeverityFilterKeySelection(key: string, featureName: string): string {
|
|
||||||
const filterName = getCrimeSeverityFilterName(key) ?? getCrimeSeverityFilterName(featureName);
|
|
||||||
const id = getCrimeSeverityFilterKeyId(key) ?? '0';
|
|
||||||
// filterName is always resolvable here: the key carries a prefix and a window
|
|
||||||
// switch keeps the same severity type. Fall back defensively to the key as-is.
|
|
||||||
if (!filterName) return key;
|
|
||||||
return createCrimeSeverityFilterKey(filterName, featureName, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The default (7-year) backend feature for a severity, if present in `features`. */
|
|
||||||
export function getDefaultCrimeSeverityFeatureName(
|
|
||||||
features: FeatureMeta[],
|
|
||||||
filterName: CrimeSeverityFilterName
|
|
||||||
): string | null {
|
|
||||||
const name = specificCrimeFeatureName(filterName, SPECIFIC_CRIME_WINDOW_DEFAULT);
|
|
||||||
return features.some((feature) => feature.name === name) ? name : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCrimeSeverityFilters(filters: FeatureFilters): FeatureFilters {
|
|
||||||
let changed = false;
|
|
||||||
const next: FeatureFilters = {};
|
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(filters)) {
|
|
||||||
if (isCrimeSeverityFeatureName(name)) {
|
|
||||||
const filterName = getCrimeSeverityFilterName(name)!;
|
|
||||||
next[createCrimeSeverityFilterKey(filterName, name, Object.keys(next).length)] = value;
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next[name] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? next : filters;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCrimeSeverityFilterMeta(
|
|
||||||
features: FeatureMeta[],
|
|
||||||
filterName: CrimeSeverityFilterName
|
|
||||||
): FeatureMeta {
|
|
||||||
const sourceFeatureName = getDefaultCrimeSeverityFeatureName(features, filterName);
|
|
||||||
const sourceFeature = sourceFeatureName
|
|
||||||
? features.find((feature) => feature.name === sourceFeatureName)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: filterName,
|
|
||||||
type: 'numeric',
|
|
||||||
group: 'Crime',
|
|
||||||
min: sourceFeature?.min ?? 0,
|
|
||||||
max: sourceFeature?.max ?? 100,
|
|
||||||
step: 1,
|
|
||||||
description: CRIME_SEVERITY_DETAILS[filterName].description,
|
|
||||||
detail: CRIME_SEVERITY_DETAILS[filterName].detail,
|
|
||||||
source: 'crime',
|
|
||||||
suffix: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampCrimeSeverityRange(
|
|
||||||
value: [number, number],
|
|
||||||
feature?: FeatureMeta
|
|
||||||
): [number, number] {
|
|
||||||
return clampSpecificCrimeRange(value, feature);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The variant-filter config for one severity (single variant + 7y/2y window toggle). */
|
|
||||||
export function getCrimeSeverityVariantConfig(
|
|
||||||
filterName: CrimeSeverityFilterName
|
|
||||||
): VariantFilterConfig {
|
|
||||||
return {
|
|
||||||
filterName,
|
|
||||||
// One variant: the severity itself, in the default window. The card hides the
|
|
||||||
// (single-option) dropdown and exposes only the window toggle.
|
|
||||||
featureNames: [specificCrimeFeatureName(filterName, SPECIFIC_CRIME_WINDOW_DEFAULT)],
|
|
||||||
dropdownLabelKey: 'filters.crimeType',
|
|
||||||
getFilterMeta: (features) => getCrimeSeverityFilterMeta(features, filterName),
|
|
||||||
getDefaultFeatureName: (features) => getDefaultCrimeSeverityFeatureName(features, filterName),
|
|
||||||
getFeatureName: getCrimeSeverityFeatureName,
|
|
||||||
replaceFilterKeySelection: replaceCrimeSeverityFilterKeySelection,
|
|
||||||
clampRange: clampCrimeSeverityRange,
|
|
||||||
window: {
|
|
||||||
labelKey: 'filters.crimeWindow',
|
|
||||||
options: [
|
|
||||||
{ id: '7y', labelKey: 'filters.crimeWindow7y' },
|
|
||||||
{ id: '2y', labelKey: 'filters.crimeWindow2y' },
|
|
||||||
],
|
|
||||||
getWindow: getSpecificCrimeWindow,
|
|
||||||
withWindow: (name, windowId) =>
|
|
||||||
withSpecificCrimeWindow(name, windowId as SpecificCrimeWindow),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -6,47 +6,24 @@
|
||||||
|
|
||||||
export interface CrimeTypeDef {
|
export interface CrimeTypeDef {
|
||||||
value: string;
|
value: string;
|
||||||
/** English label (fallback / reference). */
|
|
||||||
label: string;
|
label: string;
|
||||||
/** i18n key under `crimeTypes.*` used to render the selector label. */
|
|
||||||
labelKey: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CRIME_TYPES: readonly CrimeTypeDef[] = [
|
export const CRIME_TYPES: readonly CrimeTypeDef[] = [
|
||||||
{
|
{ value: 'Violence and sexual offences', label: 'Violence & sexual offences' },
|
||||||
value: 'Violence and sexual offences',
|
{ value: 'Anti-social behaviour', label: 'Anti-social behaviour' },
|
||||||
label: 'Violence & sexual offences',
|
{ value: 'Criminal damage and arson', label: 'Criminal damage & arson' },
|
||||||
labelKey: 'crimeTypes.violenceAndSexualOffences',
|
{ value: 'Public order', label: 'Public order' },
|
||||||
},
|
{ value: 'Shoplifting', label: 'Shoplifting' },
|
||||||
{
|
{ value: 'Vehicle crime', label: 'Vehicle crime' },
|
||||||
value: 'Anti-social behaviour',
|
{ value: 'Burglary', label: 'Burglary' },
|
||||||
label: 'Anti-social behaviour',
|
{ value: 'Other theft', label: 'Other theft' },
|
||||||
labelKey: 'crimeTypes.antiSocialBehaviour',
|
{ value: 'Theft from the person', label: 'Theft from the person' },
|
||||||
},
|
{ value: 'Bicycle theft', label: 'Bicycle theft' },
|
||||||
{
|
{ value: 'Drugs', label: 'Drugs' },
|
||||||
value: 'Criminal damage and arson',
|
{ value: 'Robbery', label: 'Robbery' },
|
||||||
label: 'Criminal damage & arson',
|
{ value: 'Possession of weapons', label: 'Possession of weapons' },
|
||||||
labelKey: 'crimeTypes.criminalDamageAndArson',
|
{ value: 'Other crime', label: 'Other crime' },
|
||||||
},
|
|
||||||
{ value: 'Public order', label: 'Public order', labelKey: 'crimeTypes.publicOrder' },
|
|
||||||
{ value: 'Shoplifting', label: 'Shoplifting', labelKey: 'crimeTypes.shoplifting' },
|
|
||||||
{ value: 'Vehicle crime', label: 'Vehicle crime', labelKey: 'crimeTypes.vehicleCrime' },
|
|
||||||
{ value: 'Burglary', label: 'Burglary', labelKey: 'crimeTypes.burglary' },
|
|
||||||
{ value: 'Other theft', label: 'Other theft', labelKey: 'crimeTypes.otherTheft' },
|
|
||||||
{
|
|
||||||
value: 'Theft from the person',
|
|
||||||
label: 'Theft from the person',
|
|
||||||
labelKey: 'crimeTypes.theftFromThePerson',
|
|
||||||
},
|
|
||||||
{ value: 'Bicycle theft', label: 'Bicycle theft', labelKey: 'crimeTypes.bicycleTheft' },
|
|
||||||
{ value: 'Drugs', label: 'Drugs', labelKey: 'crimeTypes.drugs' },
|
|
||||||
{ value: 'Robbery', label: 'Robbery', labelKey: 'crimeTypes.robbery' },
|
|
||||||
{
|
|
||||||
value: 'Possession of weapons',
|
|
||||||
label: 'Possession of weapons',
|
|
||||||
labelKey: 'crimeTypes.possessionOfWeapons',
|
|
||||||
},
|
|
||||||
{ value: 'Other crime', label: 'Other crime', labelKey: 'crimeTypes.otherCrime' },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CRIME_TYPE_VALUES: readonly string[] = CRIME_TYPES.map((c) => c.value);
|
export const CRIME_TYPE_VALUES: readonly string[] = CRIME_TYPES.map((c) => c.value);
|
||||||
|
|
|
||||||
|
|
@ -404,12 +404,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
||||||
* Returns a complete SVG icon element for a given feature name, or null if unmapped.
|
* Returns a complete SVG icon element for a given feature name, or null if unmapped.
|
||||||
*/
|
*/
|
||||||
export function getFeatureIcon(featureName: string, className: string): ReactElement | null {
|
export function getFeatureIcon(featureName: string, className: string): ReactElement | null {
|
||||||
// Crime features ("Burglary (/yr, 7y)") share the bare type's legacy
|
const paths = FEATURE_ICON_PATHS[featureName];
|
||||||
// "(avg/yr)" icon key; both windows use the same icon.
|
|
||||||
const rateMatch = featureName.match(/^(.*) \(\/yr, \d+y\)$/);
|
|
||||||
const paths =
|
|
||||||
FEATURE_ICON_PATHS[featureName] ??
|
|
||||||
(rateMatch ? FEATURE_ICON_PATHS[`${rateMatch[1]} (avg/yr)`] : undefined);
|
|
||||||
if (!paths) return null;
|
if (!paths) return null;
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type { POI } from '../types';
|
||||||
import {
|
import {
|
||||||
formatStationDistance,
|
formatStationDistance,
|
||||||
selectNearbyStations,
|
selectNearbyStations,
|
||||||
stationDisplayName,
|
|
||||||
stationSearchBounds,
|
stationSearchBounds,
|
||||||
} from './nearby-stations';
|
} from './nearby-stations';
|
||||||
|
|
||||||
|
|
@ -54,34 +53,6 @@ describe('selectNearbyStations', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stationDisplayName', () => {
|
|
||||||
it('strips the mode descriptor so the name matches the category subtitle', () => {
|
|
||||||
// Interchange POIs categorised "Tube station" but named from the DLR node.
|
|
||||||
expect(stationDisplayName('Bank DLR Station')).toBe('Bank');
|
|
||||||
expect(stationDisplayName('Canning Town DLR Station')).toBe('Canning Town');
|
|
||||||
expect(stationDisplayName('West Ham DLR Station')).toBe('West Ham');
|
|
||||||
expect(stationDisplayName('Bank Underground Station')).toBe('Bank');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops the redundant mode word from single-mode station names', () => {
|
|
||||||
expect(stationDisplayName('Beckton DLR Station')).toBe('Beckton');
|
|
||||||
expect(stationDisplayName('Shadwell DLR')).toBe('Shadwell');
|
|
||||||
expect(stationDisplayName('Greenwich Rail Station')).toBe('Greenwich');
|
|
||||||
expect(stationDisplayName('Stratford Station')).toBe('Stratford');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps disambiguating qualifiers and never empties the name', () => {
|
|
||||||
expect(stationDisplayName('Cutty Sark (for Maritime Greenwich) DLR Station')).toBe(
|
|
||||||
'Cutty Sark (for Maritime Greenwich)'
|
|
||||||
);
|
|
||||||
expect(stationDisplayName('Stratford International DLR Station')).toBe(
|
|
||||||
'Stratford International'
|
|
||||||
);
|
|
||||||
expect(stationDisplayName('West Ham')).toBe('West Ham');
|
|
||||||
expect(stationDisplayName('Station')).toBe('Station');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('stationSearchBounds', () => {
|
describe('stationSearchBounds', () => {
|
||||||
it('builds a box around the origin', () => {
|
it('builds a box around the origin', () => {
|
||||||
const bounds = stationSearchBounds({ lat: 51.5, lon: -0.1 });
|
const bounds = stationSearchBounds({ lat: 51.5, lon: -0.1 });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Bounds, POI } from '../types';
|
import type { Bounds, POI } from '../types';
|
||||||
|
|
||||||
export const STATION_CATEGORIES = ['Rail station', 'Tube station', 'DLR station'] as const;
|
export const STATION_CATEGORIES = ['Rail station', 'Tube station'] as const;
|
||||||
export const STATION_SEARCH_RADIUS_KM = 2;
|
export const STATION_SEARCH_RADIUS_KM = 2;
|
||||||
const PRIMARY_STATION_RADIUS_KM = 1;
|
const PRIMARY_STATION_RADIUS_KM = 1;
|
||||||
const FALLBACK_STATION_LIMIT = 3;
|
const FALLBACK_STATION_LIMIT = 3;
|
||||||
|
|
@ -69,26 +69,3 @@ export function formatStationDistance(distanceKmValue: number): string {
|
||||||
|
|
||||||
return `${distanceKmValue.toFixed(1)}km`;
|
return `${distanceKmValue.toFixed(1)}km`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip a trailing transit-mode descriptor (e.g. " DLR Station", " Underground
|
|
||||||
* Station", " Rail Station", " Station") from a NaPTAN station name so the row
|
|
||||||
* reads as the bare place and the category subtitle alone conveys the mode.
|
|
||||||
*
|
|
||||||
* At Tube/DLR interchanges (Bank, Canning Town, Stratford, …) NaPTAN merges the
|
|
||||||
* Underground and DLR nodes into one POI whose category resolves to "Tube
|
|
||||||
* station" but whose name was taken from the DLR node — so the raw name "Bank
|
|
||||||
* DLR Station" would otherwise sit under a "Tube station" label. Dropping the
|
|
||||||
* mode word removes both that contradiction and the everyday redundancy of
|
|
||||||
* "Beckton DLR Station" shown above a "DLR station" subtitle.
|
|
||||||
*/
|
|
||||||
export function stationDisplayName(name: string): string {
|
|
||||||
const stripped = name
|
|
||||||
.replace(
|
|
||||||
/\s+(?:DLR|Underground|Tube|Overground|Railway|Rail|Metro|Tramlink|Tram)(?:\s+(?:Station|Stop))?$/i,
|
|
||||||
''
|
|
||||||
)
|
|
||||||
.replace(/\s+(?:Station|Stop)$/i, '')
|
|
||||||
.trim();
|
|
||||||
return stripped || name;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export const OVERLAYS: OverlayDefinition[] = [
|
||||||
description: 'Approximate police.uk street-crime heatmap',
|
description: 'Approximate police.uk street-crime heatmap',
|
||||||
detail:
|
detail:
|
||||||
'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
|
'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
|
||||||
|
defaultEnabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'trees-outside-woodlands',
|
id: 'trees-outside-woodlands',
|
||||||
|
|
@ -46,7 +47,6 @@ export const OVERLAYS: OverlayDefinition[] = [
|
||||||
description: 'Individual freehold property/land-parcel boundaries',
|
description: 'Individual freehold property/land-parcel boundaries',
|
||||||
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.',
|
||||||
defaultEnabled: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'new-developments',
|
id: 'new-developments',
|
||||||
|
|
@ -54,7 +54,6 @@ export const OVERLAYS: OverlayDefinition[] = [
|
||||||
description: 'Planned new-home sites (brownfield register + Homes England)',
|
description: 'Planned new-home sites (brownfield register + Homes England)',
|
||||||
detail:
|
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.',
|
'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.',
|
||||||
defaultEnabled: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ const TRANSPORT_POI_CATEGORIES = new Set([
|
||||||
'Airport',
|
'Airport',
|
||||||
'Bus station',
|
'Bus station',
|
||||||
'Bus stop',
|
'Bus stop',
|
||||||
'DLR station',
|
|
||||||
'Ferry',
|
'Ferry',
|
||||||
'Rail station',
|
'Rail station',
|
||||||
'Taxi rank',
|
'Taxi rank',
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
import type { FeatureFilters, FeatureMeta } from '../types';
|
|
||||||
import type { VariantFilterConfig } from './variant-filter';
|
|
||||||
|
|
||||||
export const QUALIFICATIONS_FILTER_NAME = 'Qualifications';
|
|
||||||
export const QUALIFICATIONS_FILTER_KEY_PREFIX = `${QUALIFICATIONS_FILTER_NAME}:`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Census 2021 "highest level of qualification" bands (TS067). They sum to
|
* The Census 2021 qualification-breakdown features (TS067). These render as a
|
||||||
* 100% per neighbourhood and render as a single stacked "Qualifications"
|
* single stacked "Qualifications" composition in the area pane (see
|
||||||
* composition in the area pane (see STACKED_GROUPS["Neighbours"] in consts.ts).
|
* STACKED_GROUPS["Neighbours"] in consts.ts) and are display-only: they are
|
||||||
* In the filter browser they fold into one "Qualifications" filter whose
|
* hidden from the filter browser rather than offered as seven individual
|
||||||
* dropdown selects a band — including "% Degree or higher" — rather than seven
|
* sliders, so the breakdown reads as a ratio without cluttering the filter list.
|
||||||
* separate sliders.
|
|
||||||
*/
|
*/
|
||||||
export const QUALIFICATION_FEATURE_NAMES = [
|
export const QUALIFICATION_FEATURE_NAMES = [
|
||||||
'% No qualifications',
|
'% No qualifications',
|
||||||
|
|
@ -27,103 +20,3 @@ const QUALIFICATION_FEATURE_NAME_SET = new Set<string>(QUALIFICATION_FEATURE_NAM
|
||||||
export function isQualificationFeatureName(name: string): boolean {
|
export function isQualificationFeatureName(name: string): boolean {
|
||||||
return QUALIFICATION_FEATURE_NAME_SET.has(name);
|
return QUALIFICATION_FEATURE_NAME_SET.has(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isQualificationFilterName(name: string): boolean {
|
|
||||||
return isQualificationFeatureName(name) || name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createQualificationFilterKey(featureName: string, id: number | string): string {
|
|
||||||
return `${QUALIFICATIONS_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getQualificationFilterKeyId(name: string): string | null {
|
|
||||||
if (!name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX)) return null;
|
|
||||||
const rest = name.substring(QUALIFICATIONS_FILTER_KEY_PREFIX.length);
|
|
||||||
const lastColon = rest.lastIndexOf(':');
|
|
||||||
return lastColon === -1 ? null : rest.substring(lastColon + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseQualificationFilterKey(name: string): string | null {
|
|
||||||
if (!name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX)) return null;
|
|
||||||
const rest = name.substring(QUALIFICATIONS_FILTER_KEY_PREFIX.length);
|
|
||||||
const lastColon = rest.lastIndexOf(':');
|
|
||||||
if (lastColon === -1) return null;
|
|
||||||
|
|
||||||
const decoded = decodeURIComponent(rest.substring(0, lastColon));
|
|
||||||
return isQualificationFeatureName(decoded) ? decoded : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getQualificationFeatureName(name: string): string | null {
|
|
||||||
if (isQualificationFeatureName(name)) return name;
|
|
||||||
return parseQualificationFilterKey(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replaceQualificationFilterKeySelection(key: string, featureName: string): string {
|
|
||||||
const id = getQualificationFilterKeyId(key) ?? '0';
|
|
||||||
return createQualificationFilterKey(featureName, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultQualificationFeatureName(features: FeatureMeta[]): string | null {
|
|
||||||
return (
|
|
||||||
QUALIFICATION_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeQualificationFilters(filters: FeatureFilters): FeatureFilters {
|
|
||||||
let changed = false;
|
|
||||||
const next: FeatureFilters = {};
|
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(filters)) {
|
|
||||||
if (isQualificationFeatureName(name)) {
|
|
||||||
next[createQualificationFilterKey(name, Object.keys(next).length)] = value;
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next[name] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? next : filters;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getQualificationFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
|
||||||
const sourceFeatureName = getDefaultQualificationFeatureName(features);
|
|
||||||
const sourceFeature = sourceFeatureName
|
|
||||||
? features.find((feature) => feature.name === sourceFeatureName)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: QUALIFICATIONS_FILTER_NAME,
|
|
||||||
type: 'numeric',
|
|
||||||
group: 'Neighbours',
|
|
||||||
min: sourceFeature?.min ?? 0,
|
|
||||||
max: sourceFeature?.max ?? 100,
|
|
||||||
step: 1,
|
|
||||||
description:
|
|
||||||
'Share of residents (16+) by highest qualification, from no qualifications to a degree or higher',
|
|
||||||
detail:
|
|
||||||
'Filter by one Census 2021 (TS067) highest-qualification band at a time, e.g. the percentage of residents whose highest qualification is a degree or higher.',
|
|
||||||
source: 'census-2021',
|
|
||||||
suffix: '%',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampQualificationRange(
|
|
||||||
value: [number, number],
|
|
||||||
feature?: FeatureMeta
|
|
||||||
): [number, number] {
|
|
||||||
const min = feature?.histogram?.min ?? feature?.min ?? 0;
|
|
||||||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
|
||||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QUALIFICATION_VARIANT_CONFIG: VariantFilterConfig = {
|
|
||||||
filterName: QUALIFICATIONS_FILTER_NAME,
|
|
||||||
featureNames: QUALIFICATION_FEATURE_NAMES,
|
|
||||||
dropdownLabelKey: 'filters.qualificationLevel',
|
|
||||||
getFilterMeta: getQualificationFilterMeta,
|
|
||||||
getDefaultFeatureName: getDefaultQualificationFeatureName,
|
|
||||||
getFeatureName: getQualificationFeatureName,
|
|
||||||
replaceFilterKeySelection: replaceQualificationFilterKeySelection,
|
|
||||||
clampRange: clampQualificationRange,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import type { FeatureFilters, FeatureMeta } from '../types';
|
|
||||||
import type { VariantFilterConfig } from './variant-filter';
|
|
||||||
|
|
||||||
export const TENURE_FILTER_NAME = 'Tenure';
|
|
||||||
export const TENURE_FILTER_KEY_PREFIX = `${TENURE_FILTER_NAME}:`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Census 2021 housing tenure categories (TS054). They sum to 100% per
|
|
||||||
* neighbourhood and render as a single stacked "Tenure" composition in the area
|
|
||||||
* pane (see STACKED_GROUPS["Neighbours"] in consts.ts). In the filter browser
|
|
||||||
* they fold into one "Tenure" filter whose dropdown selects a category —
|
|
||||||
* owner-occupied, social rent or private rent — rather than three separate
|
|
||||||
* sliders.
|
|
||||||
*/
|
|
||||||
export const TENURE_FEATURE_NAMES = [
|
|
||||||
'% Owner occupied',
|
|
||||||
'% Social rent',
|
|
||||||
'% Private rent',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const TENURE_FEATURE_NAME_SET = new Set<string>(TENURE_FEATURE_NAMES);
|
|
||||||
|
|
||||||
export function isTenureFeatureName(name: string): boolean {
|
|
||||||
return TENURE_FEATURE_NAME_SET.has(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTenureFilterName(name: string): boolean {
|
|
||||||
return isTenureFeatureName(name) || name.startsWith(TENURE_FILTER_KEY_PREFIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTenureFilterKey(featureName: string, id: number | string): string {
|
|
||||||
return `${TENURE_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTenureFilterKeyId(name: string): string | null {
|
|
||||||
if (!name.startsWith(TENURE_FILTER_KEY_PREFIX)) return null;
|
|
||||||
const rest = name.substring(TENURE_FILTER_KEY_PREFIX.length);
|
|
||||||
const lastColon = rest.lastIndexOf(':');
|
|
||||||
return lastColon === -1 ? null : rest.substring(lastColon + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTenureFilterKey(name: string): string | null {
|
|
||||||
if (!name.startsWith(TENURE_FILTER_KEY_PREFIX)) return null;
|
|
||||||
const rest = name.substring(TENURE_FILTER_KEY_PREFIX.length);
|
|
||||||
const lastColon = rest.lastIndexOf(':');
|
|
||||||
if (lastColon === -1) return null;
|
|
||||||
|
|
||||||
const decoded = decodeURIComponent(rest.substring(0, lastColon));
|
|
||||||
return isTenureFeatureName(decoded) ? decoded : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTenureFeatureName(name: string): string | null {
|
|
||||||
if (isTenureFeatureName(name)) return name;
|
|
||||||
return parseTenureFilterKey(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replaceTenureFilterKeySelection(key: string, featureName: string): string {
|
|
||||||
const id = getTenureFilterKeyId(key) ?? '0';
|
|
||||||
return createTenureFilterKey(featureName, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultTenureFeatureName(features: FeatureMeta[]): string | null {
|
|
||||||
return (
|
|
||||||
TENURE_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeTenureFilters(filters: FeatureFilters): FeatureFilters {
|
|
||||||
let changed = false;
|
|
||||||
const next: FeatureFilters = {};
|
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(filters)) {
|
|
||||||
if (isTenureFeatureName(name)) {
|
|
||||||
next[createTenureFilterKey(name, Object.keys(next).length)] = value;
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next[name] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? next : filters;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTenureFilterMeta(features: FeatureMeta[]): FeatureMeta {
|
|
||||||
const sourceFeatureName = getDefaultTenureFeatureName(features);
|
|
||||||
const sourceFeature = sourceFeatureName
|
|
||||||
? features.find((feature) => feature.name === sourceFeatureName)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: TENURE_FILTER_NAME,
|
|
||||||
type: 'numeric',
|
|
||||||
group: 'Neighbours',
|
|
||||||
min: sourceFeature?.min ?? 0,
|
|
||||||
max: sourceFeature?.max ?? 100,
|
|
||||||
step: 1,
|
|
||||||
description:
|
|
||||||
'Share of households that own their home, rent from a social landlord, or rent privately',
|
|
||||||
detail:
|
|
||||||
'Filter by one Census 2021 (TS054) housing tenure category at a time, e.g. the percentage of households that own their home.',
|
|
||||||
source: 'census-2021',
|
|
||||||
suffix: '%',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampTenureRange(value: [number, number], feature?: FeatureMeta): [number, number] {
|
|
||||||
const min = feature?.histogram?.min ?? feature?.min ?? 0;
|
|
||||||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
|
||||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TENURE_VARIANT_CONFIG: VariantFilterConfig = {
|
|
||||||
filterName: TENURE_FILTER_NAME,
|
|
||||||
featureNames: TENURE_FEATURE_NAMES,
|
|
||||||
dropdownLabelKey: 'filters.tenureType',
|
|
||||||
getFilterMeta: getTenureFilterMeta,
|
|
||||||
getDefaultFeatureName: getDefaultTenureFeatureName,
|
|
||||||
getFeatureName: getTenureFeatureName,
|
|
||||||
replaceFilterKeySelection: replaceTenureFilterKeySelection,
|
|
||||||
clampRange: clampTenureRange,
|
|
||||||
};
|
|
||||||
|
|
@ -5,11 +5,8 @@ import { parseUrlState, stateToParams } from './url-state';
|
||||||
import { INITIAL_VIEW_STATE } from './consts';
|
import { INITIAL_VIEW_STATE } from './consts';
|
||||||
import { createSchoolFilterKey } from './school-filter';
|
import { createSchoolFilterKey } from './school-filter';
|
||||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||||
import { createCrimeSeverityFilterKey } from './crime-severity-filter';
|
|
||||||
import { createElectionVoteShareFilterKey } from './election-filter';
|
import { createElectionVoteShareFilterKey } from './election-filter';
|
||||||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||||
import { createQualificationFilterKey } from './qualification-filter';
|
|
||||||
import { createTenureFilterKey } from './tenure-filter';
|
|
||||||
import {
|
import {
|
||||||
POI_COUNT_2KM_FILTER_NAME,
|
POI_COUNT_2KM_FILTER_NAME,
|
||||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||||
|
|
@ -408,8 +405,8 @@ describe('url-state', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips repeated specific crime filters with dedicated URL params', () => {
|
it('round-trips repeated specific crime filters with dedicated URL params', () => {
|
||||||
const burglary = createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 1);
|
const burglary = createSpecificCrimeFilterKey('Burglary (avg/yr)', 1);
|
||||||
const vehicleCrime = createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 2);
|
const vehicleCrime = createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2);
|
||||||
|
|
||||||
const params = stateToParams(
|
const params = stateToParams(
|
||||||
null,
|
null,
|
||||||
|
|
@ -423,8 +420,8 @@ describe('url-state', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(params.getAll('crime')).toEqual([
|
expect(params.getAll('crime')).toEqual([
|
||||||
'Burglary (/yr, 7y):0:5',
|
'Burglary (avg/yr):0:5',
|
||||||
'Vehicle crime (/yr, 7y):1:10',
|
'Vehicle crime (avg/yr):1:10',
|
||||||
]);
|
]);
|
||||||
expect(params.getAll('filter')).toEqual([]);
|
expect(params.getAll('filter')).toEqual([]);
|
||||||
|
|
||||||
|
|
@ -432,38 +429,8 @@ describe('url-state', () => {
|
||||||
const state = parseUrlState();
|
const state = parseUrlState();
|
||||||
|
|
||||||
expect(state.filters).toEqual({
|
expect(state.filters).toEqual({
|
||||||
[createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 0)]: [0, 5],
|
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 0)]: [0, 5],
|
||||||
[createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 1)]: [1, 10],
|
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 1)]: [1, 10],
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trips serious/minor crime severity filters with dedicated URL params', () => {
|
|
||||||
const serious = createCrimeSeverityFilterKey('Serious crime', 'Serious crime (/yr, 7y)', 0);
|
|
||||||
const minor = createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 1);
|
|
||||||
|
|
||||||
const params = stateToParams(
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
[serious]: [0, 12],
|
|
||||||
[minor]: [3, 40],
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
new Set(),
|
|
||||||
'area'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(params.getAll('crimeSeverity')).toEqual([
|
|
||||||
'Serious crime (/yr, 7y):0:12',
|
|
||||||
'Minor crime (/yr, 2y):3:40',
|
|
||||||
]);
|
|
||||||
expect(params.getAll('filter')).toEqual([]);
|
|
||||||
|
|
||||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
|
||||||
const state = parseUrlState();
|
|
||||||
|
|
||||||
expect(state.filters).toEqual({
|
|
||||||
[createCrimeSeverityFilterKey('Serious crime', 'Serious crime (/yr, 7y)', 0)]: [0, 12],
|
|
||||||
[createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 1)]: [3, 40],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -521,65 +488,6 @@ describe('url-state', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips repeated qualification filters with dedicated URL params', () => {
|
|
||||||
// "% Degree or higher" exercises the leading-`%` + space encoding path.
|
|
||||||
const degree = createQualificationFilterKey('% Degree or higher', 1);
|
|
||||||
const noQuals = createQualificationFilterKey('% No qualifications', 2);
|
|
||||||
|
|
||||||
const params = stateToParams(
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
[degree]: [20, 60],
|
|
||||||
[noQuals]: [0, 25],
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
new Set(),
|
|
||||||
'area'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(params.getAll('qualification')).toEqual([
|
|
||||||
'% Degree or higher:20:60',
|
|
||||||
'% No qualifications:0:25',
|
|
||||||
]);
|
|
||||||
expect(params.getAll('filter')).toEqual([]);
|
|
||||||
|
|
||||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
|
||||||
const state = parseUrlState();
|
|
||||||
|
|
||||||
expect(state.filters).toEqual({
|
|
||||||
[createQualificationFilterKey('% Degree or higher', 0)]: [20, 60],
|
|
||||||
[createQualificationFilterKey('% No qualifications', 1)]: [0, 25],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trips repeated tenure filters with dedicated URL params', () => {
|
|
||||||
// "% Owner occupied" exercises the leading-`%` + space encoding path.
|
|
||||||
const owner = createTenureFilterKey('% Owner occupied', 1);
|
|
||||||
const privateRent = createTenureFilterKey('% Private rent', 2);
|
|
||||||
|
|
||||||
const params = stateToParams(
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
[owner]: [20, 60],
|
|
||||||
[privateRent]: [0, 25],
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
new Set(),
|
|
||||||
'area'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(params.getAll('tenure')).toEqual(['% Owner occupied:20:60', '% Private rent:0:25']);
|
|
||||||
expect(params.getAll('filter')).toEqual([]);
|
|
||||||
|
|
||||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
|
||||||
const state = parseUrlState();
|
|
||||||
|
|
||||||
expect(state.filters).toEqual({
|
|
||||||
[createTenureFilterKey('% Owner occupied', 0)]: [20, 60],
|
|
||||||
[createTenureFilterKey('% Private rent', 1)]: [0, 25],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
|
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
|
||||||
const park = createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 3);
|
const park = createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 3);
|
||||||
const cafe = createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 4);
|
const cafe = createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 4);
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,6 @@ import {
|
||||||
isSpecificCrimeFeatureName,
|
isSpecificCrimeFeatureName,
|
||||||
isSpecificCrimeFilterName,
|
isSpecificCrimeFilterName,
|
||||||
} from './crime-filter';
|
} from './crime-filter';
|
||||||
import {
|
|
||||||
createCrimeSeverityFilterKey,
|
|
||||||
getCrimeSeverityFeatureName,
|
|
||||||
getCrimeSeverityFilterName,
|
|
||||||
isCrimeSeverityFeatureName,
|
|
||||||
isCrimeSeverityFilterName,
|
|
||||||
} from './crime-severity-filter';
|
|
||||||
import {
|
import {
|
||||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||||
createElectionVoteShareFilterKey,
|
createElectionVoteShareFilterKey,
|
||||||
|
|
@ -43,20 +36,6 @@ import {
|
||||||
isEthnicityFeatureName,
|
isEthnicityFeatureName,
|
||||||
isEthnicityFilterName,
|
isEthnicityFilterName,
|
||||||
} from './ethnicity-filter';
|
} from './ethnicity-filter';
|
||||||
import {
|
|
||||||
QUALIFICATIONS_FILTER_NAME,
|
|
||||||
createQualificationFilterKey,
|
|
||||||
getQualificationFeatureName,
|
|
||||||
isQualificationFeatureName,
|
|
||||||
isQualificationFilterName,
|
|
||||||
} from './qualification-filter';
|
|
||||||
import {
|
|
||||||
TENURE_FILTER_NAME,
|
|
||||||
createTenureFilterKey,
|
|
||||||
getTenureFeatureName,
|
|
||||||
isTenureFeatureName,
|
|
||||||
isTenureFilterName,
|
|
||||||
} from './tenure-filter';
|
|
||||||
import {
|
import {
|
||||||
POI_DISTANCE_FILTER_NAME,
|
POI_DISTANCE_FILTER_NAME,
|
||||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||||
|
|
@ -104,11 +83,8 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
||||||
const filterParams = params.getAll('filter');
|
const filterParams = params.getAll('filter');
|
||||||
const schoolParams = params.getAll('school');
|
const schoolParams = params.getAll('school');
|
||||||
const crimeParams = params.getAll('crime');
|
const crimeParams = params.getAll('crime');
|
||||||
const crimeSeverityParams = params.getAll('crimeSeverity');
|
|
||||||
const voteShareParams = params.getAll('voteShare');
|
const voteShareParams = params.getAll('voteShare');
|
||||||
const ethnicityParams = params.getAll('ethnicity');
|
const ethnicityParams = params.getAll('ethnicity');
|
||||||
const qualificationParams = params.getAll('qualification');
|
|
||||||
const tenureParams = params.getAll('tenure');
|
|
||||||
const amenityDistanceParams = params.getAll('amenityDistance');
|
const amenityDistanceParams = params.getAll('amenityDistance');
|
||||||
const transportDistanceParams = params.getAll('transportDistance');
|
const transportDistanceParams = params.getAll('transportDistance');
|
||||||
const amenityCount2KmParams = params.getAll('amenityCount2km');
|
const amenityCount2KmParams = params.getAll('amenityCount2km');
|
||||||
|
|
@ -117,11 +93,8 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
||||||
filterParams.length === 0 &&
|
filterParams.length === 0 &&
|
||||||
schoolParams.length === 0 &&
|
schoolParams.length === 0 &&
|
||||||
crimeParams.length === 0 &&
|
crimeParams.length === 0 &&
|
||||||
crimeSeverityParams.length === 0 &&
|
|
||||||
voteShareParams.length === 0 &&
|
voteShareParams.length === 0 &&
|
||||||
ethnicityParams.length === 0 &&
|
ethnicityParams.length === 0 &&
|
||||||
qualificationParams.length === 0 &&
|
|
||||||
tenureParams.length === 0 &&
|
|
||||||
amenityDistanceParams.length === 0 &&
|
amenityDistanceParams.length === 0 &&
|
||||||
transportDistanceParams.length === 0 &&
|
transportDistanceParams.length === 0 &&
|
||||||
amenityCount2KmParams.length === 0 &&
|
amenityCount2KmParams.length === 0 &&
|
||||||
|
|
@ -182,19 +155,6 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
||||||
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
|
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
|
||||||
});
|
});
|
||||||
|
|
||||||
crimeSeverityParams.forEach((entry, index) => {
|
|
||||||
const parts = entry.split(':');
|
|
||||||
if (parts.length < 3) return;
|
|
||||||
const featureName = parts.slice(0, -2).join(':');
|
|
||||||
const min = Number(parts[parts.length - 2]);
|
|
||||||
const max = Number(parts[parts.length - 1]);
|
|
||||||
const filterName = getCrimeSeverityFilterName(featureName);
|
|
||||||
if (!isCrimeSeverityFeatureName(featureName) || !filterName || isNaN(min) || isNaN(max)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
filters[createCrimeSeverityFilterKey(filterName, featureName, index)] = [min, max];
|
|
||||||
});
|
|
||||||
|
|
||||||
voteShareParams.forEach((entry, index) => {
|
voteShareParams.forEach((entry, index) => {
|
||||||
const parts = entry.split(':');
|
const parts = entry.split(':');
|
||||||
if (parts.length < 3) return;
|
if (parts.length < 3) return;
|
||||||
|
|
@ -219,30 +179,6 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
|
||||||
filters[createEthnicityFilterKey(featureName, index)] = [min, max];
|
filters[createEthnicityFilterKey(featureName, index)] = [min, max];
|
||||||
});
|
});
|
||||||
|
|
||||||
qualificationParams.forEach((entry, index) => {
|
|
||||||
const parts = entry.split(':');
|
|
||||||
if (parts.length < 3) return;
|
|
||||||
const featureName = parts.slice(0, -2).join(':');
|
|
||||||
const min = Number(parts[parts.length - 2]);
|
|
||||||
const max = Number(parts[parts.length - 1]);
|
|
||||||
if (!isQualificationFeatureName(featureName) || isNaN(min) || isNaN(max)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
filters[createQualificationFilterKey(featureName, index)] = [min, max];
|
|
||||||
});
|
|
||||||
|
|
||||||
tenureParams.forEach((entry, index) => {
|
|
||||||
const parts = entry.split(':');
|
|
||||||
if (parts.length < 3) return;
|
|
||||||
const featureName = parts.slice(0, -2).join(':');
|
|
||||||
const min = Number(parts[parts.length - 2]);
|
|
||||||
const max = Number(parts[parts.length - 1]);
|
|
||||||
if (!isTenureFeatureName(featureName) || isNaN(min) || isNaN(max)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
filters[createTenureFilterKey(featureName, index)] = [min, max];
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => {
|
const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => {
|
||||||
entries.forEach((entry, index) => {
|
entries.forEach((entry, index) => {
|
||||||
const parts = entry.split(':');
|
const parts = entry.split(':');
|
||||||
|
|
@ -465,13 +401,6 @@ export function stateToParams(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crimeSeverityFeatureName = getCrimeSeverityFeatureName(name);
|
|
||||||
if (crimeSeverityFeatureName && isCrimeSeverityFilterName(name)) {
|
|
||||||
const [min, max] = value as [number, number];
|
|
||||||
params.append('crimeSeverity', `${crimeSeverityFeatureName}:${min}:${max}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
|
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
|
||||||
if (electionVoteShareFeatureName && isElectionVoteShareFilterName(name)) {
|
if (electionVoteShareFeatureName && isElectionVoteShareFilterName(name)) {
|
||||||
const [min, max] = value as [number, number];
|
const [min, max] = value as [number, number];
|
||||||
|
|
@ -486,20 +415,6 @@ export function stateToParams(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualificationFeatureName = getQualificationFeatureName(name);
|
|
||||||
if (qualificationFeatureName && isQualificationFilterName(name)) {
|
|
||||||
const [min, max] = value as [number, number];
|
|
||||||
params.append('qualification', `${qualificationFeatureName}:${min}:${max}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenureFeatureName = getTenureFeatureName(name);
|
|
||||||
if (tenureFeatureName && isTenureFilterName(name)) {
|
|
||||||
const [min, max] = value as [number, number];
|
|
||||||
params.append('tenure', `${tenureFeatureName}:${min}:${max}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const amenityDistanceFeatureName = getPoiDistanceFeatureName(name);
|
const amenityDistanceFeatureName = getPoiDistanceFeatureName(name);
|
||||||
if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) {
|
if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) {
|
||||||
const [min, max] = value as [number, number];
|
const [min, max] = value as [number, number];
|
||||||
|
|
@ -607,11 +522,8 @@ export function summarizeParams(queryString: string): string {
|
||||||
const filterParams = params.getAll('filter');
|
const filterParams = params.getAll('filter');
|
||||||
const schoolParams = params.getAll('school');
|
const schoolParams = params.getAll('school');
|
||||||
const crimeParams = params.getAll('crime');
|
const crimeParams = params.getAll('crime');
|
||||||
const crimeSeverityParams = params.getAll('crimeSeverity');
|
|
||||||
const voteShareParams = params.getAll('voteShare');
|
const voteShareParams = params.getAll('voteShare');
|
||||||
const ethnicityParams = params.getAll('ethnicity');
|
const ethnicityParams = params.getAll('ethnicity');
|
||||||
const qualificationParams = params.getAll('qualification');
|
|
||||||
const tenureParams = params.getAll('tenure');
|
|
||||||
const amenityDistanceParams = params.getAll('amenityDistance');
|
const amenityDistanceParams = params.getAll('amenityDistance');
|
||||||
const transportDistanceParams = params.getAll('transportDistance');
|
const transportDistanceParams = params.getAll('transportDistance');
|
||||||
const amenityCount2KmParams = params.getAll('amenityCount2km');
|
const amenityCount2KmParams = params.getAll('amenityCount2km');
|
||||||
|
|
@ -620,11 +532,8 @@ export function summarizeParams(queryString: string): string {
|
||||||
filterParams.length > 0 ||
|
filterParams.length > 0 ||
|
||||||
schoolParams.length > 0 ||
|
schoolParams.length > 0 ||
|
||||||
crimeParams.length > 0 ||
|
crimeParams.length > 0 ||
|
||||||
crimeSeverityParams.length > 0 ||
|
|
||||||
voteShareParams.length > 0 ||
|
voteShareParams.length > 0 ||
|
||||||
ethnicityParams.length > 0 ||
|
ethnicityParams.length > 0 ||
|
||||||
qualificationParams.length > 0 ||
|
|
||||||
tenureParams.length > 0 ||
|
|
||||||
amenityDistanceParams.length > 0 ||
|
amenityDistanceParams.length > 0 ||
|
||||||
transportDistanceParams.length > 0 ||
|
transportDistanceParams.length > 0 ||
|
||||||
amenityCount2KmParams.length > 0 ||
|
amenityCount2KmParams.length > 0 ||
|
||||||
|
|
@ -635,11 +544,8 @@ export function summarizeParams(queryString: string): string {
|
||||||
const colonIdx = entry.indexOf(':');
|
const colonIdx = entry.indexOf(':');
|
||||||
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||||
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
|
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
|
||||||
if (isCrimeSeverityFeatureName(name)) return getCrimeSeverityFilterName(name) ?? name;
|
|
||||||
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
|
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
|
||||||
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
|
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
|
||||||
if (isQualificationFeatureName(name)) return QUALIFICATIONS_FILTER_NAME;
|
|
||||||
if (isTenureFeatureName(name)) return TENURE_FILTER_NAME;
|
|
||||||
const poiFilterName = getPoiFilterName(name);
|
const poiFilterName = getPoiFilterName(name);
|
||||||
if (poiFilterName) return poiFilterName;
|
if (poiFilterName) return poiFilterName;
|
||||||
return name;
|
return name;
|
||||||
|
|
@ -649,24 +555,12 @@ export function summarizeParams(queryString: string): string {
|
||||||
for (let i = 0; i < crimeParams.length; i++) {
|
for (let i = 0; i < crimeParams.length; i++) {
|
||||||
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
|
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
|
||||||
}
|
}
|
||||||
for (const entry of crimeSeverityParams) {
|
|
||||||
const colonIdx = entry.indexOf(':');
|
|
||||||
const featureName = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
|
||||||
const severityFilterName = getCrimeSeverityFilterName(featureName);
|
|
||||||
if (severityFilterName) filterNames.push(severityFilterName);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < voteShareParams.length; i++) {
|
for (let i = 0; i < voteShareParams.length; i++) {
|
||||||
filterNames.push(ELECTION_VOTE_SHARE_FILTER_NAME);
|
filterNames.push(ELECTION_VOTE_SHARE_FILTER_NAME);
|
||||||
}
|
}
|
||||||
for (let i = 0; i < ethnicityParams.length; i++) {
|
for (let i = 0; i < ethnicityParams.length; i++) {
|
||||||
filterNames.push(ETHNICITIES_FILTER_NAME);
|
filterNames.push(ETHNICITIES_FILTER_NAME);
|
||||||
}
|
}
|
||||||
for (let i = 0; i < qualificationParams.length; i++) {
|
|
||||||
filterNames.push(QUALIFICATIONS_FILTER_NAME);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < tenureParams.length; i++) {
|
|
||||||
filterNames.push(TENURE_FILTER_NAME);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < amenityDistanceParams.length; i++) {
|
for (let i = 0; i < amenityDistanceParams.length; i++) {
|
||||||
filterNames.push(POI_DISTANCE_FILTER_NAME);
|
filterNames.push(POI_DISTANCE_FILTER_NAME);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import type { FeatureMeta } from '../types';
|
|
||||||
|
|
||||||
/** i18n keys (typed for the strict `t()`) usable as a variant dropdown label. */
|
|
||||||
type VariantDropdownLabelKey =
|
|
||||||
| 'filters.crimeType'
|
|
||||||
| 'filters.qualificationLevel'
|
|
||||||
| 'filters.tenureType';
|
|
||||||
|
|
||||||
/** i18n keys (typed for the strict `t()`) usable as a window-toggle label. */
|
|
||||||
type VariantWindowLabelKey =
|
|
||||||
| 'filters.crimeWindow'
|
|
||||||
| 'filters.crimeWindow7y'
|
|
||||||
| 'filters.crimeWindow2y';
|
|
||||||
|
|
||||||
/** One option in a variant filter's secondary window/period toggle. */
|
|
||||||
export interface VariantWindowOption {
|
|
||||||
/** Stable id encoded inside the feature name (e.g. "7y"). */
|
|
||||||
id: string;
|
|
||||||
/** i18n key for the toggle button label. */
|
|
||||||
labelKey: VariantWindowLabelKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional secondary axis for a variant filter: the same variant measured over
|
|
||||||
* a different time window (e.g. crime rates over 7 vs 2 years). The dropdown
|
|
||||||
* still picks the variant; this toggle picks the window. Both ultimately select
|
|
||||||
* a single backend feature name, so switching either re-points the filter key.
|
|
||||||
*/
|
|
||||||
export interface VariantWindowConfig {
|
|
||||||
/** Toggle options in display order. */
|
|
||||||
options: VariantWindowOption[];
|
|
||||||
/** Optional i18n key for a small label above the toggle. */
|
|
||||||
labelKey?: VariantWindowLabelKey;
|
|
||||||
/** Window id of a feature name (e.g. "7y"), or null if unrecognized. */
|
|
||||||
getWindow: (featureName: string) => string | null;
|
|
||||||
/** The same variant's feature name in a different window. */
|
|
||||||
withWindow: (featureName: string, windowId: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared shape for the "pick one backend feature variant, filter its range"
|
|
||||||
* family of client-side aggregate filters. Several Census/police breakdowns
|
|
||||||
* (specific crimes, qualifications, …) are dozens of individual percentage
|
|
||||||
* features that would clutter the filter browser as separate sliders. Instead
|
|
||||||
* each is folded into a single named filter whose card carries a dropdown to
|
|
||||||
* choose the variant, reusing one component ([`VariantFilterCard`]).
|
|
||||||
*
|
|
||||||
* Each filter library (e.g. `crime-filter`, `qualification-filter`) exports a
|
|
||||||
* config so the card stays variant-agnostic.
|
|
||||||
*/
|
|
||||||
export interface VariantFilterConfig {
|
|
||||||
/** Display name of the aggregate filter, e.g. "Specific crimes". */
|
|
||||||
filterName: string;
|
|
||||||
/**
|
|
||||||
* Backend feature names enumerating the dropdown options, in display order.
|
|
||||||
* With a [`window`] config these are the canonical (default-window) names;
|
|
||||||
* the card re-points each to the currently selected window.
|
|
||||||
*/
|
|
||||||
featureNames: readonly string[];
|
|
||||||
/** i18n key for the dropdown label, e.g. "filters.crimeType". */
|
|
||||||
dropdownLabelKey: VariantDropdownLabelKey;
|
|
||||||
/** Synthetic [`FeatureMeta`] used for the aggregate filter's own label/info. */
|
|
||||||
getFilterMeta: (features: FeatureMeta[]) => FeatureMeta;
|
|
||||||
/** First selectable variant present in `features`, or null if none. */
|
|
||||||
getDefaultFeatureName: (features: FeatureMeta[]) => string | null;
|
|
||||||
/** Backend feature name for a filter key (or a bare feature name). */
|
|
||||||
getFeatureName: (name: string) => string | null;
|
|
||||||
/** Rewrite a filter key to point at a different variant, keeping its id. */
|
|
||||||
replaceFilterKeySelection: (key: string, featureName: string) => string;
|
|
||||||
/** Clamp a [min, max] range into the selected feature's bounds. */
|
|
||||||
clampRange: (value: [number, number], feature?: FeatureMeta) => [number, number];
|
|
||||||
/**
|
|
||||||
* Server-translatable source value for a dropdown option's label; the card
|
|
||||||
* renders `ts(...)` of it. Defaults to the feature name itself. Useful with a
|
|
||||||
* [`window`] toggle so the label can be the bare variant (no window suffix).
|
|
||||||
*/
|
|
||||||
getOptionLabelSource?: (featureName: string) => string;
|
|
||||||
/** Optional secondary window/period toggle (e.g. 7- vs 2-year crime rates). */
|
|
||||||
window?: VariantWindowConfig;
|
|
||||||
}
|
|
||||||
|
|
@ -310,47 +310,23 @@ export interface CrimeYearPoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CrimeYearStats {
|
export interface CrimeYearStats {
|
||||||
/** Bare crime type (e.g. "Burglary"). */
|
/** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */
|
||||||
name: string;
|
name: string;
|
||||||
points: CrimeYearPoint[];
|
points: CrimeYearPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CrimeAreaAverage {
|
export interface CrimeAreaAverage {
|
||||||
/** Full crime-feature name (e.g. "Burglary (/yr, 7y)"). */
|
/** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */
|
||||||
name: string;
|
name: string;
|
||||||
/** Exact national mean count. Preferred over the histogram-bin national
|
/** Exact national mean (avg/yr). Preferred over the histogram-bin national
|
||||||
* average for crime so all reference numbers share one estimator. */
|
* average for crime so all reference numbers share one estimator. */
|
||||||
national?: number;
|
national?: number;
|
||||||
/** Mean count across the selection's outcode. */
|
/** Mean headline rate (avg/yr) across the selection's outcode. */
|
||||||
outcode?: number;
|
outcode?: number;
|
||||||
/** Mean count across the selection's postcode sector. */
|
/** Mean headline rate (avg/yr) across the selection's postcode sector. */
|
||||||
sector?: number;
|
sector?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One individual police.uk crime, from /api/crime-records. */
|
|
||||||
export interface CrimeIncident {
|
|
||||||
/** "YYYY-MM". */
|
|
||||||
month: string;
|
|
||||||
/** Crime type (e.g. "Burglary"). */
|
|
||||||
type: string;
|
|
||||||
location?: string;
|
|
||||||
outcome?: string;
|
|
||||||
lat: number;
|
|
||||||
lon: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CrimeRecordsResponse {
|
|
||||||
records: CrimeIncident[];
|
|
||||||
total: number;
|
|
||||||
offset: number;
|
|
||||||
/**
|
|
||||||
* Server flag meaning "more pages exist beyond this one". The client derives
|
|
||||||
* pagination from `total` vs `records.length`, so this is currently not
|
|
||||||
* surfaced in the UI (kept to mirror the server response shape).
|
|
||||||
*/
|
|
||||||
truncated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilterExclusion {
|
export interface FilterExclusion {
|
||||||
name: string;
|
name: string;
|
||||||
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
||||||
|
|
@ -382,12 +358,9 @@ export interface HexagonStatsResponse {
|
||||||
/** Postcode sector (e.g. "E14 2") of the selection's central postcode, when
|
/** Postcode sector (e.g. "E14 2") of the selection's central postcode, when
|
||||||
* sector crime averages are available for it. */
|
* sector crime averages are available for it. */
|
||||||
crime_sector?: string;
|
crime_sector?: string;
|
||||||
/** Per-crime-feature average counts across the central postcode's outcode and
|
/** Per-crime-type average rates across the central postcode's outcode and
|
||||||
* sector, shown alongside the national average for each crime metric. */
|
* sector, shown alongside the national average for each crime metric. */
|
||||||
crime_area_averages?: CrimeAreaAverage[];
|
crime_area_averages?: CrimeAreaAverage[];
|
||||||
/** Total individual crime records (last 7 years) across the selection's
|
|
||||||
* postcodes — the count behind the "individual crimes" list. */
|
|
||||||
crime_total_records?: number;
|
|
||||||
central_postcode?: string;
|
central_postcode?: string;
|
||||||
/** Total usual residents (ONS Census 2021) across the postcodes in this
|
/** Total usual residents (ONS Census 2021) across the postcodes in this
|
||||||
* selection. Display-only; independent of active filters. */
|
* selection. Display-only; independent of active filters. */
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import polars as pl
|
||||||
|
|
||||||
NAPTAN_CSV_URL = "https://naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv"
|
NAPTAN_CSV_URL = "https://naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv"
|
||||||
TUBE_STATION_CATEGORY = "Tube station"
|
TUBE_STATION_CATEGORY = "Tube station"
|
||||||
DLR_STATION_CATEGORY = "DLR station"
|
|
||||||
TRAM_METRO_CATEGORY = "Tram & Metro stop"
|
TRAM_METRO_CATEGORY = "Tram & Metro stop"
|
||||||
TUBE_STATION_MERGE_RADIUS_DEGREES = 0.01
|
TUBE_STATION_MERGE_RADIUS_DEGREES = 0.01
|
||||||
|
|
||||||
|
|
@ -25,13 +24,6 @@ TUBE_STATION_MERGE_RADIUS_DEGREES = 0.01
|
||||||
# WM Metro, Blackpool Tramway, heritage railways, ...).
|
# WM Metro, Blackpool Tramway, heritage railways, ...).
|
||||||
LONDON_UNDERGROUND_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZLU"
|
LONDON_UNDERGROUND_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZLU"
|
||||||
|
|
||||||
# The Docklands Light Railway uses the analogous "ZZDL" system code (e.g.
|
|
||||||
# "9400ZZDLBEC" for Beckton). Like ZZLU it is unique to one network, so a
|
|
||||||
# TMU/MET stop carrying a ZZDL code is reclassified from the tram/metro family
|
|
||||||
# to its own "DLR station" category — restoring DLR to the train/tube station
|
|
||||||
# list and giving it the DLR roundel rather than a generic tram icon.
|
|
||||||
LONDON_DLR_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZDL"
|
|
||||||
|
|
||||||
|
|
||||||
STOP_TYPES = {
|
STOP_TYPES = {
|
||||||
"AIR": "Airport",
|
"AIR": "Airport",
|
||||||
|
|
@ -56,11 +48,10 @@ STOP_TYPES = {
|
||||||
"TXR": "Taxi rank",
|
"TXR": "Taxi rank",
|
||||||
# Tram/Metro/Underground: TMU is an entrance node, MET the station access
|
# Tram/Metro/Underground: TMU is an entrance node, MET the station access
|
||||||
# area. Both start as "Tram & Metro stop"; merged stations whose ATCO codes
|
# area. Both start as "Tram & Metro stop"; merged stations whose ATCO codes
|
||||||
# mark them as London Underground (ZZLU) or Docklands Light Railway (ZZDL)
|
# mark them as London Underground (ZZLU) are reclassified to "Tube station"
|
||||||
# are reclassified to "Tube station" / "DLR station" after dedup (see
|
# after dedup (see _deduplicate_station_areas). Heritage railways (RHDR,
|
||||||
# _deduplicate_station_areas). Heritage railways (RHDR, Severn Valley, ...)
|
# Severn Valley, ...) are TMU/MET in NaPTAN with no machine-readable
|
||||||
# are TMU/MET in NaPTAN with no machine-readable "heritage" flag, so they
|
# "heritage" flag, so they remain in "Tram & Metro stop".
|
||||||
# remain in "Tram & Metro stop".
|
|
||||||
"TMU": TRAM_METRO_CATEGORY,
|
"TMU": TRAM_METRO_CATEGORY,
|
||||||
"MET": TRAM_METRO_CATEGORY,
|
"MET": TRAM_METRO_CATEGORY,
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +68,6 @@ ENTRANCE_STOP_TYPES = {"RSE", "FTD", "TMU", "BCE"}
|
||||||
STATION_MERGE_CATEGORIES = {
|
STATION_MERGE_CATEGORIES = {
|
||||||
TRAM_METRO_CATEGORY,
|
TRAM_METRO_CATEGORY,
|
||||||
TUBE_STATION_CATEGORY,
|
TUBE_STATION_CATEGORY,
|
||||||
DLR_STATION_CATEGORY,
|
|
||||||
"Rail station",
|
"Rail station",
|
||||||
"Ferry",
|
"Ferry",
|
||||||
"Bus station",
|
"Bus station",
|
||||||
|
|
@ -276,7 +266,6 @@ class StationAccumulator:
|
||||||
lng_sum: float
|
lng_sum: float
|
||||||
entrance: bool = False
|
entrance: bool = False
|
||||||
is_lu: bool = False
|
is_lu: bool = False
|
||||||
is_dlr: bool = False
|
|
||||||
count: int = 1
|
count: int = 1
|
||||||
qualifier: str = ""
|
qualifier: str = ""
|
||||||
|
|
||||||
|
|
@ -303,7 +292,6 @@ class StationAccumulator:
|
||||||
self.lng_sum += float(row["lng"])
|
self.lng_sum += float(row["lng"])
|
||||||
self.count += 1
|
self.count += 1
|
||||||
self.is_lu = self.is_lu or bool(row.get("is_lu"))
|
self.is_lu = self.is_lu or bool(row.get("is_lu"))
|
||||||
self.is_dlr = self.is_dlr or bool(row.get("is_dlr"))
|
|
||||||
|
|
||||||
name = str(row["name"] or "")
|
name = str(row["name"] or "")
|
||||||
row_qualifier = station_name_qualifier(name)
|
row_qualifier = station_name_qualifier(name)
|
||||||
|
|
@ -330,19 +318,12 @@ class StationAccumulator:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def output_category(self) -> str:
|
def output_category(self) -> str:
|
||||||
# A merged tram/metro station is a genuine Tube/DLR station when ANY of
|
# A merged tram/metro station is a genuine Tube station when ANY of its
|
||||||
# its constituent nodes carries the matching ATCO system code. Checking
|
# constituent nodes carries a London Underground ATCO code. Checking
|
||||||
# the whole group (not just the winning node) matters because LU/DLR
|
# the whole group (not just the winning node) matters because LU
|
||||||
# entrance nodes often carry non-ZZLU/ZZDL codes (e.g. 4900VICT...).
|
# entrance nodes often carry non-ZZLU codes (e.g. 4900VICT...).
|
||||||
# A single node is never both (ZZLU vs ZZDL), but a co-located
|
|
||||||
# interchange (Bank, Stratford, Canning Town, West Ham) merges its LU
|
|
||||||
# and DLR halves into one group carrying both flags; Tube is checked
|
|
||||||
# first so these resolve to "Tube station" — their primary identity —
|
|
||||||
# leaving "DLR station" for the DLR-only stops the fix targets.
|
|
||||||
if self.category == TRAM_METRO_CATEGORY and self.is_lu:
|
if self.category == TRAM_METRO_CATEGORY and self.is_lu:
|
||||||
return TUBE_STATION_CATEGORY
|
return TUBE_STATION_CATEGORY
|
||||||
if self.category == TRAM_METRO_CATEGORY and self.is_dlr:
|
|
||||||
return DLR_STATION_CATEGORY
|
|
||||||
return self.category
|
return self.category
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -355,7 +336,6 @@ def _station_from_row(row: dict[str, object]) -> StationAccumulator:
|
||||||
lng_sum=float(row["lng"]),
|
lng_sum=float(row["lng"]),
|
||||||
entrance=bool(row.get("entrance")),
|
entrance=bool(row.get("entrance")),
|
||||||
is_lu=bool(row.get("is_lu")),
|
is_lu=bool(row.get("is_lu")),
|
||||||
is_dlr=bool(row.get("is_dlr")),
|
|
||||||
qualifier=station_name_qualifier(str(row["name"] or "")),
|
qualifier=station_name_qualifier(str(row["name"] or "")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -443,8 +423,7 @@ def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
|
||||||
station by normalized name + area, with the primary station/terminal node
|
station by normalized name + area, with the primary station/terminal node
|
||||||
(e.g. RLY, FER, MET, BST) winning over an entrance node (RSE, FTD, TMU,
|
(e.g. RLY, FER, MET, BST) winning over an entrance node (RSE, FTD, TMU,
|
||||||
BCE). Merged tram/metro stations with a London Underground ATCO code in
|
BCE). Merged tram/metro stations with a London Underground ATCO code in
|
||||||
the group become "Tube station"; those with a Docklands Light Railway code
|
the group become "Tube station". Other stops are deduplicated by exact
|
||||||
become "DLR station". Other stops are deduplicated by exact
|
|
||||||
name+category+locality.
|
name+category+locality.
|
||||||
"""
|
"""
|
||||||
station = df.filter(pl.col("category").is_in(list(STATION_MERGE_CATEGORIES)))
|
station = df.filter(pl.col("category").is_in(list(STATION_MERGE_CATEGORIES)))
|
||||||
|
|
@ -511,10 +490,6 @@ def download_naptan(output: Path) -> None:
|
||||||
.str.contains(LONDON_UNDERGROUND_ATCO_PATTERN)
|
.str.contains(LONDON_UNDERGROUND_ATCO_PATTERN)
|
||||||
.fill_null(False)
|
.fill_null(False)
|
||||||
.alias("is_lu"),
|
.alias("is_lu"),
|
||||||
pl.col("ATCOCode")
|
|
||||||
.str.contains(LONDON_DLR_ATCO_PATTERN)
|
|
||||||
.fill_null(False)
|
|
||||||
.alias("is_dlr"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
before = len(df)
|
before = len(df)
|
||||||
|
|
|
||||||
|
|
@ -651,12 +651,7 @@ def _naptan_dlr_stations(naptan_path: Path) -> list[dict]:
|
||||||
match = _DLR_CODE_RE.search(atco_id)
|
match = _DLR_CODE_RE.search(atco_id)
|
||||||
if not match:
|
if not match:
|
||||||
continue
|
continue
|
||||||
if row["category"] not in {
|
if row["category"] not in {"Tube station", "Tram & Metro stop", "Rail station"}:
|
||||||
"Tube station",
|
|
||||||
"DLR station",
|
|
||||||
"Tram & Metro stop",
|
|
||||||
"Rail station",
|
|
||||||
}:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
code = match.group(1)
|
code = match.group(1)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import polars as pl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pipeline.download.naptan import (
|
from pipeline.download.naptan import (
|
||||||
DLR_STATION_CATEGORY,
|
|
||||||
TRAM_METRO_CATEGORY,
|
TRAM_METRO_CATEGORY,
|
||||||
TUBE_STATION_CATEGORY,
|
TUBE_STATION_CATEGORY,
|
||||||
canonical_station_name,
|
canonical_station_name,
|
||||||
|
|
@ -132,48 +131,6 @@ def test_deduplicate_naptan_splits_london_underground_from_tram_metro():
|
||||||
assert tram["id"][0] == "9400ZZMAWST"
|
assert tram["id"][0] == "9400ZZMAWST"
|
||||||
|
|
||||||
|
|
||||||
def test_deduplicate_naptan_splits_dlr_from_tram_metro():
|
|
||||||
# DLR stations arrive as the tram/metro family (MET station node + TMU
|
|
||||||
# entrance). The Beckton group carries a 9400ZZDL station node, so the
|
|
||||||
# merged POI is reclassified to "DLR station" even though its entrance
|
|
||||||
# carries a non-ZZDL ATCO code; the Metrolink group stays "Tram & Metro
|
|
||||||
# stop".
|
|
||||||
df = pl.DataFrame(
|
|
||||||
{
|
|
||||||
"id": [
|
|
||||||
"9400ZZDLBEC",
|
|
||||||
"490000254S",
|
|
||||||
"9400ZZMAWST",
|
|
||||||
],
|
|
||||||
"name": [
|
|
||||||
"Beckton DLR Station",
|
|
||||||
"Beckton",
|
|
||||||
"Weaste (Manchester Metrolink)",
|
|
||||||
],
|
|
||||||
"category": [TRAM_METRO_CATEGORY] * 3,
|
|
||||||
"lat": [51.5148, 51.5148, 53.4826],
|
|
||||||
"lng": [0.0613, 0.0614, -2.3087],
|
|
||||||
"locality": [None, None, None],
|
|
||||||
"entrance": [False, True, False],
|
|
||||||
"is_lu": [False, False, False],
|
|
||||||
"is_dlr": [True, False, False],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = deduplicate_naptan(df).sort("category")
|
|
||||||
|
|
||||||
assert len(result) == 2
|
|
||||||
assert result["category"].to_list() == [
|
|
||||||
DLR_STATION_CATEGORY,
|
|
||||||
TRAM_METRO_CATEGORY,
|
|
||||||
]
|
|
||||||
dlr = result.filter(pl.col("category") == DLR_STATION_CATEGORY)
|
|
||||||
# The station node (not the entrance) represents the merged POI.
|
|
||||||
assert dlr["id"][0] == "9400ZZDLBEC"
|
|
||||||
tram = result.filter(pl.col("category") == TRAM_METRO_CATEGORY)
|
|
||||||
assert tram["id"][0] == "9400ZZMAWST"
|
|
||||||
|
|
||||||
|
|
||||||
def test_deduplicate_naptan_merges_bus_station_bays_and_entrances():
|
def test_deduplicate_naptan_merges_bus_station_bays_and_entrances():
|
||||||
# BCS bays and a BCE entrance of one bus station collapse to a single POI
|
# BCS bays and a BCE entrance of one bus station collapse to a single POI
|
||||||
# represented by a non-entrance node; a different bus station in another
|
# represented by a non-entrance node; a different bus station in another
|
||||||
|
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
"""Precompute per-outcode / per-postcode-sector / national mean headline crime
|
|
||||||
counts for the right pane's area comparison.
|
|
||||||
|
|
||||||
The right pane shows each crime metric next to its area context: the mean
|
|
||||||
average-annual count (``"X (/yr, 7y)"``) across the selection's postcode sector (e.g.
|
|
||||||
``"E14 2"``), its outcode (e.g. ``"E14"``), and the nation. Crime is constant
|
|
||||||
within a postcode (the merge keys it on the postcode), so each postcode
|
|
||||||
contributes its single value weighted by how many properties sit in it — keeping
|
|
||||||
every scope on the same property-weighted basis as the per-selection mean, so the
|
|
||||||
four numbers (this selection / sector / outcode / nation) are directly
|
|
||||||
comparable. The national figure here is an EXACT property-weighted mean, which is
|
|
||||||
why it overrides the upward-biased histogram-bin national average for crime.
|
|
||||||
|
|
||||||
This used to be recomputed inside the server on every boot from the loaded
|
|
||||||
property matrix. It is a pure function of the two merge outputs, so it belongs in
|
|
||||||
the data build; the server now just loads the parquet this writes. Reading the
|
|
||||||
crime values from ``postcode.parquet`` and the per-postcode property weights from
|
|
||||||
``properties.parquet`` mirrors exactly the two inputs the server loads, so the
|
|
||||||
result matches what the server used to compute (minus its u16 quantization loss).
|
|
||||||
|
|
||||||
Output schema — one row per area:
|
|
||||||
|
|
||||||
scope : ``"national"`` | ``"outcode"`` | ``"sector"``
|
|
||||||
area : the outcode (``"E14"``) / sector (``"E14 2"``);
|
|
||||||
``""`` for the single national row
|
|
||||||
``<type> (/yr, 7y|2y)`` : Float32 property-weighted mean crime count per year
|
|
||||||
(null = the scope has no data for that type)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
# Filterable crime columns are the average-annual incident counts and carry this
|
|
||||||
# marker in postcode.parquet (e.g. "Burglary (/yr, 7y)"). We average those. The
|
|
||||||
# full column name is kept; the server discovers and keys area averages by the
|
|
||||||
# same names.
|
|
||||||
COUNT_MARKER = " (/yr, "
|
|
||||||
|
|
||||||
# `scope` discriminator values. The server's loader dispatches on these.
|
|
||||||
SCOPE_NATIONAL = "national"
|
|
||||||
SCOPE_OUTCODE = "outcode"
|
|
||||||
SCOPE_SECTOR = "sector"
|
|
||||||
|
|
||||||
# Area label on the national row — it spans the whole country, so it has no code.
|
|
||||||
NATIONAL_AREA = ""
|
|
||||||
|
|
||||||
# Both merge outputs key on the canonical NSPL `pcds` postcode (spaced, e.g.
|
|
||||||
# "E14 2DG").
|
|
||||||
POSTCODE_COLUMN = "Postcode"
|
|
||||||
|
|
||||||
# Internal weight / split columns dropped before write.
|
|
||||||
_WEIGHT_COLUMN = "_weight"
|
|
||||||
_OUTCODE_COLUMN = "_outcode"
|
|
||||||
_SECTOR_COLUMN = "_sector"
|
|
||||||
|
|
||||||
|
|
||||||
def _crime_columns(columns: list[str]) -> list[str]:
|
|
||||||
crime_cols = [name for name in columns if COUNT_MARKER in name]
|
|
||||||
if not crime_cols:
|
|
||||||
raise ValueError(
|
|
||||||
f"postcode parquet has no '*{COUNT_MARKER}*' crime count columns to average"
|
|
||||||
)
|
|
||||||
return crime_cols
|
|
||||||
|
|
||||||
|
|
||||||
def _weighted_mean(column: str) -> pl.Expr:
|
|
||||||
"""Property-weighted mean of ``column`` that excludes nulls from BOTH the
|
|
||||||
value sum and the weight.
|
|
||||||
|
|
||||||
A null crime value is a genuine gap (the postcode's police force published no
|
|
||||||
usable data), not zero crime, so it must dilute neither the numerator nor the
|
|
||||||
denominator — exactly as the server's former estimator skipped NaN values.
|
|
||||||
Yields null when no postcode in the group has data for this type.
|
|
||||||
"""
|
|
||||||
weight = pl.col(_WEIGHT_COLUMN)
|
|
||||||
numerator = (pl.col(column) * weight).sum()
|
|
||||||
denominator = weight.filter(pl.col(column).is_not_null()).sum()
|
|
||||||
return (
|
|
||||||
pl.when(denominator > 0)
|
|
||||||
.then(numerator / denominator)
|
|
||||||
.otherwise(None)
|
|
||||||
.cast(pl.Float32)
|
|
||||||
.alias(column)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_area_crime_averages(
|
|
||||||
postcodes: pl.LazyFrame, properties: pl.LazyFrame
|
|
||||||
) -> pl.DataFrame:
|
|
||||||
"""Build the national / per-outcode / per-sector crime-average table.
|
|
||||||
|
|
||||||
``postcodes`` is the merge's postcode output (one row per active postcode,
|
|
||||||
carrying the ``"* (/yr, *)"`` crime count columns); ``properties`` is the merge's
|
|
||||||
per-property output, used only to weight each postcode by its property count.
|
|
||||||
"""
|
|
||||||
crime_cols = _crime_columns(postcodes.collect_schema().names())
|
|
||||||
|
|
||||||
# Property weight per postcode = how many property rows the server indexes
|
|
||||||
# under it. The inner join keeps only postcodes that actually carry
|
|
||||||
# properties, matching the server's per-postcode row index (a postcode with
|
|
||||||
# no properties never contributed to any average).
|
|
||||||
weights = properties.group_by(POSTCODE_COLUMN).agg(pl.len().alias(_WEIGHT_COLUMN))
|
|
||||||
|
|
||||||
# Outcode / sector of the spaced `pcds` postcode, matching the server's
|
|
||||||
# postcode_outcode / postcode_sector (split on the single space; sector =
|
|
||||||
# outcode + space + first inward character). Null where the form has no
|
|
||||||
# inward code, so such rows drop out of the per-area groups.
|
|
||||||
parts = pl.col(POSTCODE_COLUMN).str.splitn(" ", 2).struct
|
|
||||||
outward = parts.field("field_0")
|
|
||||||
inward = parts.field("field_1")
|
|
||||||
base = (
|
|
||||||
postcodes.select(POSTCODE_COLUMN, *crime_cols)
|
|
||||||
.join(weights, on=POSTCODE_COLUMN, how="inner")
|
|
||||||
.with_columns(
|
|
||||||
pl.when(inward.is_not_null())
|
|
||||||
.then(outward)
|
|
||||||
.otherwise(None)
|
|
||||||
.alias(_OUTCODE_COLUMN),
|
|
||||||
pl.when(inward.str.len_chars() >= 1)
|
|
||||||
.then(outward + pl.lit(" ") + inward.str.slice(0, 1))
|
|
||||||
.otherwise(None)
|
|
||||||
.alias(_SECTOR_COLUMN),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
mean_exprs = [_weighted_mean(column) for column in crime_cols]
|
|
||||||
|
|
||||||
national = base.select(
|
|
||||||
pl.lit(SCOPE_NATIONAL).alias("scope"),
|
|
||||||
pl.lit(NATIONAL_AREA).alias("area"),
|
|
||||||
*mean_exprs,
|
|
||||||
)
|
|
||||||
by_outcode = (
|
|
||||||
base.drop_nulls(_OUTCODE_COLUMN)
|
|
||||||
.group_by(_OUTCODE_COLUMN)
|
|
||||||
.agg(mean_exprs)
|
|
||||||
.select(
|
|
||||||
pl.lit(SCOPE_OUTCODE).alias("scope"),
|
|
||||||
pl.col(_OUTCODE_COLUMN).alias("area"),
|
|
||||||
*crime_cols,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
by_sector = (
|
|
||||||
base.drop_nulls(_SECTOR_COLUMN)
|
|
||||||
.group_by(_SECTOR_COLUMN)
|
|
||||||
.agg(mean_exprs)
|
|
||||||
.select(
|
|
||||||
pl.lit(SCOPE_SECTOR).alias("scope"),
|
|
||||||
pl.col(_SECTOR_COLUMN).alias("area"),
|
|
||||||
*crime_cols,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = pl.concat([national, by_outcode, by_sector], how="vertical").collect(
|
|
||||||
engine="streaming"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Drop per-area rows where every crime type is null: the server only created
|
|
||||||
# a map entry once a scope had at least one finite value, so an all-null
|
|
||||||
# outcode/sector reported no code at all. The national row is always kept (it
|
|
||||||
# always has data, and is emitted even for areas absent from both maps).
|
|
||||||
has_any = pl.any_horizontal(pl.col(column).is_not_null() for column in crime_cols)
|
|
||||||
return result.filter((pl.col("scope") == SCOPE_NATIONAL) | has_any)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"Precompute national / per-outcode / per-sector mean headline crime "
|
|
||||||
"counts from the merge outputs"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--postcodes",
|
|
||||||
type=Path,
|
|
||||||
required=True,
|
|
||||||
help="postcode.parquet (area features, incl. the '* (/yr, *)' crime columns)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--properties",
|
|
||||||
type=Path,
|
|
||||||
required=True,
|
|
||||||
help="properties.parquet (per-property rows; supplies postcode property weights)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--output",
|
|
||||||
type=Path,
|
|
||||||
required=True,
|
|
||||||
help="Output area_crime_averages.parquet path",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
result = compute_area_crime_averages(
|
|
||||||
pl.scan_parquet(args.postcodes), pl.scan_parquet(args.properties)
|
|
||||||
)
|
|
||||||
outcodes = result.filter(pl.col("scope") == SCOPE_OUTCODE).height
|
|
||||||
sectors = result.filter(pl.col("scope") == SCOPE_SECTOR).height
|
|
||||||
print(
|
|
||||||
f"Area crime averages: {result.height} rows "
|
|
||||||
f"({outcodes} outcodes, {sectors} sectors, "
|
|
||||||
f"{len(_crime_columns(result.columns))} crime types)"
|
|
||||||
)
|
|
||||||
|
|
||||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
result.write_parquet(args.output, compression="zstd")
|
|
||||||
print(f"Saved to {args.output}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -2,26 +2,22 @@
|
||||||
|
|
||||||
Instead of attributing each incident to its published LSOA code, this transform
|
Instead of attributing each incident to its published LSOA code, this transform
|
||||||
counts the anonymised incident *points* that fall within ``buffer_m`` (default
|
counts the anonymised incident *points* that fall within ``buffer_m`` (default
|
||||||
50m) of each postcode's boundary polygon (the polygon buffered outward). A point
|
100m) of each postcode's boundary polygon (the polygon buffered outward). A point
|
||||||
inside several overlapping buffers counts for each postcode -- the same
|
inside several overlapping buffers counts for each postcode -- the same
|
||||||
multiplicity the tree-density filter uses for features near more than one
|
multiplicity the tree-density filter uses for features near more than one
|
||||||
postcode. The 50m buffer deliberately smooths police.uk's snap-to-grid
|
postcode. The wide 100m buffer deliberately smooths police.uk's snap-to-grid
|
||||||
coordinates, which would otherwise make the count hypersensitive to which side of
|
coordinates, which would otherwise make the count hypersensitive to which side of
|
||||||
a narrow line a shared "map point" anchor happened to land on.
|
a narrow line a shared "map point" anchor happened to land on.
|
||||||
|
|
||||||
One figure is produced for every postcode and crime type, averaged over the
|
Counts are **area-normalised**: each postcode's count is divided by its buffered
|
||||||
**last 7 years** and the **last 2 years**:
|
catchment area and rescaled by the median catchment area, so the metric reflects
|
||||||
|
crime *density* rather than how much ground the buffer sweeps (a median-sized
|
||||||
* the **average annual incident count** -- ``sum(counts in covered window) * 12 /
|
catchment is left unchanged; a large rural postcode is no longer inflated simply
|
||||||
covered_months`` -- the raw, absolute number of recorded incidents per year,
|
for covering more of the map). Normalising by the buffered area -- the region
|
||||||
with no per-area or per-capita normalisation. A covered postcode with no
|
that actually collects points -- rather than the raw polygon keeps tiny unit
|
||||||
incidents of a type gets ``0``; a postcode whose force never published in the
|
postcodes from being over-inflated by the fixed buffer-ring floor. NOTE: this is
|
||||||
window, or whose geometry is unusable, gets a *null* (unknown, never a zero).
|
an incident *density of the surrounding streets*, not a per-resident risk --
|
||||||
|
zero-resident commercial centres (Soho, retail parks) legitimately rank high.
|
||||||
This figure is what the server exposes as the filterable crime feature -- the
|
|
||||||
headline metric in the right pane. (An earlier version divided it by an ambient
|
|
||||||
daytime population to get a per-1,000-people rate; that was hard to read, so the
|
|
||||||
absolute per-year count is now used directly.)
|
|
||||||
|
|
||||||
**Force-coverage calendar.** police.uk has multi-year publication gaps for whole
|
**Force-coverage calendar.** police.uk has multi-year publication gaps for whole
|
||||||
forces (Greater Manchester has published nothing between 2019-07 and the present
|
forces (Greater Manchester has published nothing between 2019-07 and the present
|
||||||
|
|
@ -33,25 +29,28 @@ computed against the months the postcode's own force actually published:
|
||||||
matched it (BTP, which reports nationwide, is excluded from the vote);
|
matched it (BTP, which reports nationwide, is excluded from the vote);
|
||||||
postcodes with no incidents inherit their outcode's majority force, then the
|
postcodes with no incidents inherit their outcode's majority force, then the
|
||||||
national modal force.
|
national modal force.
|
||||||
* A window's average pools the counts over the force's *covered* months inside
|
* The headline ``"{type} (avg/yr)"`` is the POOLED annualised rate over the
|
||||||
that window and annualises by those months, so a coverage gap shrinks the data
|
force's covered months: ``sum(counts in covered years) * 12 / covered_months``.
|
||||||
rather than reading as a low-crime dip.
|
Years in which the force published nothing contribute neither incidents nor
|
||||||
* The by-year series only emits bars for years with at least ``min_bar_months``
|
months, so a coverage gap no longer reads as a low-crime period. (Pooling over
|
||||||
covered months (default 6).
|
covered months also fixes the old "divide by years-with-incidents" headline,
|
||||||
|
which inflated sporadic categories by up to ~15x.)
|
||||||
|
* The by-year series only emits bars for years with at least
|
||||||
|
``min_bar_months`` covered months (default 6): annualising a single observed
|
||||||
|
month x12 produced misleading spikes. Each bar is scaled by the force's
|
||||||
|
covered months in that year, not the global month calendar.
|
||||||
* ``covered_years`` (list[struct{year, months}]) is written for every postcode
|
* ``covered_years`` (list[struct{year, months}]) is written for every postcode
|
||||||
so the server can tell "covered, zero crime" from "no data".
|
so the server can tell "covered, zero crime" (year listed, no bar) from "no
|
||||||
|
data" (year absent) instead of charting gaps as zeros.
|
||||||
* Postcodes whose boundary buffer is unusable (broken geometry) get null
|
* Postcodes whose boundary buffer is unusable (broken geometry) get null
|
||||||
figures and an empty ``covered_years`` -- unknown, not zero.
|
headline columns and an empty ``covered_years`` -- unknown, not zero.
|
||||||
|
|
||||||
Outputs, all keyed on ``postcode``:
|
Outputs mirror the old LSOA transform's shape but are keyed on ``postcode``:
|
||||||
|
|
||||||
* ``crime_by_postcode.parquet`` -- ``"{type} (/yr, 7y)"`` / ``"{type} (/yr, 2y)"``
|
* ``crime_by_postcode.parquet`` -- ``postcode`` + ``"{type} (avg/yr)"`` columns.
|
||||||
average-annual-count columns (the filterable crime features).
|
* ``crime_by_postcode_by_year.parquet`` -- one row per postcode: ``postcode`` +
|
||||||
* ``crime_by_postcode_by_year.parquet`` -- ``covered_years`` + nested
|
``covered_years`` + nested ``"{type} (by year)"`` ``list[struct{year, count}]``
|
||||||
``"{type} (by year)"`` ``list[struct{year, count}]`` per-year raw counts.
|
columns, with Serious/Minor rollups.
|
||||||
* ``crime_records.parquet`` -- one row per counted incident over the last 7
|
|
||||||
years (``postcode`` + month/type/location/outcome/lat/lon), sorted by
|
|
||||||
postcode so the server can slice each postcode's incidents directly.
|
|
||||||
|
|
||||||
Caveat: police.uk coordinates are snapped to a fixed set of anonymous "map
|
Caveat: police.uk coordinates are snapped to a fixed set of anonymous "map
|
||||||
points", not true locations, and a share of rows have no coordinate at all
|
points", not true locations, and a share of rows have no coordinate at all
|
||||||
|
|
@ -64,7 +63,6 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -82,36 +80,22 @@ from pipeline.transform.postcode_boundaries.loader import load_postcode_polygons
|
||||||
|
|
||||||
# Serious types first so column order is stable and self-documenting.
|
# Serious types first so column order is stable and self-documenting.
|
||||||
ALL_CRIME_TYPES: tuple[str, ...] = SERIOUS_CRIME_TYPES + MINOR_CRIME_TYPES
|
ALL_CRIME_TYPES: tuple[str, ...] = SERIOUS_CRIME_TYPES + MINOR_CRIME_TYPES
|
||||||
# Output type axis = the 14 leaf types plus the two rollups, in that order.
|
|
||||||
ROLLUP_TYPES: tuple[str, ...] = ("Serious crime", "Minor crime")
|
|
||||||
ALL_OUTPUT_TYPES: tuple[str, ...] = ALL_CRIME_TYPES + ROLLUP_TYPES
|
|
||||||
|
|
||||||
DEFAULT_BUFFER_M = 50.0
|
DEFAULT_BUFFER_M = 100.0
|
||||||
MONTH_DIR_RE = re.compile(r"^\d{4}-\d{2}$")
|
MONTH_DIR_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||||
STREET_CSV_NAME_RE = re.compile(r"^(\d{4}-\d{2})-(.+)-street\.csv$")
|
STREET_CSV_NAME_RE = re.compile(r"^(\d{4}-\d{2})-(.+)-street\.csv$")
|
||||||
|
|
||||||
# Trailing-window definitions, in (label, years) form. Each window's average is
|
|
||||||
# pooled over the force's covered months inside the window; one average-annual-
|
|
||||||
# count column (`(/yr, <label>)`) — the filterable crime feature — is emitted per
|
|
||||||
# window.
|
|
||||||
WINDOWS: tuple[tuple[str, int], ...] = (("7y", 7), ("2y", 2))
|
|
||||||
|
|
||||||
# Per-incident records cover the longest window's calendar years so the
|
|
||||||
# "individual crimes" list reconciles exactly with the headline counts: every
|
|
||||||
# record is an incident inside that window and vice versa. The headline counts
|
|
||||||
# are pooled per calendar year, so the records window must be calendar-year
|
|
||||||
# aligned too -- a rolling month span (e.g. a fixed 84 months) would include
|
|
||||||
# incidents from a year the headline excludes when the latest month is mid-year.
|
|
||||||
RECORDS_WINDOW_YEARS = max(win_years for _, win_years in WINDOWS)
|
|
||||||
|
|
||||||
# Minimum covered months for a year to get a by-year chart bar (and to be
|
# Minimum covered months for a year to get a by-year chart bar (and to be
|
||||||
# listed in `covered_years`). Annualising fewer observed months (x12 from a
|
# listed in `covered_years`). Annualising fewer observed months (x12 from a
|
||||||
# single month at the worst) produces bars dominated by noise. Six months keeps
|
# single month at the worst) produces bars dominated by noise, and the first
|
||||||
# the annualisation factor <= 2.
|
# (2010: one month) and current partial year would otherwise always chart as
|
||||||
|
# spikes/dips. Six months keeps the annualisation factor <= 2.
|
||||||
MIN_BAR_MONTHS = 6
|
MIN_BAR_MONTHS = 6
|
||||||
|
|
||||||
# Forces that report nationwide rather than policing a territory. They never
|
# Forces that report nationwide rather than policing a territory. They never
|
||||||
# define a postcode's home force, but their incidents still count.
|
# define a postcode's home force (their publication calendar says nothing about
|
||||||
|
# whether the *territorial* force covering the postcode published), but their
|
||||||
|
# incidents still count toward whichever postcodes they fall in.
|
||||||
NON_TERRITORIAL_FORCES = frozenset({"btp"})
|
NON_TERRITORIAL_FORCES = frozenset({"btp"})
|
||||||
|
|
||||||
COVERAGE_COLUMN = "covered_years"
|
COVERAGE_COLUMN = "covered_years"
|
||||||
|
|
@ -122,14 +106,8 @@ LON_BOUNDS = (-9.5, 2.5)
|
||||||
LAT_BOUNDS = (49.0, 61.5)
|
LAT_BOUNDS = (49.0, 61.5)
|
||||||
|
|
||||||
# Read CSVs in chunks of files to bound peak memory while keeping the STRtree
|
# Read CSVs in chunks of files to bound peak memory while keeping the STRtree
|
||||||
# query vectorised over a useful number of points. Kept modest because the
|
# query vectorised over a useful number of points.
|
||||||
# in-window batches also materialise the per-incident record strings.
|
_CSV_BATCH = 64
|
||||||
_CSV_BATCH = 32
|
|
||||||
|
|
||||||
|
|
||||||
def crime_column(type_name: str, window: str) -> str:
|
|
||||||
"""Filterable average-annual-count column, e.g. ``"Burglary (/yr, 7y)"``."""
|
|
||||||
return f"{type_name} (/yr, {window})"
|
|
||||||
|
|
||||||
|
|
||||||
def _force_calendar(
|
def _force_calendar(
|
||||||
|
|
@ -137,9 +115,12 @@ def _force_calendar(
|
||||||
) -> tuple[list[int], list[str], np.ndarray]:
|
) -> tuple[list[int], list[str], np.ndarray]:
|
||||||
"""Derive the per-force publication calendar from the CSV paths.
|
"""Derive the per-force publication calendar from the CSV paths.
|
||||||
|
|
||||||
File presence IS the coverage signal: a (force, month) with no file
|
Each police.uk file lives under ``{crime_dir}/{YYYY-MM}/{YYYY-MM}-{force}-
|
||||||
published nothing. Returns the sorted distinct years, the force slugs
|
street.csv`` and holds that force's incidents for that month, so file
|
||||||
(sorted), and ``months_in_year_force`` of shape (n_forces, n_years).
|
presence IS the coverage signal: a (force, month) with no file published
|
||||||
|
nothing. Returns the sorted distinct years, the force slugs (sorted), and
|
||||||
|
``months_in_year_force`` of shape (n_forces, n_years) -- how many months
|
||||||
|
each force published in each year.
|
||||||
"""
|
"""
|
||||||
month_force: set[tuple[str, str]] = set()
|
month_force: set[tuple[str, str]] = set()
|
||||||
for path in csvs:
|
for path in csvs:
|
||||||
|
|
@ -161,6 +142,9 @@ def _force_calendar(
|
||||||
for month, force in month_force:
|
for month, force in month_force:
|
||||||
months_in_year_force[force_to_idx[force], year_to_idx[int(month[:4])]] += 1
|
months_in_year_force[force_to_idx[force], year_to_idx[int(month[:4])]] += 1
|
||||||
|
|
||||||
|
# Surface coverage gaps loudly: any territorial force missing months inside
|
||||||
|
# the global publication window is exactly the data hole the coverage
|
||||||
|
# masking exists for.
|
||||||
all_months = {month for month, _ in month_force}
|
all_months = {month for month, _ in month_force}
|
||||||
for force in forces:
|
for force in forces:
|
||||||
published = {m for m, f in month_force if f == force}
|
published = {m for m, f in month_force if f == force}
|
||||||
|
|
@ -175,25 +159,22 @@ def _force_calendar(
|
||||||
|
|
||||||
def _build_tree(
|
def _build_tree(
|
||||||
polygons: np.ndarray, buffer_m: float
|
polygons: np.ndarray, buffer_m: float
|
||||||
) -> tuple[shapely.STRtree, np.ndarray]:
|
) -> tuple[np.ndarray, shapely.STRtree]:
|
||||||
"""Buffer postcode polygons outward by ``buffer_m`` and index them.
|
"""Buffer postcode polygons outward by ``buffer_m`` and index them.
|
||||||
|
|
||||||
Buffer index == postcode index. Returns the STRtree and a ``usable`` boolean
|
Buffer index == postcode index. Geometries that fail to buffer are replaced
|
||||||
mask: geometries that fail to buffer are replaced with an empty polygon (so
|
with an empty polygon so the index stays aligned; they simply never match.
|
||||||
the index stays aligned and they never match) and marked unusable, so their
|
|
||||||
crime picture is reported as unknown rather than zero.
|
|
||||||
"""
|
"""
|
||||||
buffers = shapely.buffer(polygons, buffer_m, quad_segs=8)
|
buffers = shapely.buffer(polygons, buffer_m, quad_segs=8)
|
||||||
broken = shapely.is_missing(buffers) | ~shapely.is_valid(buffers)
|
broken = shapely.is_missing(buffers) | ~shapely.is_valid(buffers)
|
||||||
if broken.any():
|
if broken.any():
|
||||||
print(f" {int(broken.sum()):,} postcode buffers unusable; left empty")
|
print(f" {int(broken.sum()):,} postcode buffers unusable; left empty")
|
||||||
buffers[broken] = shapely.from_wkt("POLYGON EMPTY")
|
buffers[broken] = shapely.from_wkt("POLYGON EMPTY")
|
||||||
return shapely.STRtree(buffers), ~broken
|
return buffers, shapely.STRtree(buffers)
|
||||||
|
|
||||||
|
|
||||||
def _accumulate_counts(
|
def _accumulate_counts(
|
||||||
csvs: list[Path],
|
csvs: list[Path],
|
||||||
postcodes: np.ndarray,
|
|
||||||
tree: shapely.STRtree,
|
tree: shapely.STRtree,
|
||||||
type_to_idx: dict[str, int],
|
type_to_idx: dict[str, int],
|
||||||
year_to_idx: dict[int, int],
|
year_to_idx: dict[int, int],
|
||||||
|
|
@ -201,49 +182,35 @@ def _accumulate_counts(
|
||||||
transformer: Transformer,
|
transformer: Transformer,
|
||||||
counts: np.ndarray,
|
counts: np.ndarray,
|
||||||
force_votes: np.ndarray,
|
force_votes: np.ndarray,
|
||||||
records_shard_dir: Path | None,
|
|
||||||
records_min_ym: int,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Stream the crime CSVs, counting points-in-buffer per (postcode, type, year).
|
"""Stream the crime CSVs, counting points-in-buffer per (postcode, type, year).
|
||||||
|
|
||||||
Also accumulates ``force_votes`` (n_postcodes, n_forces) for the home-force
|
Also accumulates ``force_votes`` (n_postcodes, n_forces): how many matched
|
||||||
election and, when ``records_shard_dir`` is set, writes one parquet shard per
|
incidents each force's files contributed to each postcode, which later
|
||||||
batch holding every counted (incident, postcode) pair whose month is within
|
elects the postcode's home force for the coverage calendar.
|
||||||
the records window (month index >= ``records_min_ym``).
|
|
||||||
"""
|
"""
|
||||||
# Type overrides only for the columns we ever read; LSOA is not stored.
|
|
||||||
schema = {
|
schema = {
|
||||||
"Longitude": pl.Float64,
|
"Longitude": pl.Float64,
|
||||||
"Latitude": pl.Float64,
|
"Latitude": pl.Float64,
|
||||||
"Month": pl.Utf8,
|
"Month": pl.Utf8,
|
||||||
"Crime type": pl.Utf8,
|
"Crime type": pl.Utf8,
|
||||||
"Location": pl.Utf8,
|
|
||||||
"Last outcome category": pl.Utf8,
|
|
||||||
}
|
}
|
||||||
years = list(year_to_idx)
|
years = list(year_to_idx)
|
||||||
total_points = 0
|
total_points = 0
|
||||||
total_matches = 0
|
total_matches = 0
|
||||||
total_dropped = 0
|
total_dropped = 0
|
||||||
total_records = 0
|
|
||||||
unknown_type_counts: dict[str, int] = {}
|
unknown_type_counts: dict[str, int] = {}
|
||||||
|
|
||||||
for start in range(0, len(csvs), _CSV_BATCH):
|
for start in range(0, len(csvs), _CSV_BATCH):
|
||||||
batch = csvs[start : start + _CSV_BATCH]
|
batch = csvs[start : start + _CSV_BATCH]
|
||||||
|
# The source file identifies the publishing force (police.uk has no
|
||||||
|
# force column with consistent naming); map each path back to its
|
||||||
|
# force index for the home-force vote.
|
||||||
path_to_fidx = {}
|
path_to_fidx = {}
|
||||||
batch_max_ym = -1
|
|
||||||
for path in batch:
|
for path in batch:
|
||||||
m = STREET_CSV_NAME_RE.fullmatch(path.name)
|
m = STREET_CSV_NAME_RE.fullmatch(path.name)
|
||||||
if m is not None:
|
if m is not None and m.group(2) in force_to_idx:
|
||||||
ym = m.group(1)
|
path_to_fidx[str(path)] = force_to_idx[m.group(2)]
|
||||||
batch_max_ym = max(batch_max_ym, int(ym[:4]) * 12 + int(ym[5:7]) - 1)
|
|
||||||
if m.group(2) in force_to_idx:
|
|
||||||
path_to_fidx[str(path)] = force_to_idx[m.group(2)]
|
|
||||||
# The per-incident record strings (Location, outcome) are by far the
|
|
||||||
# heaviest columns; read them only for batches that fall inside the
|
|
||||||
# records window, so the ~50% of pre-window months cost nothing extra.
|
|
||||||
want_records = records_shard_dir is not None and batch_max_ym >= records_min_ym
|
|
||||||
record_cols = ["Location", "Last outcome category"] if want_records else []
|
|
||||||
|
|
||||||
frame = (
|
frame = (
|
||||||
pl.scan_csv(
|
pl.scan_csv(
|
||||||
batch,
|
batch,
|
||||||
|
|
@ -251,12 +218,12 @@ def _accumulate_counts(
|
||||||
ignore_errors=True,
|
ignore_errors=True,
|
||||||
include_file_paths="_source_path",
|
include_file_paths="_source_path",
|
||||||
)
|
)
|
||||||
.select(
|
.select("Longitude", "Latitude", "Month", "Crime type", "_source_path")
|
||||||
"Longitude", "Latitude", "Month", "Crime type", *record_cols, "_source_path"
|
# strict=False: a single malformed Month drops only that row instead
|
||||||
)
|
# of aborting the whole build (a non-numeric year becomes null and is
|
||||||
|
# filtered out by the year membership check below).
|
||||||
.with_columns(
|
.with_columns(
|
||||||
pl.col("Month").str.slice(0, 4).cast(pl.Int32, strict=False).alias("year"),
|
pl.col("Month").str.slice(0, 4).cast(pl.Int32, strict=False).alias("year")
|
||||||
pl.col("Month").str.slice(5, 2).cast(pl.Int32, strict=False).alias("_mm"),
|
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
pl.col("Longitude").is_not_null()
|
pl.col("Longitude").is_not_null()
|
||||||
|
|
@ -267,11 +234,14 @@ def _accumulate_counts(
|
||||||
& (pl.col("Crime type") != "")
|
& (pl.col("Crime type") != "")
|
||||||
& pl.col("year").is_in(years)
|
& pl.col("year").is_in(years)
|
||||||
)
|
)
|
||||||
# year*12 + (month-1): an integer month index for window filtering.
|
# Canonicalise legacy pre-2014 crime-type names ("Violent crime",
|
||||||
.with_columns(
|
# "Public disorder and weapons") to their current equivalents before
|
||||||
(pl.col("year") * 12 + (pl.col("_mm").fill_null(1) - 1)).alias("month_index")
|
# indexing, so ~1.9M historical incidents are counted instead of
|
||||||
)
|
# dropped. `.replace` leaves current types unchanged.
|
||||||
.with_columns(pl.col("Crime type").replace(LEGACY_CRIME_TYPE_ALIASES))
|
.with_columns(pl.col("Crime type").replace(LEGACY_CRIME_TYPE_ALIASES))
|
||||||
|
# Map crime types to indices with default=None so an unrecognised
|
||||||
|
# type yields a null index we can *report* rather than silently drop
|
||||||
|
# (the legacy LSOA path surfaced unknown types via its dynamic pivot).
|
||||||
.with_columns(
|
.with_columns(
|
||||||
pl.col("Crime type")
|
pl.col("Crime type")
|
||||||
.replace_strict(type_to_idx, default=None, return_dtype=pl.Int32)
|
.replace_strict(type_to_idx, default=None, return_dtype=pl.Int32)
|
||||||
|
|
@ -283,16 +253,7 @@ def _accumulate_counts(
|
||||||
.replace_strict(path_to_fidx, default=-1, return_dtype=pl.Int32)
|
.replace_strict(path_to_fidx, default=-1, return_dtype=pl.Int32)
|
||||||
.alias("fidx"),
|
.alias("fidx"),
|
||||||
)
|
)
|
||||||
.select(
|
.select("Longitude", "Latitude", "Crime type", "tidx", "yidx", "fidx")
|
||||||
"Longitude",
|
|
||||||
"Latitude",
|
|
||||||
"Crime type",
|
|
||||||
*record_cols,
|
|
||||||
"month_index",
|
|
||||||
"tidx",
|
|
||||||
"yidx",
|
|
||||||
"fidx",
|
|
||||||
)
|
|
||||||
.collect(engine="streaming")
|
.collect(engine="streaming")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -312,15 +273,19 @@ def _accumulate_counts(
|
||||||
tidx = frame["tidx"].to_numpy()
|
tidx = frame["tidx"].to_numpy()
|
||||||
yidx = frame["yidx"].to_numpy()
|
yidx = frame["yidx"].to_numpy()
|
||||||
fidx = frame["fidx"].to_numpy()
|
fidx = frame["fidx"].to_numpy()
|
||||||
month_index = frame["month_index"].to_numpy()
|
|
||||||
|
|
||||||
x, y = transformer.transform(lon, lat)
|
x, y = transformer.transform(lon, lat)
|
||||||
finite = np.isfinite(x) & np.isfinite(y)
|
finite = np.isfinite(x) & np.isfinite(y)
|
||||||
total_dropped += int((~finite).sum())
|
total_dropped += int((~finite).sum())
|
||||||
if not finite.any():
|
if not finite.any():
|
||||||
continue
|
continue
|
||||||
x, y = x[finite], y[finite]
|
x, y, tidx, yidx, fidx = (
|
||||||
tidx, yidx, fidx = tidx[finite], yidx[finite], fidx[finite]
|
x[finite],
|
||||||
|
y[finite],
|
||||||
|
tidx[finite],
|
||||||
|
yidx[finite],
|
||||||
|
fidx[finite],
|
||||||
|
)
|
||||||
total_points += x.size
|
total_points += x.size
|
||||||
|
|
||||||
points = shapely.points(x, y)
|
points = shapely.points(x, y)
|
||||||
|
|
@ -341,26 +306,9 @@ def _accumulate_counts(
|
||||||
)
|
)
|
||||||
total_matches += point_index.size
|
total_matches += point_index.size
|
||||||
|
|
||||||
if want_records:
|
|
||||||
total_records += _write_record_shard(
|
|
||||||
records_shard_dir,
|
|
||||||
start,
|
|
||||||
postcodes,
|
|
||||||
point_index,
|
|
||||||
postcode_index,
|
|
||||||
month_index[finite],
|
|
||||||
frame["Crime type"].to_numpy()[finite],
|
|
||||||
frame["Location"].to_numpy()[finite],
|
|
||||||
frame["Last outcome category"].to_numpy()[finite],
|
|
||||||
lon[finite],
|
|
||||||
lat[finite],
|
|
||||||
records_min_ym,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f" files {start + len(batch):,}/{len(csvs):,}: "
|
f" files {start + len(batch):,}/{len(csvs):,}: "
|
||||||
f"{total_points:,} located points, {total_matches:,} postcode matches"
|
f"{total_points:,} located points, {total_matches:,} postcode matches"
|
||||||
+ (f", {total_records:,} records" if records_shard_dir is not None else "")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if total_dropped:
|
if total_dropped:
|
||||||
|
|
@ -381,65 +329,19 @@ def _accumulate_counts(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_record_shard(
|
|
||||||
records_shard_dir: Path,
|
|
||||||
start: int,
|
|
||||||
postcodes: np.ndarray,
|
|
||||||
point_index: np.ndarray,
|
|
||||||
postcode_index: np.ndarray,
|
|
||||||
month_index: np.ndarray,
|
|
||||||
crime_type: np.ndarray,
|
|
||||||
location: np.ndarray,
|
|
||||||
outcome: np.ndarray,
|
|
||||||
lon: np.ndarray,
|
|
||||||
lat: np.ndarray,
|
|
||||||
records_min_ym: int,
|
|
||||||
) -> int:
|
|
||||||
"""Write one parquet shard of (incident, postcode) records for this batch.
|
|
||||||
|
|
||||||
Each matched pair becomes a row -- the same multiplicity as the count -- so a
|
|
||||||
postcode's records are exactly the incidents that made up its counts. Only
|
|
||||||
incidents within the records window (month index >= ``records_min_ym``) are
|
|
||||||
kept. Returns the number of rows written.
|
|
||||||
"""
|
|
||||||
mi = month_index[point_index]
|
|
||||||
keep = mi >= records_min_ym
|
|
||||||
if not keep.any():
|
|
||||||
return 0
|
|
||||||
pidx = point_index[keep]
|
|
||||||
# Build the string columns from Python lists (.tolist()) rather than numpy
|
|
||||||
# object arrays: an all-null Location/outcome slice is an all-None object
|
|
||||||
# array that polars cannot cast to String, whereas a list of str|None infers
|
|
||||||
# a nullable String column cleanly.
|
|
||||||
shard = pl.DataFrame(
|
|
||||||
{
|
|
||||||
"postcode": postcodes[postcode_index[keep]].astype(str),
|
|
||||||
"month_index": mi[keep].astype(np.int32),
|
|
||||||
"crime_type": crime_type[pidx].astype(str),
|
|
||||||
"location": location[pidx].tolist(),
|
|
||||||
"outcome": outcome[pidx].tolist(),
|
|
||||||
"lat": lat[pidx].astype(np.float32),
|
|
||||||
"lon": lon[pidx].astype(np.float32),
|
|
||||||
},
|
|
||||||
schema_overrides={
|
|
||||||
"postcode": pl.String,
|
|
||||||
"crime_type": pl.String,
|
|
||||||
"location": pl.String,
|
|
||||||
"outcome": pl.String,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
shard.write_parquet(
|
|
||||||
records_shard_dir / f"{start:08d}.parquet", compression="zstd"
|
|
||||||
)
|
|
||||||
return shard.height
|
|
||||||
|
|
||||||
|
|
||||||
def _assign_home_force(
|
def _assign_home_force(
|
||||||
postcodes: np.ndarray,
|
postcodes: np.ndarray,
|
||||||
force_votes: np.ndarray,
|
force_votes: np.ndarray,
|
||||||
forces: list[str],
|
forces: list[str],
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Elect each postcode's home (territorial) force by majority incident vote."""
|
"""Elect each postcode's home (territorial) force.
|
||||||
|
|
||||||
|
Majority vote of matched incidents per publishing force; non-territorial
|
||||||
|
forces (BTP) are excluded from the vote because their calendar says nothing
|
||||||
|
about local coverage. Postcodes with no votes (no incidents ever, or
|
||||||
|
BTP-only) inherit the majority force of their outcode, then the national
|
||||||
|
modal force, so every postcode gets a coverage calendar.
|
||||||
|
"""
|
||||||
votes = force_votes.astype(np.int64, copy=True)
|
votes = force_votes.astype(np.int64, copy=True)
|
||||||
for idx, force in enumerate(forces):
|
for idx, force in enumerate(forces):
|
||||||
if force in NON_TERRITORIAL_FORCES:
|
if force in NON_TERRITORIAL_FORCES:
|
||||||
|
|
@ -452,15 +354,18 @@ def _assign_home_force(
|
||||||
if not has_vote.any():
|
if not has_vote.any():
|
||||||
raise ValueError("No incidents matched any postcode; cannot assign forces")
|
raise ValueError("No incidents matched any postcode; cannot assign forces")
|
||||||
|
|
||||||
|
# Outcode-majority fallback for postcodes with no (territorial) incidents.
|
||||||
outcodes = np.array([pc.split(" ")[0] for pc in postcodes], dtype=object)
|
outcodes = np.array([pc.split(" ")[0] for pc in postcodes], dtype=object)
|
||||||
national_modal = int(np.bincount(home[has_vote], minlength=len(forces)).argmax())
|
national_modal = int(
|
||||||
|
np.bincount(home[has_vote], minlength=len(forces)).argmax()
|
||||||
|
)
|
||||||
if (~has_vote).any():
|
if (~has_vote).any():
|
||||||
outcode_modal: dict[str, int] = {}
|
outcode_modal: dict[str, int] = {}
|
||||||
voted_outcodes = outcodes[has_vote]
|
voted_outcodes = outcodes[has_vote]
|
||||||
voted_home = home[has_vote]
|
voted_home = home[has_vote]
|
||||||
for oc in np.unique(voted_outcodes):
|
for oc in np.unique(voted_outcodes):
|
||||||
tally = np.bincount(voted_home[voted_outcodes == oc], minlength=len(forces))
|
counts = np.bincount(voted_home[voted_outcodes == oc], minlength=len(forces))
|
||||||
outcode_modal[oc] = int(tally.argmax())
|
outcode_modal[oc] = int(counts.argmax())
|
||||||
fallback = np.array(
|
fallback = np.array(
|
||||||
[outcode_modal.get(oc, national_modal) for oc in outcodes[~has_vote]],
|
[outcode_modal.get(oc, national_modal) for oc in outcodes[~has_vote]],
|
||||||
dtype=np.int32,
|
dtype=np.int32,
|
||||||
|
|
@ -474,82 +379,10 @@ def _assign_home_force(
|
||||||
return home
|
return home
|
||||||
|
|
||||||
|
|
||||||
def _window_annualised(
|
|
||||||
counts: np.ndarray,
|
|
||||||
months_in_year_force: np.ndarray,
|
|
||||||
home_fidx: np.ndarray,
|
|
||||||
usable: np.ndarray,
|
|
||||||
year_mask: np.ndarray,
|
|
||||||
) -> np.ndarray:
|
|
||||||
"""Raw annualised incidents/yr per (postcode, type) over a window of years.
|
|
||||||
|
|
||||||
For each force, the count is pooled over the force's covered months that fall
|
|
||||||
inside ``year_mask`` and annualised by those covered months. A covered
|
|
||||||
postcode with no incidents of a type gets 0; a postcode whose force never
|
|
||||||
published in the window, or whose geometry is unusable, gets NaN (unknown).
|
|
||||||
"""
|
|
||||||
n_pc, n_types = counts.shape[0], counts.shape[1]
|
|
||||||
avg = np.full((n_pc, n_types), np.nan, dtype=np.float64)
|
|
||||||
for f in range(months_in_year_force.shape[0]):
|
|
||||||
sel = home_fidx == f
|
|
||||||
if not sel.any():
|
|
||||||
continue
|
|
||||||
cov_months = months_in_year_force[f].astype(np.float64) * year_mask
|
|
||||||
denom = cov_months.sum()
|
|
||||||
if denom <= 0:
|
|
||||||
continue # force published nothing in this window; stays NaN
|
|
||||||
window_years = cov_months > 0
|
|
||||||
pooled = counts[sel][:, :, window_years].sum(axis=2, dtype=np.float64)
|
|
||||||
avg[sel] = pooled * 12.0 / denom
|
|
||||||
avg[~usable] = np.nan
|
|
||||||
return avg
|
|
||||||
|
|
||||||
|
|
||||||
def _append_rollups(avg14: np.ndarray) -> np.ndarray:
|
|
||||||
"""Append Serious/Minor rollup columns (sum of components) -> (n_pc, 16)."""
|
|
||||||
serious_idx = [ALL_CRIME_TYPES.index(t) for t in SERIOUS_CRIME_TYPES]
|
|
||||||
minor_idx = [ALL_CRIME_TYPES.index(t) for t in MINOR_CRIME_TYPES]
|
|
||||||
serious = avg14[:, serious_idx].sum(axis=1)
|
|
||||||
minor = avg14[:, minor_idx].sum(axis=1)
|
|
||||||
return np.column_stack([avg14, serious, minor])
|
|
||||||
|
|
||||||
|
|
||||||
def _write_crime_table(
|
|
||||||
postcodes: np.ndarray,
|
|
||||||
raw_by_window: dict[str, np.ndarray],
|
|
||||||
output_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Write the average-annual-count parquet (the filterable crime features).
|
|
||||||
|
|
||||||
``raw_by_window[label]`` is the (n_postcodes, 16) average annual incident
|
|
||||||
count for that window (14 leaf types + 2 rollups). Each value is the raw,
|
|
||||||
absolute incidents/yr; a postcode with no usable data for a window keeps NaN
|
|
||||||
(written as null).
|
|
||||||
"""
|
|
||||||
data: dict[str, np.ndarray] = {"postcode": postcodes}
|
|
||||||
for label, _years in WINDOWS:
|
|
||||||
counts = np.round(raw_by_window[label], 1).astype(np.float32)
|
|
||||||
for type_idx, name in enumerate(ALL_OUTPUT_TYPES):
|
|
||||||
data[crime_column(name, label)] = counts[:, type_idx]
|
|
||||||
|
|
||||||
_write_nan_aware(data, output_path, "postcode crime average annual counts")
|
|
||||||
|
|
||||||
|
|
||||||
def _write_nan_aware(
|
|
||||||
data: dict[str, np.ndarray], output_path: Path, label: str
|
|
||||||
) -> None:
|
|
||||||
frame = pl.DataFrame(data)
|
|
||||||
value_cols = [c for c in frame.columns if c != "postcode"]
|
|
||||||
frame = frame.with_columns(pl.col(c).fill_nan(None) for c in value_cols)
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
frame.write_parquet(output_path, compression="zstd")
|
|
||||||
print(f"Wrote {label}: {output_path} {frame.shape}")
|
|
||||||
|
|
||||||
|
|
||||||
def _rollup_long(
|
def _rollup_long(
|
||||||
long: pl.DataFrame, types: tuple[str, ...], rollup_name: str
|
long: pl.DataFrame, types: tuple[str, ...], rollup_name: str
|
||||||
) -> pl.DataFrame:
|
) -> pl.DataFrame:
|
||||||
"""Sum per-year counts across ``types`` into a single rollup."""
|
"""Sum per-year annualised counts across ``types`` into a single rollup."""
|
||||||
return (
|
return (
|
||||||
long.filter(pl.col("Crime type").is_in(list(types)))
|
long.filter(pl.col("Crime type").is_in(list(types)))
|
||||||
.group_by("postcode", "year")
|
.group_by("postcode", "year")
|
||||||
|
|
@ -559,29 +392,108 @@ def _rollup_long(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_avg_yr(
|
||||||
|
postcodes: np.ndarray,
|
||||||
|
counts: np.ndarray,
|
||||||
|
months_in_year_force: np.ndarray,
|
||||||
|
home_fidx: np.ndarray,
|
||||||
|
norm: np.ndarray,
|
||||||
|
output_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Write ``postcode`` + ``"{type} (avg/yr)"`` density-normalised averages.
|
||||||
|
|
||||||
|
The headline is the POOLED annualised rate over the home force's covered
|
||||||
|
months: ``sum(counts in covered years) * 12 / covered_months``. Years the
|
||||||
|
force published nothing contribute neither incidents nor months, so a
|
||||||
|
coverage gap (e.g. Greater Manchester 2019-07 onwards) is excluded instead
|
||||||
|
of read as zero crime. Pooling over the full covered window -- rather than
|
||||||
|
averaging only over years a type happened to occur -- is what keeps a
|
||||||
|
single robbery-year from printing as a perennial robbery rate. Each
|
||||||
|
postcode's value is then multiplied by ``norm`` (median_area / buffered
|
||||||
|
catchment area) so the metric is a density rather than a footprint-inflated
|
||||||
|
raw count; postcodes with unusable geometry (norm == 0) are null, not 0.
|
||||||
|
"""
|
||||||
|
n_postcodes, n_types = counts.shape[0], counts.shape[1]
|
||||||
|
avg = np.full((n_postcodes, n_types), np.nan, dtype=np.float64)
|
||||||
|
for f in range(months_in_year_force.shape[0]):
|
||||||
|
sel = home_fidx == f
|
||||||
|
if not sel.any():
|
||||||
|
continue
|
||||||
|
cov_months = months_in_year_force[f].astype(np.float64)
|
||||||
|
denom = cov_months.sum()
|
||||||
|
if denom <= 0:
|
||||||
|
continue # force never published; stays null
|
||||||
|
covered_years = cov_months > 0
|
||||||
|
pooled = counts[sel][:, :, covered_years].sum(axis=2, dtype=np.float64)
|
||||||
|
avg[sel] = pooled * 12.0 / denom
|
||||||
|
|
||||||
|
avg *= norm[:, None]
|
||||||
|
avg[norm <= 0] = np.nan # unusable geometry: unknown, not zero
|
||||||
|
avg = np.round(avg, 1).astype(np.float32)
|
||||||
|
|
||||||
|
data: dict[str, np.ndarray] = {"postcode": postcodes}
|
||||||
|
for type_idx, name in enumerate(ALL_CRIME_TYPES):
|
||||||
|
data[f"{name} (avg/yr)"] = avg[:, type_idx]
|
||||||
|
|
||||||
|
# Serious/Minor rollup headlines = the exact SUM of their component (avg/yr)
|
||||||
|
# columns, so each rollup always equals the sum of the parts shown beside it
|
||||||
|
# and can never fall below one of its own components. All components share
|
||||||
|
# the postcode's pooled covered-month denominator, so the sum is itself the
|
||||||
|
# pooled rollup rate. Null components (unusable geometry) propagate to a
|
||||||
|
# null rollup.
|
||||||
|
for rollup_name, rollup_types in (
|
||||||
|
("Serious crime", SERIOUS_CRIME_TYPES),
|
||||||
|
("Minor crime", MINOR_CRIME_TYPES),
|
||||||
|
):
|
||||||
|
rollup_idx = [ALL_CRIME_TYPES.index(name) for name in rollup_types]
|
||||||
|
data[f"{rollup_name} (avg/yr)"] = np.round(
|
||||||
|
avg[:, rollup_idx].sum(axis=1), 1
|
||||||
|
).astype(np.float32)
|
||||||
|
|
||||||
|
frame = pl.DataFrame(data)
|
||||||
|
value_cols = [c for c in frame.columns if c != "postcode"]
|
||||||
|
frame = frame.with_columns(pl.col(c).fill_nan(None) for c in value_cols)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
frame.write_parquet(output_path, compression="zstd")
|
||||||
|
print(f"Wrote postcode crime averages: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
def _write_by_year(
|
def _write_by_year(
|
||||||
postcodes: np.ndarray,
|
postcodes: np.ndarray,
|
||||||
counts: np.ndarray,
|
counts: np.ndarray,
|
||||||
years: list[int],
|
years: list[int],
|
||||||
months_in_year_force: np.ndarray,
|
months_in_year_force: np.ndarray,
|
||||||
home_fidx: np.ndarray,
|
home_fidx: np.ndarray,
|
||||||
usable: np.ndarray,
|
norm: np.ndarray,
|
||||||
min_bar_months: int,
|
min_bar_months: int,
|
||||||
output_path: Path,
|
output_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write nested ``"{type} (by year)"`` raw-count series plus rollups + coverage.
|
"""Write nested ``"{type} (by year)"`` series plus rollups and coverage.
|
||||||
|
|
||||||
A bar is only emitted for (postcode, year)s where the postcode's home force
|
A bar is only emitted for (postcode, year)s where the postcode's home force
|
||||||
published at least ``min_bar_months`` months. Bars are the raw annualised
|
published at least ``min_bar_months`` months -- annualising a thinner year
|
||||||
count for that year (``count * 12 / covered_months``); unlike the headline
|
(x12 from a single month at the extreme) charts noise, and a force-gap year
|
||||||
windows there is no per-capita normalisation here -- the chart shows incident
|
must chart as *no data*, not zero. Bars are scaled by the force's covered
|
||||||
volume over time. Every postcode gets a ``covered_years`` row so consumers can
|
months in that year and area-normalised by the same ``norm`` factor as the
|
||||||
distinguish covered-but-crime-free years from coverage gaps.
|
headline so chart and headline stay mutually consistent.
|
||||||
|
|
||||||
|
Every postcode gets a row (the output is dense) carrying ``covered_years``
|
||||||
|
-- the list of {year, months} the home force published at least
|
||||||
|
``min_bar_months`` months -- so consumers can distinguish covered-but-
|
||||||
|
crime-free years (year listed, no bar => genuine zero) from coverage gaps
|
||||||
|
(year absent => unknown). Postcodes with unusable geometry get an empty
|
||||||
|
coverage list: their crime picture is unknown.
|
||||||
"""
|
"""
|
||||||
|
# (n_postcodes, n_years): covered months of each postcode's home force.
|
||||||
cov_pc_year = months_in_year_force[home_fidx, :]
|
cov_pc_year = months_in_year_force[home_fidx, :]
|
||||||
|
usable = norm > 0
|
||||||
|
|
||||||
annual = np.round(
|
annual = np.round(
|
||||||
counts.astype(np.float64) * 12.0 / np.maximum(cov_pc_year[:, None, :], 1),
|
counts.astype(np.float64)
|
||||||
|
* 12.0
|
||||||
|
/ np.maximum(cov_pc_year[:, None, :], 1)
|
||||||
|
* norm[:, None, None],
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
bar_ok = (
|
bar_ok = (
|
||||||
|
|
@ -594,6 +506,9 @@ def _write_by_year(
|
||||||
|
|
||||||
type_names = np.array(ALL_CRIME_TYPES, dtype=object)
|
type_names = np.array(ALL_CRIME_TYPES, dtype=object)
|
||||||
year_values = np.array(years, dtype=np.int32)
|
year_values = np.array(years, dtype=np.int32)
|
||||||
|
# Explicit schema: with full masking (e.g. every year below min_bar_months)
|
||||||
|
# the fancy-indexed numpy object arrays are empty and polars would infer
|
||||||
|
# Object columns, which breaks the rollup `is_in` below.
|
||||||
long = pl.DataFrame(
|
long = pl.DataFrame(
|
||||||
{
|
{
|
||||||
"postcode": postcodes[pc_i].astype(str),
|
"postcode": postcodes[pc_i].astype(str),
|
||||||
|
|
@ -617,6 +532,8 @@ def _write_by_year(
|
||||||
type_cols = [c for c in wide.columns if c != "postcode"]
|
type_cols = [c for c in wide.columns if c != "postcode"]
|
||||||
wide = wide.rename({col: f"{col} (by year)" for col in type_cols})
|
wide = wide.rename({col: f"{col} (by year)" for col in type_cols})
|
||||||
|
|
||||||
|
# Dense base: every postcode, with its home force's coverage calendar.
|
||||||
|
# Built per force (there are ~45) and joined on the force index.
|
||||||
coverage_per_force: list[list[dict[str, int]]] = []
|
coverage_per_force: list[list[dict[str, int]]] = []
|
||||||
for f in range(months_in_year_force.shape[0]):
|
for f in range(months_in_year_force.shape[0]):
|
||||||
coverage_per_force.append(
|
coverage_per_force.append(
|
||||||
|
|
@ -645,6 +562,7 @@ def _write_by_year(
|
||||||
dense = (
|
dense = (
|
||||||
base.join(coverage_frame, on="_fidx", how="left")
|
base.join(coverage_frame, on="_fidx", how="left")
|
||||||
.with_columns(
|
.with_columns(
|
||||||
|
# Unusable geometry: empty coverage -- the crime picture is unknown.
|
||||||
pl.when(pl.col("_usable"))
|
pl.when(pl.col("_usable"))
|
||||||
.then(pl.col(COVERAGE_COLUMN))
|
.then(pl.col(COVERAGE_COLUMN))
|
||||||
.otherwise(pl.col(COVERAGE_COLUMN).list.head(0))
|
.otherwise(pl.col(COVERAGE_COLUMN).list.head(0))
|
||||||
|
|
@ -659,47 +577,11 @@ def _write_by_year(
|
||||||
print(f"Wrote postcode crime by-year series: {output_path} {wide.shape}")
|
print(f"Wrote postcode crime by-year series: {output_path} {wide.shape}")
|
||||||
|
|
||||||
|
|
||||||
def _finalize_records(
|
|
||||||
records_shard_dir: Path, output_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Concatenate the per-batch record shards into one postcode-sorted parquet.
|
|
||||||
|
|
||||||
Sorting by postcode lets the server build a contiguous per-postcode slice
|
|
||||||
index. The sort runs on the streaming engine so it spills rather than holding
|
|
||||||
all ~40M rows in memory.
|
|
||||||
"""
|
|
||||||
shards = sorted(records_shard_dir.glob("*.parquet"))
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if not shards:
|
|
||||||
pl.DataFrame(
|
|
||||||
schema={
|
|
||||||
"postcode": pl.String,
|
|
||||||
"month_index": pl.Int32,
|
|
||||||
"crime_type": pl.String,
|
|
||||||
"location": pl.String,
|
|
||||||
"outcome": pl.String,
|
|
||||||
"lat": pl.Float32,
|
|
||||||
"lon": pl.Float32,
|
|
||||||
}
|
|
||||||
).write_parquet(output_path, compression="zstd")
|
|
||||||
print(f"Wrote crime records: {output_path} (empty)")
|
|
||||||
return
|
|
||||||
|
|
||||||
(
|
|
||||||
pl.scan_parquet(shards)
|
|
||||||
.sort("postcode")
|
|
||||||
.sink_parquet(output_path, compression="zstd")
|
|
||||||
)
|
|
||||||
n = pl.scan_parquet(output_path).select(pl.len()).collect().item()
|
|
||||||
print(f"Wrote crime records: {output_path} ({n:,} rows)")
|
|
||||||
|
|
||||||
|
|
||||||
def transform_crime_spatial(
|
def transform_crime_spatial(
|
||||||
crime_dir: Path,
|
crime_dir: Path,
|
||||||
boundaries_dir: Path,
|
boundaries_dir: Path,
|
||||||
output_path: Path,
|
output_path: Path,
|
||||||
by_year_output_path: Path,
|
by_year_output_path: Path,
|
||||||
records_output_path: Path,
|
|
||||||
buffer_m: float = DEFAULT_BUFFER_M,
|
buffer_m: float = DEFAULT_BUFFER_M,
|
||||||
max_postcodes: int | None = None,
|
max_postcodes: int | None = None,
|
||||||
max_files: int | None = None,
|
max_files: int | None = None,
|
||||||
|
|
@ -712,10 +594,6 @@ def transform_crime_spatial(
|
||||||
csvs = csvs[:max_files]
|
csvs = csvs[:max_files]
|
||||||
|
|
||||||
years, forces, months_in_year_force = _force_calendar(csvs)
|
years, forces, months_in_year_force = _force_calendar(csvs)
|
||||||
latest_year = years[-1]
|
|
||||||
# Records cover the longest window's calendar years (January of its earliest
|
|
||||||
# year onward), so they reconcile exactly with the calendar-year headline.
|
|
||||||
records_min_ym = (latest_year - (RECORDS_WINDOW_YEARS - 1)) * 12
|
|
||||||
print(
|
print(
|
||||||
f"Found {len(csvs):,} street crime CSVs across {len(forces)} forces "
|
f"Found {len(csvs):,} street crime CSVs across {len(forces)} forces "
|
||||||
f"({years[0]}-{years[-1]})"
|
f"({years[0]}-{years[-1]})"
|
||||||
|
|
@ -723,10 +601,27 @@ def transform_crime_spatial(
|
||||||
)
|
)
|
||||||
|
|
||||||
postcodes, polygons = load_postcode_polygons(boundaries_dir, max_postcodes)
|
postcodes, polygons = load_postcode_polygons(boundaries_dir, max_postcodes)
|
||||||
postcodes = np.asarray(postcodes)
|
|
||||||
|
|
||||||
print(f"Buffering {len(postcodes):,} postcode polygons by {buffer_m:g}m...")
|
print(f"Buffering {len(postcodes):,} postcode polygons by {buffer_m:g}m...")
|
||||||
tree, usable = _build_tree(polygons, buffer_m)
|
buffers, tree = _build_tree(polygons, buffer_m)
|
||||||
|
|
||||||
|
# Area-normalisation factor (median_area / catchment_area): divides out the
|
||||||
|
# size of each postcode's catchment so the count measures crime density, not
|
||||||
|
# how much ground the buffer sweeps. We normalise by the *buffered* area --
|
||||||
|
# the region that actually collects points -- rather than the raw polygon, so
|
||||||
|
# a tiny unit postcode isn't over-inflated by the fixed buffer-ring floor.
|
||||||
|
# Buffers are in EPSG:27700, so shapely.area is in m^2.
|
||||||
|
areas = shapely.area(buffers).astype(np.float64)
|
||||||
|
usable_area = np.isfinite(areas) & (areas > 0)
|
||||||
|
if not usable_area.any():
|
||||||
|
raise ValueError("No postcode buffers have a positive area to normalise by")
|
||||||
|
median_area = float(np.median(areas[usable_area]))
|
||||||
|
norm = np.zeros(len(postcodes), dtype=np.float64)
|
||||||
|
norm[usable_area] = median_area / areas[usable_area]
|
||||||
|
print(
|
||||||
|
f"Area-normalising to median catchment area {median_area:,.0f} m^2 "
|
||||||
|
f"({int(usable_area.sum()):,}/{len(areas):,} postcodes have usable area)"
|
||||||
|
)
|
||||||
|
|
||||||
type_to_idx = {name: idx for idx, name in enumerate(ALL_CRIME_TYPES)}
|
type_to_idx = {name: idx for idx, name in enumerate(ALL_CRIME_TYPES)}
|
||||||
year_to_idx = {year: idx for idx, year in enumerate(years)}
|
year_to_idx = {year: idx for idx, year in enumerate(years)}
|
||||||
|
|
@ -735,50 +630,25 @@ def transform_crime_spatial(
|
||||||
force_votes = np.zeros((len(postcodes), len(forces)), dtype=np.int32)
|
force_votes = np.zeros((len(postcodes), len(forces)), dtype=np.int32)
|
||||||
|
|
||||||
transformer = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
|
transformer = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
|
||||||
|
_accumulate_counts(
|
||||||
|
csvs, tree, type_to_idx, year_to_idx, force_to_idx, transformer, counts, force_votes
|
||||||
|
)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(
|
home_fidx = _assign_home_force(np.asarray(postcodes), force_votes, forces)
|
||||||
prefix="crime_records_", dir=records_output_path.parent
|
|
||||||
) as shard_dir_str:
|
|
||||||
shard_dir = Path(shard_dir_str)
|
|
||||||
_accumulate_counts(
|
|
||||||
csvs,
|
|
||||||
postcodes,
|
|
||||||
tree,
|
|
||||||
type_to_idx,
|
|
||||||
year_to_idx,
|
|
||||||
force_to_idx,
|
|
||||||
transformer,
|
|
||||||
counts,
|
|
||||||
force_votes,
|
|
||||||
shard_dir,
|
|
||||||
records_min_ym,
|
|
||||||
)
|
|
||||||
|
|
||||||
home_fidx = _assign_home_force(postcodes, force_votes, forces)
|
_write_avg_yr(
|
||||||
|
postcodes, counts, months_in_year_force, home_fidx, norm, output_path
|
||||||
# Per-window raw annualised averages (14 leaf types + Serious/Minor).
|
)
|
||||||
raw_by_window: dict[str, np.ndarray] = {}
|
_write_by_year(
|
||||||
for label, win_years in WINDOWS:
|
postcodes,
|
||||||
year_mask = np.array(
|
counts,
|
||||||
[1.0 if y > latest_year - win_years else 0.0 for y in years]
|
years,
|
||||||
)
|
months_in_year_force,
|
||||||
avg14 = _window_annualised(
|
home_fidx,
|
||||||
counts, months_in_year_force, home_fidx, usable, year_mask
|
norm,
|
||||||
)
|
min_bar_months,
|
||||||
raw_by_window[label] = _append_rollups(avg14)
|
by_year_output_path,
|
||||||
|
)
|
||||||
_write_crime_table(postcodes, raw_by_window, output_path)
|
|
||||||
_write_by_year(
|
|
||||||
postcodes,
|
|
||||||
counts,
|
|
||||||
years,
|
|
||||||
months_in_year_force,
|
|
||||||
home_fidx,
|
|
||||||
usable,
|
|
||||||
min_bar_months,
|
|
||||||
by_year_output_path,
|
|
||||||
)
|
|
||||||
_finalize_records(shard_dir, records_output_path)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
@ -801,7 +671,7 @@ def main() -> None:
|
||||||
"--output",
|
"--output",
|
||||||
type=Path,
|
type=Path,
|
||||||
required=True,
|
required=True,
|
||||||
help="Output parquet: postcode + '{type} (/yr, <window>)' average-annual-count columns",
|
help="Output parquet: postcode + '{type} (avg/yr)' columns",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output-by-year",
|
"--output-by-year",
|
||||||
|
|
@ -810,10 +680,10 @@ def main() -> None:
|
||||||
help="Output parquet: postcode + nested '{type} (by year)' columns",
|
help="Output parquet: postcode + nested '{type} (by year)' columns",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output-records",
|
"--buffer-m",
|
||||||
type=Path,
|
type=float,
|
||||||
required=True,
|
default=DEFAULT_BUFFER_M,
|
||||||
help="Output parquet: one row per counted incident (last 7 years), postcode-sorted",
|
help="Outward buffer (metres) added to each postcode boundary",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--max-postcodes",
|
"--max-postcodes",
|
||||||
|
|
@ -835,12 +705,15 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.buffer_m <= 0:
|
||||||
|
raise SystemExit("--buffer-m must be greater than zero")
|
||||||
|
|
||||||
transform_crime_spatial(
|
transform_crime_spatial(
|
||||||
crime_dir=args.input,
|
crime_dir=args.input,
|
||||||
boundaries_dir=args.boundaries,
|
boundaries_dir=args.boundaries,
|
||||||
output_path=args.output,
|
output_path=args.output,
|
||||||
by_year_output_path=args.output_by_year,
|
by_year_output_path=args.output_by_year,
|
||||||
records_output_path=args.output_records,
|
buffer_m=args.buffer_m,
|
||||||
max_postcodes=args.max_postcodes,
|
max_postcodes=args.max_postcodes,
|
||||||
max_files=args.max_files,
|
max_files=args.max_files,
|
||||||
min_bar_months=args.min_bar_months,
|
min_bar_months=args.min_bar_months,
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
"""Join the slim price estimates back onto properties.parquet.
|
|
||||||
|
|
||||||
Price estimation runs on ``price_inputs.parquet`` (built by ``property_base``
|
|
||||||
straight from epc_pp + arcgis, independently of merge's area features) and emits
|
|
||||||
``price_estimates.parquet`` — the natural key (Postcode + coalesced address) plus
|
|
||||||
``Estimated current price`` / ``Est. price per sqm``. This step joins those two
|
|
||||||
columns onto properties.parquet to produce the file the server consumes.
|
|
||||||
|
|
||||||
Why the natural key
|
|
||||||
-------------------
|
|
||||||
Estimates and properties are built by separate runs, so a positional row index
|
|
||||||
would not line up. Instead both derive the key ``(Postcode, coalesce(register
|
|
||||||
address, EPC address))`` — which is unique and non-null on the deduped dwelling
|
|
||||||
universe (see ``property_base._dedupe_collapsed_properties``) and identical on
|
|
||||||
both sides because both start from that same universe. So estimates map onto
|
|
||||||
properties 1:1 regardless of row order.
|
|
||||||
|
|
||||||
Re-running is safe: any pre-existing estimate columns are dropped first, and the
|
|
||||||
join is keyed (not positional), so a second run reproduces the same result. The
|
|
||||||
join refuses if any property has no estimate (the dwelling universes diverged,
|
|
||||||
e.g. a stale price_inputs vs a newer epc_pp) rather than silently leaving prices
|
|
||||||
null. Output is written to a temp file and atomically renamed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from pipeline.transform.price_estimation.utils import (
|
|
||||||
ESTIMATE_COLUMNS,
|
|
||||||
JOIN_ADDRESS,
|
|
||||||
JOIN_KEYS,
|
|
||||||
join_address_expr,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def join_estimates(properties: Path, estimates_path: Path) -> int:
|
|
||||||
"""Augment ``properties`` in place with the estimate columns; return rows.
|
|
||||||
|
|
||||||
Joins the slim estimates onto properties by the natural key and atomically
|
|
||||||
replaces properties.parquet. Idempotent: any estimate columns already on the
|
|
||||||
file are dropped first.
|
|
||||||
"""
|
|
||||||
estimates = pl.scan_parquet(estimates_path)
|
|
||||||
est_cols = estimates.collect_schema().names()
|
|
||||||
missing = [c for c in (*JOIN_KEYS, *ESTIMATE_COLUMNS) if c not in est_cols]
|
|
||||||
if missing:
|
|
||||||
raise ValueError(f"{estimates_path}: missing columns {missing}")
|
|
||||||
|
|
||||||
stats = estimates.select(
|
|
||||||
n=pl.len(), unique=pl.struct(JOIN_KEYS).n_unique()
|
|
||||||
).collect(engine="streaming")
|
|
||||||
n_estimates, n_unique = stats["n"][0], stats["unique"][0]
|
|
||||||
if n_unique != n_estimates:
|
|
||||||
raise ValueError(
|
|
||||||
f"{estimates_path}: natural key {JOIN_KEYS} is not unique "
|
|
||||||
f"({n_estimates - n_unique:,} duplicate rows)"
|
|
||||||
)
|
|
||||||
|
|
||||||
n_properties = pl.scan_parquet(properties).select(pl.len()).collect().item()
|
|
||||||
|
|
||||||
# Drop any estimate columns already present (idempotent re-run) and attach the
|
|
||||||
# coalesced-address half of the natural key.
|
|
||||||
properties_keyed = (
|
|
||||||
pl.scan_parquet(properties)
|
|
||||||
.drop(ESTIMATE_COLUMNS, strict=False)
|
|
||||||
.with_columns(join_address_expr())
|
|
||||||
)
|
|
||||||
|
|
||||||
# Every property must have an estimate: estimates and properties come from the
|
|
||||||
# same dwelling universe, so a gap means a stale/foreign price_inputs (e.g.
|
|
||||||
# built from a different epc_pp). Fail loudly instead of nulling prices.
|
|
||||||
#
|
|
||||||
# This assumes properties.parquet contains ONLY epc_pp-derived dwellings, which
|
|
||||||
# is true for the production merge output. Running merge with --actual-listings
|
|
||||||
# appends listing seed rows whose (Postcode, address) keys are absent from
|
|
||||||
# price_inputs (built straight from epc_pp), which would trip the guard below.
|
|
||||||
# Enabling listing integration on the primary output therefore requires
|
|
||||||
# price_inputs to include those seed rows too.
|
|
||||||
unmatched = (
|
|
||||||
properties_keyed.select(JOIN_KEYS)
|
|
||||||
.join(estimates.select(JOIN_KEYS), on=JOIN_KEYS, how="anti")
|
|
||||||
.select(pl.len())
|
|
||||||
.collect(engine="streaming")
|
|
||||||
.item()
|
|
||||||
)
|
|
||||||
if unmatched:
|
|
||||||
raise ValueError(
|
|
||||||
f"{properties}: {unmatched:,} of {n_properties:,} properties have no "
|
|
||||||
"matching estimate; the price_inputs and properties dwelling universes "
|
|
||||||
"differ (regenerate price_inputs.parquet from the current epc_pp)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# maintain_order="left" keeps properties in merge's row order; the unique key
|
|
||||||
# cannot fan the join out, so the row count is preserved.
|
|
||||||
result = properties_keyed.join(
|
|
||||||
estimates, on=JOIN_KEYS, how="left", maintain_order="left"
|
|
||||||
).drop(JOIN_ADDRESS)
|
|
||||||
|
|
||||||
tmp = properties.with_name(properties.name + ".tmp")
|
|
||||||
result.sink_parquet(tmp)
|
|
||||||
|
|
||||||
written = pl.scan_parquet(tmp).select(pl.len()).collect().item()
|
|
||||||
if written != n_properties:
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
raise ValueError(
|
|
||||||
f"{properties}: join changed the row count "
|
|
||||||
f"({n_properties:,} -> {written:,})"
|
|
||||||
)
|
|
||||||
|
|
||||||
tmp.replace(properties)
|
|
||||||
return written
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Join price_estimates.parquet onto properties.parquet"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--properties",
|
|
||||||
type=Path,
|
|
||||||
required=True,
|
|
||||||
help="properties.parquet (read, then overwritten with the estimate "
|
|
||||||
"columns joined in)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--estimates",
|
|
||||||
type=Path,
|
|
||||||
required=True,
|
|
||||||
help="Slim price_estimates.parquet from price_estimation.estimate",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
written = join_estimates(args.properties, args.estimates)
|
|
||||||
size_mb = args.properties.stat().st_size / (1024 * 1024)
|
|
||||||
n_priced = (
|
|
||||||
pl.scan_parquet(args.properties)
|
|
||||||
.filter(pl.col("Estimated current price").is_not_null())
|
|
||||||
.select(pl.len())
|
|
||||||
.collect()
|
|
||||||
.item()
|
|
||||||
)
|
|
||||||
print(f"Wrote {args.properties} ({size_mb:.1f} MB)")
|
|
||||||
print(f" {written:,} rows, {n_priced:,} with an estimated current price")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -29,16 +29,10 @@ from pipeline.utils.fuzzy_join import (
|
||||||
normalize_address_key,
|
normalize_address_key,
|
||||||
normalize_postcode_key,
|
normalize_postcode_key,
|
||||||
)
|
)
|
||||||
from pipeline.transform.property_base import (
|
|
||||||
MIN_FLOOR_AREA_M2,
|
|
||||||
_active_english_postcode_area,
|
|
||||||
_filter_to_active_english_postcodes,
|
|
||||||
build_property_base,
|
|
||||||
property_type_expr,
|
|
||||||
)
|
|
||||||
from pipeline.utils.normalize import drop_digit_tokens
|
from pipeline.utils.normalize import drop_digit_tokens
|
||||||
from pipeline.utils.postcode_mapping import build_postcode_mapping
|
from pipeline.utils.postcode_mapping import build_postcode_mapping
|
||||||
|
|
||||||
|
MIN_FLOOR_AREA_M2 = 10
|
||||||
CONSERVATION_AREA_FEATURE = "Within conservation area"
|
CONSERVATION_AREA_FEATURE = "Within conservation area"
|
||||||
# Named "Tree canopy" (not "Street tree") because the underlying density unions
|
# Named "Tree canopy" (not "Street tree") because the underlying density unions
|
||||||
# Forest Research TOW lone-tree/group crowns AND NFI woodland canopy, so a
|
# Forest Research TOW lone-tree/group crowns AND NFI woodland canopy, so a
|
||||||
|
|
@ -83,42 +77,23 @@ _AREA_COLUMNS = [
|
||||||
"% Mixed",
|
"% Mixed",
|
||||||
"% White",
|
"% White",
|
||||||
"% Other",
|
"% Other",
|
||||||
# Crime — average annual recorded incident count (incidents/yr), 7-year and
|
# Crime
|
||||||
# 2-year windows. These are the filterable crime features; the per-incident
|
"Anti-social behaviour (avg/yr)",
|
||||||
# records live in a separate side table the server loads directly (it bypasses
|
"Violence and sexual offences (avg/yr)",
|
||||||
# the merge).
|
"Criminal damage and arson (avg/yr)",
|
||||||
"Anti-social behaviour (/yr, 7y)",
|
"Burglary (avg/yr)",
|
||||||
"Anti-social behaviour (/yr, 2y)",
|
"Vehicle crime (avg/yr)",
|
||||||
"Violence and sexual offences (/yr, 7y)",
|
"Robbery (avg/yr)",
|
||||||
"Violence and sexual offences (/yr, 2y)",
|
"Other theft (avg/yr)",
|
||||||
"Criminal damage and arson (/yr, 7y)",
|
"Shoplifting (avg/yr)",
|
||||||
"Criminal damage and arson (/yr, 2y)",
|
"Drugs (avg/yr)",
|
||||||
"Burglary (/yr, 7y)",
|
"Possession of weapons (avg/yr)",
|
||||||
"Burglary (/yr, 2y)",
|
"Public order (avg/yr)",
|
||||||
"Vehicle crime (/yr, 7y)",
|
"Bicycle theft (avg/yr)",
|
||||||
"Vehicle crime (/yr, 2y)",
|
"Theft from the person (avg/yr)",
|
||||||
"Robbery (/yr, 7y)",
|
"Other crime (avg/yr)",
|
||||||
"Robbery (/yr, 2y)",
|
"Serious crime (avg/yr)",
|
||||||
"Other theft (/yr, 7y)",
|
"Minor crime (avg/yr)",
|
||||||
"Other theft (/yr, 2y)",
|
|
||||||
"Shoplifting (/yr, 7y)",
|
|
||||||
"Shoplifting (/yr, 2y)",
|
|
||||||
"Drugs (/yr, 7y)",
|
|
||||||
"Drugs (/yr, 2y)",
|
|
||||||
"Possession of weapons (/yr, 7y)",
|
|
||||||
"Possession of weapons (/yr, 2y)",
|
|
||||||
"Public order (/yr, 7y)",
|
|
||||||
"Public order (/yr, 2y)",
|
|
||||||
"Bicycle theft (/yr, 7y)",
|
|
||||||
"Bicycle theft (/yr, 2y)",
|
|
||||||
"Theft from the person (/yr, 7y)",
|
|
||||||
"Theft from the person (/yr, 2y)",
|
|
||||||
"Other crime (/yr, 7y)",
|
|
||||||
"Other crime (/yr, 2y)",
|
|
||||||
"Serious crime (/yr, 7y)",
|
|
||||||
"Serious crime (/yr, 2y)",
|
|
||||||
"Minor crime (/yr, 7y)",
|
|
||||||
"Minor crime (/yr, 2y)",
|
|
||||||
# Amenities
|
# Amenities
|
||||||
"Number of restaurants within 2km",
|
"Number of restaurants within 2km",
|
||||||
"Number of grocery shops and supermarkets within 2km",
|
"Number of grocery shops and supermarkets within 2km",
|
||||||
|
|
@ -214,6 +189,8 @@ _FINAL_RENAME_COLUMNS = {
|
||||||
"outstanding_primary_catchments": "Outstanding primary school catchments",
|
"outstanding_primary_catchments": "Outstanding primary school catchments",
|
||||||
"outstanding_secondary_catchments": "Outstanding secondary school catchments",
|
"outstanding_secondary_catchments": "Outstanding secondary school catchments",
|
||||||
"max_download_speed": "Max available download speed (Mbps)",
|
"max_download_speed": "Max available download speed (Mbps)",
|
||||||
|
"serious_crime_avg_yr": "Serious crime (avg/yr)",
|
||||||
|
"minor_crime_avg_yr": "Minor crime (avg/yr)",
|
||||||
"mean_monthly_rent": "Estimated monthly rent",
|
"mean_monthly_rent": "Estimated monthly rent",
|
||||||
"floor_height": "Interior height (m)",
|
"floor_height": "Interior height (m)",
|
||||||
"was_council_house": "Former council house",
|
"was_council_house": "Former council house",
|
||||||
|
|
@ -845,6 +822,78 @@ def _validate_property_postcodes(df: pl.DataFrame) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_english_postcode_area(arcgis_raw: pl.LazyFrame) -> pl.LazyFrame:
|
||||||
|
"""Return the supported postcode universe with geography join keys."""
|
||||||
|
return (
|
||||||
|
arcgis_raw.filter(pl.col("ctry25cd") == "E92000001")
|
||||||
|
.filter(pl.col("doterm").is_null())
|
||||||
|
.select(
|
||||||
|
pl.col("pcds").alias("postcode"),
|
||||||
|
"lat",
|
||||||
|
pl.col("long").alias("lon"),
|
||||||
|
"ctry25cd",
|
||||||
|
pl.col("lsoa21cd").alias("lsoa21"),
|
||||||
|
pl.col("oa21cd").alias("oa21"),
|
||||||
|
pl.col("pcon24cd").alias("pcon"),
|
||||||
|
)
|
||||||
|
.drop_nulls(["postcode"])
|
||||||
|
.unique(["postcode"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remap_terminated_postcodes(
|
||||||
|
wide: pl.LazyFrame, postcode_mapping: pl.LazyFrame
|
||||||
|
) -> pl.LazyFrame:
|
||||||
|
return (
|
||||||
|
wide.join(
|
||||||
|
postcode_mapping,
|
||||||
|
left_on="postcode",
|
||||||
|
right_on="old_postcode",
|
||||||
|
how="left",
|
||||||
|
)
|
||||||
|
.with_columns(
|
||||||
|
pl.coalesce("new_postcode", "postcode").alias("postcode"),
|
||||||
|
)
|
||||||
|
.drop("new_postcode")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_collapsed_properties(wide: pl.LazyFrame) -> pl.LazyFrame:
|
||||||
|
"""Keep one row per (postcode, address) — the most-recent transaction.
|
||||||
|
|
||||||
|
The terminated-postcode remap can map two distinct postcodes onto one active
|
||||||
|
successor, collapsing the same physical address onto a single
|
||||||
|
(postcode, address) key with conflicting sale records. Keep the row with the
|
||||||
|
latest date_of_transfer so the headline price/date reflect the most recent
|
||||||
|
transaction; genuinely distinct addresses are untouched.
|
||||||
|
|
||||||
|
The dedup key coalesces the price-paid address with the EPC address: EPC-only
|
||||||
|
dwellings (never sold) have a null pp_address, so keying on pp_address alone
|
||||||
|
would collapse EVERY EPC-only dwelling in a postcode onto one
|
||||||
|
(postcode, null) key and silently drop all but one. Each dwelling's coalesced
|
||||||
|
address is unique within its postcode (the EPC frame is deduped on
|
||||||
|
address+postcode upstream), so the coalesced key keeps them distinct while
|
||||||
|
leaving sold-property dedup unchanged — pp_address wins the coalesce whenever
|
||||||
|
a sale exists.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
wide.with_columns(
|
||||||
|
pl.coalesce("pp_address", "epc_address").alias("_dedupe_address")
|
||||||
|
)
|
||||||
|
.sort("date_of_transfer", descending=True, nulls_last=True)
|
||||||
|
.unique(
|
||||||
|
subset=["postcode", "_dedupe_address"], keep="first", maintain_order=True
|
||||||
|
)
|
||||||
|
.drop("_dedupe_address")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_to_active_english_postcodes(
|
||||||
|
wide: pl.LazyFrame, active_postcodes: pl.LazyFrame
|
||||||
|
) -> pl.LazyFrame:
|
||||||
|
return wide.join(active_postcodes, on="postcode", how="semi")
|
||||||
|
|
||||||
|
|
||||||
def _join_area_side_tables(
|
def _join_area_side_tables(
|
||||||
base: pl.LazyFrame,
|
base: pl.LazyFrame,
|
||||||
*,
|
*,
|
||||||
|
|
@ -874,15 +923,21 @@ def _join_area_side_tables(
|
||||||
# joined on the same `lsoa21` key as ethnicity, education, IoD, and median age.
|
# joined on the same `lsoa21` key as ethnicity, education, IoD, and median age.
|
||||||
base = base.join(tenure, on="lsoa21", how="left")
|
base = base.join(tenure, on="lsoa21", how="left")
|
||||||
|
|
||||||
# Crime is counted spatially per postcode (incidents within the boundary
|
# Crime is counted spatially per postcode (incidents within 50m of the
|
||||||
# buffer), so it joins on postcode rather than LSOA. crime_spatial writes
|
# postcode boundary), so it joins on postcode rather than LSOA. crime_spatial
|
||||||
# average-annual-count columns ("{type} (/yr, 7y|2y)"), including the
|
# precomputes the Serious/Minor headline rollups as the mean of the by-year
|
||||||
# Serious/Minor rollups (the exact sum of their components); all pass straight
|
# rollup bars; read those straight through (renamed to the internal columns
|
||||||
# through to display/filtering. A postcode absent from the crime table keeps
|
# _finalize_merged_columns expects) rather than re-summing the per-type
|
||||||
# null values via the left join (no fabricated zero). The per-incident records
|
# avg/yr columns — summing divides each type by its OWN years-present and
|
||||||
# are a separate side table the server loads directly, so it is not joined
|
# overstates the rollup when types differ in coverage. A postcode absent from
|
||||||
# here.
|
# the crime table keeps null rollups via the left join (no fabricated zero);
|
||||||
base = base.join(crime, on="postcode", how="left")
|
# the per-type avg/yr columns pass through unchanged for display.
|
||||||
|
base = base.join(crime, on="postcode", how="left").rename(
|
||||||
|
{
|
||||||
|
"Serious crime (avg/yr)": "serious_crime_avg_yr",
|
||||||
|
"Minor crime (avg/yr)": "minor_crime_avg_yr",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
base = base.join(median_age, on="lsoa21", how="left")
|
base = base.join(median_age, on="lsoa21", how="left")
|
||||||
base = base.join(election, on="pcon", how="left")
|
base = base.join(election, on="pcon", how="left")
|
||||||
|
|
@ -2331,17 +2386,27 @@ def _build(
|
||||||
)
|
)
|
||||||
_validate_lad_source_coverage(iod_path, rental_prices_path)
|
_validate_lad_source_coverage(iod_path, rental_prices_path)
|
||||||
|
|
||||||
# The dwelling universe — floor filter, terminated-postcode remap,
|
wide = pl.scan_parquet(epc_pp_path).filter(
|
||||||
# collapse-dedupe, restrict to active English postcodes — is shared with
|
pl.col("total_floor_area").is_null()
|
||||||
# price estimation so estimates line up 1:1 with these rows. See
|
| (pl.col("total_floor_area") > MIN_FLOOR_AREA_M2)
|
||||||
# pipeline.transform.property_base.
|
)
|
||||||
wide = build_property_base(epc_pp_path, arcgis_path)
|
|
||||||
|
# Remap terminated postcodes to nearest active successor before filtering to
|
||||||
|
# the supported active-English postcode universe. Historical properties from
|
||||||
|
# terminated English postcodes are retained under their successor postcode.
|
||||||
|
postcode_mapping = build_postcode_mapping(arcgis_path)
|
||||||
|
wide = _remap_terminated_postcodes(wide, postcode_mapping.lazy())
|
||||||
|
# The remap can collapse two terminated postcodes onto one active successor,
|
||||||
|
# duplicating a physical address's (postcode, pp_address) key; keep only the
|
||||||
|
# most-recent transaction per address before the per-postcode joins.
|
||||||
|
wide = _dedupe_collapsed_properties(wide)
|
||||||
arcgis_raw = pl.scan_parquet(arcgis_path)
|
arcgis_raw = pl.scan_parquet(arcgis_path)
|
||||||
arcgis = _active_english_postcode_area(arcgis_raw)
|
arcgis = _active_english_postcode_area(arcgis_raw)
|
||||||
active_postcodes = arcgis.select("postcode").unique()
|
active_postcodes = arcgis.select("postcode").unique()
|
||||||
active_postcode_count = (
|
active_postcode_count = (
|
||||||
active_postcodes.select(pl.len()).collect(engine="streaming").item()
|
active_postcodes.select(pl.len()).collect(engine="streaming").item()
|
||||||
)
|
)
|
||||||
|
wide = _filter_to_active_english_postcodes(wide, active_postcodes)
|
||||||
|
|
||||||
if listed_buildings_path is not None:
|
if listed_buildings_path is not None:
|
||||||
active_postcodes_for_listed = (
|
active_postcodes_for_listed = (
|
||||||
|
|
@ -2477,10 +2542,37 @@ def _build(
|
||||||
how="left",
|
how="left",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Derive property_type (EPC preferred, price-paid fallback, built_form for
|
# Derive property_type: prefer EPC data, fall back to price-paid.
|
||||||
# houses). Shared with price_inputs so the estimate uses the same type; see
|
# For Houses, use built_form (e.g. Semi-Detached, Mid-Terrace) for finer detail.
|
||||||
# property_base.property_type_expr.
|
bad_built_form = pl.col("built_form").is_null() | pl.col("built_form").is_in(
|
||||||
wide = wide.with_columns(property_type_expr().alias("property_type"))
|
["NO DATA!", "Not Recorded"]
|
||||||
|
)
|
||||||
|
has_epc = pl.col("epc_property_type").is_not_null()
|
||||||
|
is_house = pl.col("epc_property_type") == "House"
|
||||||
|
wide = wide.with_columns(
|
||||||
|
pl.when(has_epc & is_house & ~bad_built_form)
|
||||||
|
.then(pl.col("built_form"))
|
||||||
|
.when(has_epc & is_house)
|
||||||
|
.then(pl.col("pp_property_type"))
|
||||||
|
.when(has_epc)
|
||||||
|
.then(pl.col("epc_property_type"))
|
||||||
|
.otherwise(pl.col("pp_property_type"))
|
||||||
|
# Unify EPC's "Flat"/"Maisonette" with price-paid's "Flats/Maisonettes",
|
||||||
|
# collapse terrace sub-types, and fold rare types into "Other"
|
||||||
|
.replace(
|
||||||
|
{
|
||||||
|
"Flat": "Flats/Maisonettes",
|
||||||
|
"Maisonette": "Flats/Maisonettes",
|
||||||
|
"End-Terrace": "Terraced",
|
||||||
|
"Mid-Terrace": "Terraced",
|
||||||
|
"Enclosed End-Terrace": "Terraced",
|
||||||
|
"Enclosed Mid-Terrace": "Terraced",
|
||||||
|
"Bungalow": "Other",
|
||||||
|
"Park home": "Other",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.alias("property_type")
|
||||||
|
)
|
||||||
|
|
||||||
wide = wide.with_columns(
|
wide = wide.with_columns(
|
||||||
pl.when(pl.col("duration") == "U")
|
pl.when(pl.col("duration") == "U")
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,7 @@ The output of `process_oa` is `list[(postcode, polygon)]` — the per-OA fragmen
|
||||||
|
|
||||||
**Fragment merging** (`output.py:merge_fragments`): Groups all fragments by postcode, unions them. If the result is a MultiPolygon (meaning the postcode has disconnected pieces — either from spanning OAs with a gap, or algorithm artifacts), applies a 5m buffer-then-unbuffer to close tiny gaps from floating-point mismatches at OA boundary edges. If still a MultiPolygon after that, keeps the largest part **plus any other part ≥ `_MIN_DETACHED_PART_AREA` (100 m²)** (`_keep_polygon_parts`); only sub-100 m² noise slivers are dropped. Keeping substantial detached parts matters because a postcode genuinely split across an OA seam (by a railway, river, or main road wider than the 5m buffer) would otherwise lose a chunk — measured at ~1.8% of merged area left as uncovered gaps (often 3000–5000 m² building blocks) before this change.
|
**Fragment merging** (`output.py:merge_fragments`): Groups all fragments by postcode, unions them. If the result is a MultiPolygon (meaning the postcode has disconnected pieces — either from spanning OAs with a gap, or algorithm artifacts), applies a 5m buffer-then-unbuffer to close tiny gaps from floating-point mismatches at OA boundary edges. If still a MultiPolygon after that, keeps the largest part **plus any other part ≥ `_MIN_DETACHED_PART_AREA` (100 m²)** (`_keep_polygon_parts`); only sub-100 m² noise slivers are dropped. Keeping substantial detached parts matters because a postcode genuinely split across an OA seam (by a railway, river, or main road wider than the 5m buffer) would otherwise lose a chunk — measured at ~1.8% of merged area left as uncovered gaps (often 3000–5000 m² building blocks) before this change.
|
||||||
|
|
||||||
**Greenspace subtraction is connectivity-preserving** (`greenspace.py:subtract_greenspace`): park/water polygons are subtracted from each postcode, but greenspace that *crosses* a postcode (a river, a strip of parkland, a golf course through a village) would otherwise split it into scattered pieces. When the subtraction disconnects a postcode, `_reconnect_split` re-adds the narrowest removed necks — a morphological closing (`_RECONNECT_BRIDGE_M`, 25 m) clipped to the original postcode footprint — so parts ≤ ~50 m apart stay joined by a thin bridge of the postcode's own land (no address moves); genuinely wide barriers stay subtracted and the postcode legitimately splits.
|
**GeoJSON output** (`output.py:write_district_geojson`): Two passes. Pass 1 converts every postcode from BNG to WGS84 (pyproj), simplifies with 1m tolerance (Douglas-Peucker), and snaps to 6 decimal places (~0.1m precision); multi-part postcodes become `MultiPolygon` (`to_wgs84_geojson_multi`, each part handled independently), single-part stay `Polygon`. The whole set is then made a **partition** (`_resolve_overlaps`): each postcode is trimmed by the union of its higher-priority overlapping neighbours, where **priority = ascending area** (smaller postcodes win contested ground). That single rule handles both seam overlap *and* containment — an enclosed postcode is always smaller than its container, so it keeps its area while the container gets a hole (the query uses both the `overlaps` and `contains` predicates, since `overlaps` alone excludes containment). This runs last, so nothing re-introduces overlap; a postcode that would be emptied keeps its original geometry, so no active postcode is dropped. Pass 2 groups postcodes by district (the outward code, e.g. `SW1A` from `SW1A 1AA`), rounds coordinates to 6dp, and writes a `{district}.geojson` FeatureCollection. Each Feature has `postcodes` (formatted like `"SW1A 1AA"`) and `mapit_code` (no space: `"SW1A1AA"`) in its properties.
|
||||||
|
|
||||||
**GeoJSON output** (`output.py:write_district_geojson`): three passes. Pass 1 converts every postcode from BNG to WGS84 (pyproj), simplifies with 1m tolerance (Douglas-Peucker), and snaps to 6 decimal places (~0.1m precision); multi-part postcodes become `MultiPolygon` (`to_wgs84_geojson_multi`, each part handled independently), single-part stay `Polygon`. The whole set is then made a **partition** (`_resolve_overlaps`): each postcode is trimmed by the union of its higher-priority overlapping neighbours, where **priority = ascending area** (smaller postcodes win contested ground). That single rule handles both seam overlap *and* containment — an enclosed postcode is always smaller than its container, so it keeps its area while the container gets a hole (the query uses both the `overlaps` and `contains` predicates, since `overlaps` alone excludes containment). This runs last, so nothing re-introduces overlap; a postcode that would be emptied keeps its original geometry, so no active postcode is dropped. Pass 2 **de-fragments** the partition (`_eliminate_small_detached_parts`): a detached part that is *both* small in absolute terms (< `_ELIM_ABS_MAX_M2`, 3000 m²) *and* a minor fraction (< `_ELIM_FRAC_MAX`, 15%) of its postcode is absorbed into the neighbouring postcode it shares the most boundary with — the classic GIS *eliminate*. This removes the Voronoi/INSPIRE/seam *scatter* that left ~1/3 of postcodes non-contiguous, while a genuine bisection (two substantial parts split by a river/railway) keeps both parts. The land is **reassigned**, never dropped, so the output stays a gapless partition and coverage is conserved; the largest part of every postcode is always retained, so no active postcode is dropped (a tiny neighbour-less sliver in removed greenspace is dropped, a larger isolated patch is kept). Pass 3 groups postcodes by district (the outward code, e.g. `SW1A` from `SW1A 1AA`), rounds coordinates to 6dp, and writes a `{district}.geojson` FeatureCollection. Each Feature has `postcodes` (formatted like `"SW1A 1AA"`) and `mapit_code` (no space: `"SW1A1AA"`) in its properties.
|
|
||||||
|
|
||||||
## Memory architecture
|
## Memory architecture
|
||||||
|
|
||||||
|
|
@ -109,7 +107,6 @@ Key design choices:
|
||||||
2. **Every postcode that exists in the UPRN data gets a polygon** — unless all its UPRNs share coordinates with another postcode's UPRNs (handled by jitter) or it has zero UPRNs
|
2. **Every postcode that exists in the UPRN data gets a polygon** — unless all its UPRNs share coordinates with another postcode's UPRNs (handled by jitter) or it has zero UPRNs
|
||||||
3. **Postcode polygons never extend outside their OA(s)** — all geometry is clipped to OA boundaries
|
3. **Postcode polygons never extend outside their OA(s)** — all geometry is clipped to OA boundaries
|
||||||
4. **A postcode split across an OA seam keeps all its substantial parts** — `merge_fragments` keeps every part ≥ 100 m² and the output is emitted as a `MultiPolygon` (the Rust server `postcodes.rs` and `loader.py` both parse MultiPolygon); only sub-100 m² noise slivers are dropped
|
4. **A postcode split across an OA seam keeps all its substantial parts** — `merge_fragments` keeps every part ≥ 100 m² and the output is emitted as a `MultiPolygon` (the Rust server `postcodes.rs` and `loader.py` both parse MultiPolygon); only sub-100 m² noise slivers are dropped
|
||||||
5. **Postcodes are contiguous unless genuinely split** — most non-contiguity is *scatter* (a unit drawn as many disconnected specks) from point-Voronoi over sparse/interleaved UPRNs, greenspace cutting across a unit, and overlap/seam slivers. Connectivity-preserving greenspace subtraction + the `_eliminate_small_detached_parts` de-fragmentation pass absorb that scatter into neighbours (coverage-conserving), cutting the share of multi-part postcodes roughly in half (~30% → ~14% measured on the worst rural/coastal districts) without dropping any postcode or leaving coverage gaps. Genuine bisections (river/railway/major road, or a detached part above the absolute+fraction thresholds) are preserved.
|
|
||||||
|
|
||||||
## Module structure
|
## Module structure
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from shapely import make_valid, wkb
|
||||||
from shapely.geometry import MultiPolygon, Polygon
|
from shapely.geometry import MultiPolygon, Polygon
|
||||||
from shapely.strtree import STRtree
|
from shapely.strtree import STRtree
|
||||||
|
|
||||||
from .geometry import _SNAP_GRID, _poly_valid, safe_difference, safe_intersection, safe_union
|
from .geometry import safe_difference, safe_union
|
||||||
|
|
||||||
|
|
||||||
def load_greenspace(path: Path) -> tuple[STRtree, list]:
|
def load_greenspace(path: Path) -> tuple[STRtree, list]:
|
||||||
|
|
@ -36,42 +36,6 @@ def load_greenspace(path: Path) -> tuple[STRtree, list]:
|
||||||
|
|
||||||
MAX_REMOVAL_FRACTION = 0.9 # Keep original if >90% would be removed
|
MAX_REMOVAL_FRACTION = 0.9 # Keep original if >90% would be removed
|
||||||
|
|
||||||
# Greenspace that merely trims the edge of a postcode is fine, but greenspace
|
|
||||||
# that CROSSES it (a river, a strip of parkland, a golf course running through a
|
|
||||||
# village) splits the postcode into a MultiPolygon -- one the map then draws as
|
|
||||||
# several disconnected pieces. When subtraction disconnects a postcode, re-add
|
|
||||||
# the postcode's OWN removed land along the narrowest necks (a morphological
|
|
||||||
# closing clipped to the original footprint) so the parts stay joined by a thin
|
|
||||||
# bridge. Parts left more than ~2x this width apart (a genuinely wide barrier)
|
|
||||||
# stay split. Because the bridge is the postcode's own land, no address moves and
|
|
||||||
# only a thin sliver of green is kept back.
|
|
||||||
_RECONNECT_BRIDGE_M = 25.0
|
|
||||||
|
|
||||||
|
|
||||||
def _reconnect_split(
|
|
||||||
result: Polygon | MultiPolygon, postcode_geom: Polygon | MultiPolygon
|
|
||||||
) -> Polygon | MultiPolygon:
|
|
||||||
"""Re-join postcode parts that greenspace subtraction pulled apart by re-adding
|
|
||||||
the narrow removed necks (within the original postcode), leaving wide barriers
|
|
||||||
intact."""
|
|
||||||
if result.geom_type != "MultiPolygon":
|
|
||||||
return result
|
|
||||||
closed = result.buffer(_RECONNECT_BRIDGE_M).buffer(-_RECONNECT_BRIDGE_M)
|
|
||||||
if not closed.is_valid:
|
|
||||||
closed = make_valid(closed)
|
|
||||||
# The closing material that lies inside the original postcode but outside the
|
|
||||||
# subtraction result == the thin green necks linking the parts. The exact
|
|
||||||
# overlay path can leave line/point debris (coincident edges) that is
|
|
||||||
# zero-area but NOT is_empty; `_poly_valid` strips it to polygons only, so the
|
|
||||||
# is_empty guard works and the union can never return a GeometryCollection
|
|
||||||
# (which `to_wgs84_geojson_multi` would silently truncate to a single part).
|
|
||||||
bridges = _poly_valid(
|
|
||||||
safe_difference(safe_intersection(closed, postcode_geom), result), _SNAP_GRID
|
|
||||||
)
|
|
||||||
if bridges.is_empty:
|
|
||||||
return result
|
|
||||||
return _poly_valid(safe_union([result, bridges]), _SNAP_GRID)
|
|
||||||
|
|
||||||
|
|
||||||
def subtract_greenspace(
|
def subtract_greenspace(
|
||||||
postcode_geom: Polygon | MultiPolygon,
|
postcode_geom: Polygon | MultiPolygon,
|
||||||
|
|
@ -84,10 +48,6 @@ def subtract_greenspace(
|
||||||
of intersecting greenspace from the postcode polygon. If subtraction
|
of intersecting greenspace from the postcode polygon. If subtraction
|
||||||
would remove >90% of the area, keeps the original (the postcode
|
would remove >90% of the area, keeps the original (the postcode
|
||||||
genuinely covers that land, e.g. churchyards, riverside addresses).
|
genuinely covers that land, e.g. churchyards, riverside addresses).
|
||||||
|
|
||||||
If the subtraction disconnects the postcode (greenspace crossing it),
|
|
||||||
:func:`_reconnect_split` re-adds the narrowest removed necks so the postcode
|
|
||||||
stays a single piece rather than shipping as scattered fragments.
|
|
||||||
"""
|
"""
|
||||||
candidate_idxs = tree.query(postcode_geom)
|
candidate_idxs = tree.query(postcode_geom)
|
||||||
if len(candidate_idxs) == 0:
|
if len(candidate_idxs) == 0:
|
||||||
|
|
@ -114,4 +74,4 @@ def subtract_greenspace(
|
||||||
if original_area > 0 and result.area / original_area < (1 - MAX_REMOVAL_FRACTION):
|
if original_area > 0 and result.area / original_area < (1 - MAX_REMOVAL_FRACTION):
|
||||||
return postcode_geom
|
return postcode_geom
|
||||||
|
|
||||||
return _reconnect_split(result, postcode_geom)
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import shutil
|
import shutil
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -384,164 +383,6 @@ def _resolve_overlaps(
|
||||||
return [(pc, out[i]) for i, (pc, _) in enumerate(items)]
|
return [(pc, out[i]) for i, (pc, _) in enumerate(items)]
|
||||||
|
|
||||||
|
|
||||||
# A detached (non-largest) part of a postcode is absorbed into the neighbouring
|
|
||||||
# postcode it shares the most boundary with, when the part is BOTH small in
|
|
||||||
# absolute terms AND a minor fraction of its postcode. This removes the
|
|
||||||
# Voronoi/INSPIRE/overlap-seam SCATTER that otherwise left ~1/3 of postcodes
|
|
||||||
# non-contiguous (a single unit drawn as many disconnected specks), while keeping
|
|
||||||
# genuine splits: a postcode bisected by a river/railway into two SUBSTANTIAL
|
|
||||||
# parts has both parts above these thresholds and is left alone. The land is
|
|
||||||
# REASSIGNED to an adjacent postcode wherever a neighbour exists, so coverage is
|
|
||||||
# conserved and the output stays a partition; only a tiny neighbour-less sliver
|
|
||||||
# (< _ELIM_DROP_BELOW_M2, snap/overlap debris floating in removed greenspace) is
|
|
||||||
# dropped, and a larger isolated part (a genuine detached hamlet) is kept. The
|
|
||||||
# largest part of every postcode is always retained, so no postcode is dropped.
|
|
||||||
_ELIM_ABS_MAX_M2 = 3000.0 # absolute size below which a minor part reads as scatter
|
|
||||||
_ELIM_FRAC_MAX = 0.15 # ...and only when it is < this fraction of the postcode
|
|
||||||
_ELIM_DROP_BELOW_M2 = 200.0 # a neighbour-less sliver this small is snap debris -> drop
|
|
||||||
# WGS84-degree distances at UK latitudes (1e-6 deg ~ 0.11 m at ~53N)
|
|
||||||
_ELIM_SHARED_EPS_DEG = 5e-7 # ~0.05 m: thin probe whose overlap area ~ shared-edge length
|
|
||||||
# Candidate-gather + nearest-neighbour fallback radius, in degrees so it needs no
|
|
||||||
# per-part reprojection. The metric reach is mildly anisotropic (~2.2 m N-S,
|
|
||||||
# ~1.3-1.4 m E-W at England's latitudes); this only bounds the rare gapped-seam
|
|
||||||
# fallback (the dominant border-sharing path is unaffected), so the approximation
|
|
||||||
# is harmless. Gather and accept use the SAME radius, so there is no dead band.
|
|
||||||
_ELIM_NEAREST_MAX_DEG = 2e-5
|
|
||||||
_M_PER_DEG_LAT = 111_320.0
|
|
||||||
_ELIM_ITERATIONS = 2
|
|
||||||
|
|
||||||
|
|
||||||
def _approx_area_m2(geom_deg) -> float:
|
|
||||||
"""Metric area of a WGS84 polygon via a latitude-scaled planar approximation.
|
|
||||||
|
|
||||||
Accurate to a few percent at the few-hundred-to-few-thousand m^2 scale these
|
|
||||||
thresholds work at, and far cheaper than a per-part CRS transform over the
|
|
||||||
full ~1.8M postcodes.
|
|
||||||
"""
|
|
||||||
area_deg2 = geom_deg.area
|
|
||||||
if area_deg2 == 0.0:
|
|
||||||
return 0.0
|
|
||||||
centroid = geom_deg.centroid
|
|
||||||
if centroid.is_empty:
|
|
||||||
lat = (geom_deg.bounds[1] + geom_deg.bounds[3]) / 2
|
|
||||||
else:
|
|
||||||
lat = centroid.y
|
|
||||||
return area_deg2 * _M_PER_DEG_LAT * _M_PER_DEG_LAT * math.cos(math.radians(lat))
|
|
||||||
|
|
||||||
|
|
||||||
def _geom_parts(geom):
|
|
||||||
return list(geom.geoms) if geom.geom_type == "MultiPolygon" else [geom]
|
|
||||||
|
|
||||||
|
|
||||||
def _eliminate_small_detached_parts(
|
|
||||||
items: list[tuple[str, Polygon | MultiPolygon]],
|
|
||||||
) -> list[tuple[str, Polygon | MultiPolygon]]:
|
|
||||||
"""De-fragment the partition: absorb small detached parts into the neighbour
|
|
||||||
they border most (see the module note above).
|
|
||||||
|
|
||||||
Runs on the de-overlapped WGS84 geometries as the last shaping step. The
|
|
||||||
largest part of every postcode is always retained, so no active postcode is
|
|
||||||
dropped; parts are moved between postcodes (or, for tiny neighbour-less
|
|
||||||
slivers, dropped), so total covered area is conserved to within rounding.
|
|
||||||
"""
|
|
||||||
geoms: dict[str, Polygon | MultiPolygon] = {pc: g for pc, g in items}
|
|
||||||
for _ in range(_ELIM_ITERATIONS):
|
|
||||||
recs: list[tuple[str, Polygon, float]] = []
|
|
||||||
total: dict[str, float] = defaultdict(float)
|
|
||||||
largest: dict[str, float] = defaultdict(float)
|
|
||||||
for pc, geom in geoms.items():
|
|
||||||
for part in _geom_parts(geom):
|
|
||||||
if part.is_empty:
|
|
||||||
continue
|
|
||||||
area = _approx_area_m2(part)
|
|
||||||
recs.append((pc, part, area))
|
|
||||||
total[pc] += area
|
|
||||||
if area > largest[pc]:
|
|
||||||
largest[pc] = area
|
|
||||||
|
|
||||||
tree = STRtree([part for _, part, _ in recs])
|
|
||||||
assign: dict[int, str | None] = {}
|
|
||||||
for i, (pc, part, area) in enumerate(recs):
|
|
||||||
if area >= largest[pc]:
|
|
||||||
continue # the largest part always stays -> a postcode never vanishes
|
|
||||||
if not (area < _ELIM_ABS_MAX_M2 and area < _ELIM_FRAC_MAX * total[pc]):
|
|
||||||
continue # substantial or major-fraction part = genuine split, keep
|
|
||||||
|
|
||||||
best_pc = None
|
|
||||||
best_score = 0.0
|
|
||||||
nearest_pc = None
|
|
||||||
nearest_d = float("inf")
|
|
||||||
# Gather candidates out to the nearest-fallback radius (not just the
|
|
||||||
# smaller border-probe radius), so a gapped seam in the
|
|
||||||
# (border, nearest] band can actually be reassigned rather than dropped.
|
|
||||||
for j in tree.query(part.buffer(_ELIM_NEAREST_MAX_DEG)):
|
|
||||||
if j == i:
|
|
||||||
continue
|
|
||||||
other_pc, other_geom, _ = recs[j]
|
|
||||||
if other_pc == pc:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
score = part.buffer(_ELIM_SHARED_EPS_DEG).intersection(other_geom).area
|
|
||||||
except GEOSException:
|
|
||||||
score = 0.0
|
|
||||||
# Ties broken by the lexicographically smaller postcode so the
|
|
||||||
# result is independent of STRtree traversal order (matches the
|
|
||||||
# stable tie-break in _resolve_overlaps; keeps output byte-stable
|
|
||||||
# across shapely/GEOS upgrades for content-hash caching).
|
|
||||||
if score > best_score or (
|
|
||||||
score == best_score and best_pc is not None and other_pc < best_pc
|
|
||||||
):
|
|
||||||
best_score = score
|
|
||||||
best_pc = other_pc
|
|
||||||
dist = part.distance(other_geom)
|
|
||||||
if dist < nearest_d or (
|
|
||||||
dist == nearest_d
|
|
||||||
and nearest_pc is not None
|
|
||||||
and other_pc < nearest_pc
|
|
||||||
):
|
|
||||||
nearest_d = dist
|
|
||||||
nearest_pc = other_pc
|
|
||||||
|
|
||||||
if best_pc is not None and best_score > 0:
|
|
||||||
assign[i] = best_pc # shares a border -> absorb into that neighbour
|
|
||||||
elif nearest_pc is not None and nearest_d <= _ELIM_NEAREST_MAX_DEG:
|
|
||||||
assign[i] = nearest_pc # gapped seam -> nearest neighbour
|
|
||||||
elif area < _ELIM_DROP_BELOW_M2:
|
|
||||||
assign[i] = None # neighbour-less snap/overlap sliver -> drop
|
|
||||||
# else: keep with its own postcode (a genuine isolated patch)
|
|
||||||
|
|
||||||
if not assign:
|
|
||||||
break
|
|
||||||
|
|
||||||
new_parts: dict[str, list] = defaultdict(list)
|
|
||||||
for i, (pc, part, _) in enumerate(recs):
|
|
||||||
if i in assign:
|
|
||||||
target = assign[i]
|
|
||||||
if target is None:
|
|
||||||
continue
|
|
||||||
new_parts[target].append(part)
|
|
||||||
else:
|
|
||||||
new_parts[pc].append(part)
|
|
||||||
|
|
||||||
rebuilt: dict[str, Polygon | MultiPolygon] = {}
|
|
||||||
for pc, parts in new_parts.items():
|
|
||||||
if len(parts) == 1:
|
|
||||||
rebuilt[pc] = parts[0]
|
|
||||||
else:
|
|
||||||
merged = safe_union(parts, grid=_OUTPUT_PRECISION_DEG)
|
|
||||||
if not merged.is_empty:
|
|
||||||
rebuilt[pc] = merged
|
|
||||||
# Carry forward any postcode that contributed no parts to recs (e.g. an
|
|
||||||
# all-empty input geometry, which the part loop skips): never drop a
|
|
||||||
# postcode just because reassignment fired elsewhere in this pass.
|
|
||||||
for pc, geom in geoms.items():
|
|
||||||
if pc not in rebuilt:
|
|
||||||
rebuilt[pc] = geom
|
|
||||||
geoms = rebuilt
|
|
||||||
|
|
||||||
return list(geoms.items())
|
|
||||||
|
|
||||||
|
|
||||||
def _round_coords(coords, ndigits=6):
|
def _round_coords(coords, ndigits=6):
|
||||||
if coords and isinstance(coords[0], (int, float)):
|
if coords and isinstance(coords[0], (int, float)):
|
||||||
return [round(coords[0], ndigits), round(coords[1], ndigits)]
|
return [round(coords[0], ndigits), round(coords[1], ndigits)]
|
||||||
|
|
@ -660,11 +501,6 @@ def write_district_geojson(
|
||||||
# Remove overlap strips so the output is a clean partition.
|
# Remove overlap strips so the output is a clean partition.
|
||||||
converted = _resolve_overlaps(converted)
|
converted = _resolve_overlaps(converted)
|
||||||
|
|
||||||
# De-fragment: absorb small detached parts (Voronoi/INSPIRE/seam scatter) into
|
|
||||||
# the neighbour they border most, so postcodes stop shipping as many
|
|
||||||
# disconnected specks. Coverage-preserving; runs on the final partition.
|
|
||||||
converted = _eliminate_small_detached_parts(converted)
|
|
||||||
|
|
||||||
by_district: dict[str, list[tuple[str, Polygon | MultiPolygon]]] = defaultdict(list)
|
by_district: dict[str, list[tuple[str, Polygon | MultiPolygon]]] = defaultdict(list)
|
||||||
for pc, geom in converted:
|
for pc, geom in converted:
|
||||||
parts = pc.split()
|
parts = pc.split()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ Each test targets a specific bug or edge case identified during code review.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
@ -22,8 +21,6 @@ from .inspire import build_inspire_index
|
||||||
from .oa_boundaries import parse_gpkg_geometry
|
from .oa_boundaries import parse_gpkg_geometry
|
||||||
from .greenspace import subtract_greenspace
|
from .greenspace import subtract_greenspace
|
||||||
from .output import (
|
from .output import (
|
||||||
_approx_area_m2,
|
|
||||||
_eliminate_small_detached_parts,
|
|
||||||
_fill_holes,
|
_fill_holes,
|
||||||
merge_fragments,
|
merge_fragments,
|
||||||
to_wgs84_geojson,
|
to_wgs84_geojson,
|
||||||
|
|
@ -1833,231 +1830,3 @@ class TestFragmentsCache:
|
||||||
cache.write_text("c")
|
cache.write_text("c")
|
||||||
# arcgis is optional/absent — it cannot have invalidated the cache.
|
# arcgis is optional/absent — it cannot have invalidated the cache.
|
||||||
assert fragments_cache_is_fresh(cache, [tmp_path / "absent.parquet"]) is True
|
assert fragments_cache_is_fresh(cache, [tmp_path / "absent.parquet"]) is True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# De-fragmentation: small detached parts are absorbed into their best neighbour
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# WGS84 helper: build a box from METRE offsets around a fixed England anchor, so
|
|
||||||
# the eliminate pass (which works in WGS84 degrees and measures area with a
|
|
||||||
# latitude-scaled approximation) sees realistic coordinates and areas.
|
|
||||||
_ANCHOR_LON, _ANCHOR_LAT = -0.1, 51.5
|
|
||||||
_M_PER_DEG_LAT = 111_320.0
|
|
||||||
_M_PER_DEG_LON = 111_320.0 * math.cos(math.radians(_ANCHOR_LAT))
|
|
||||||
|
|
||||||
|
|
||||||
def _mbox(x0, y0, x1, y1):
|
|
||||||
return box(
|
|
||||||
_ANCHOR_LON + x0 / _M_PER_DEG_LON,
|
|
||||||
_ANCHOR_LAT + y0 / _M_PER_DEG_LAT,
|
|
||||||
_ANCHOR_LON + x1 / _M_PER_DEG_LON,
|
|
||||||
_ANCHOR_LAT + y1 / _M_PER_DEG_LAT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(items):
|
|
||||||
return dict(items)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEliminateSmallDetachedParts:
|
|
||||||
"""A small detached part should be absorbed into the postcode it borders most,
|
|
||||||
de-fragmenting the unit while conserving coverage and never dropping a
|
|
||||||
postcode. Genuine large splits must survive."""
|
|
||||||
|
|
||||||
def test_small_island_absorbed_by_surrounding_neighbour(self):
|
|
||||||
# A: a big main blob (left) plus a small 30x30m island sitting INSIDE B's
|
|
||||||
# territory; B fills the middle/right with a hole where the island is.
|
|
||||||
main_a = _mbox(0, 0, 100, 200) # 20000 m²
|
|
||||||
island = _mbox(150, 90, 180, 120) # 900 m², surrounded by B
|
|
||||||
a = MultiPolygon([main_a, island])
|
|
||||||
b = _mbox(100, 0, 300, 200).difference(island) # hole around the island
|
|
||||||
before = unary_union([a, b]).area
|
|
||||||
|
|
||||||
out = _as_dict(_eliminate_small_detached_parts([("A", a), ("B", b)]))
|
|
||||||
|
|
||||||
assert "A" in out and "B" in out, "no postcode may be dropped"
|
|
||||||
assert out["A"].geom_type == "Polygon", "A's island should be absorbed away"
|
|
||||||
# The island area moves to B (the surrounding postcode); coverage is kept.
|
|
||||||
assert out["B"].contains(island.representative_point())
|
|
||||||
after = unary_union([out["A"], out["B"]]).area
|
|
||||||
assert after == pytest.approx(before, rel=1e-6), "coverage must be conserved"
|
|
||||||
|
|
||||||
def test_genuine_large_split_is_kept(self):
|
|
||||||
# Two substantial parts (both 40000 m²) far apart — a real bisection, not
|
|
||||||
# scatter. Neither is below the absolute/fraction thresholds, so both stay.
|
|
||||||
a = MultiPolygon([_mbox(0, 0, 200, 200), _mbox(400, 0, 600, 200)])
|
|
||||||
out = _as_dict(_eliminate_small_detached_parts([("A", a)]))
|
|
||||||
assert out["A"].geom_type == "MultiPolygon"
|
|
||||||
assert len(out["A"].geoms) == 2
|
|
||||||
|
|
||||||
def test_midsize_minor_part_kept_when_above_abs_threshold(self):
|
|
||||||
# A big main plus a 10000 m² detached part: above the 3000 m² absolute
|
|
||||||
# threshold, so it is a genuine secondary piece and must NOT be eliminated,
|
|
||||||
# even though it is a small fraction of the postcode.
|
|
||||||
a = MultiPolygon([_mbox(0, 0, 300, 300), _mbox(1000, 0, 1100, 100)])
|
|
||||||
out = _as_dict(_eliminate_small_detached_parts([("A", a)]))
|
|
||||||
assert out["A"].geom_type == "MultiPolygon"
|
|
||||||
|
|
||||||
def test_tiny_neighbourless_sliver_is_dropped(self):
|
|
||||||
# A main blob plus a 10x10m (100 m²) sliver far from anything: below the
|
|
||||||
# drop threshold with no neighbour to absorb it -> dropped as snap debris.
|
|
||||||
a = MultiPolygon([_mbox(0, 0, 200, 200), _mbox(1000, 1000, 1010, 1010)])
|
|
||||||
out = _as_dict(_eliminate_small_detached_parts([("A", a)]))
|
|
||||||
assert "A" in out, "the postcode itself must survive"
|
|
||||||
assert out["A"].geom_type == "Polygon"
|
|
||||||
|
|
||||||
def test_largest_part_always_retained_no_postcode_dropped(self):
|
|
||||||
# Even a postcode that is ENTIRELY a tiny sliver keeps its (largest) part —
|
|
||||||
# active postcodes must never be dropped (validate_outputs is zero-tolerance).
|
|
||||||
tiny = _mbox(500, 500, 505, 505) # 25 m², the whole postcode
|
|
||||||
big = _mbox(0, 0, 300, 300)
|
|
||||||
out = _as_dict(
|
|
||||||
_eliminate_small_detached_parts([("TINY", tiny), ("BIG", big)])
|
|
||||||
)
|
|
||||||
assert "TINY" in out and not out["TINY"].is_empty
|
|
||||||
assert "BIG" in out
|
|
||||||
|
|
||||||
def test_no_overlaps_introduced(self):
|
|
||||||
# Absorbing parts must keep the output a partition (no double-coverage).
|
|
||||||
main_a = _mbox(0, 0, 100, 200)
|
|
||||||
island = _mbox(150, 90, 180, 120)
|
|
||||||
a = MultiPolygon([main_a, island])
|
|
||||||
b = _mbox(100, 0, 300, 200).difference(island)
|
|
||||||
out = _as_dict(_eliminate_small_detached_parts([("A", a), ("B", b)]))
|
|
||||||
overlap = out["A"].intersection(out["B"]).area
|
|
||||||
assert overlap < (1.0 / (_M_PER_DEG_LAT * _M_PER_DEG_LON)), "no overlap"
|
|
||||||
|
|
||||||
def test_empty_postcode_not_dropped_when_reassignment_fires(self):
|
|
||||||
# Regression: an empty-geometry postcode must survive even when a
|
|
||||||
# reassignment fires elsewhere in the same pass (the rebuild used to drop
|
|
||||||
# any key absent from `recs`). No active postcode may ever be dropped.
|
|
||||||
main_a = _mbox(0, 0, 100, 200)
|
|
||||||
island = _mbox(150, 90, 180, 120) # B absorbs this -> reassignment fires
|
|
||||||
a = MultiPolygon([main_a, island])
|
|
||||||
b = _mbox(100, 0, 300, 200).difference(island)
|
|
||||||
empty = Polygon() # degenerate input that contributes no parts
|
|
||||||
out = _as_dict(
|
|
||||||
_eliminate_small_detached_parts([("A", a), ("B", b), ("EMPTY", empty)])
|
|
||||||
)
|
|
||||||
assert "EMPTY" in out, "an empty-geometry postcode must not be dropped"
|
|
||||||
assert "A" in out and "B" in out
|
|
||||||
|
|
||||||
def test_tiebreak_is_deterministic_smaller_postcode_wins(self):
|
|
||||||
# An island bordered by TWO postcodes with EQUAL shared edges must go to
|
|
||||||
# the lexicographically smaller postcode, independent of STRtree order.
|
|
||||||
a_main = _mbox(0, 0, 100, 100) # A's main, far from the island
|
|
||||||
island = _mbox(300, 50, 320, 70) # 400 m², A's detached part
|
|
||||||
a = MultiPolygon([a_main, island])
|
|
||||||
bbb = _mbox(250, 0, 300, 120) # borders island's left edge
|
|
||||||
ccc = _mbox(320, 0, 370, 120) # borders island's right edge (equal)
|
|
||||||
out = _as_dict(
|
|
||||||
_eliminate_small_detached_parts([("A", a), ("BBB", bbb), ("CCC", ccc)])
|
|
||||||
)
|
|
||||||
pt = island.representative_point()
|
|
||||||
assert out["BBB"].contains(pt), "tie must go to the smaller postcode string"
|
|
||||||
assert not out["CCC"].contains(pt)
|
|
||||||
assert out["A"].geom_type == "Polygon"
|
|
||||||
|
|
||||||
def test_gapped_sliver_within_nearest_radius_is_reassigned_not_dropped(self):
|
|
||||||
# A 300 m² sliver 1.2 m from its only neighbour N — beyond the border probe
|
|
||||||
# but inside the nearest-fallback radius. Before the gather buffer was
|
|
||||||
# widened to the accept radius, the old (smaller) gather buffer never
|
|
||||||
# returned N, so the sliver fell through to the drop branch. Now it is
|
|
||||||
# reassigned to N, conserving coverage.
|
|
||||||
x_main = _mbox(0, 0, 100, 100) # far from the sliver
|
|
||||||
x_sliver = _mbox(278.8, 50, 298.8, 65) # 300 m², right edge at x=298.8
|
|
||||||
x = MultiPolygon([x_main, x_sliver])
|
|
||||||
n = _mbox(300, 0, 400, 200) # left edge at x=300 -> 1.2 m gap
|
|
||||||
before = unary_union([x, n]).area
|
|
||||||
out = _as_dict(_eliminate_small_detached_parts([("X", x), ("N", n)]))
|
|
||||||
assert out["X"].geom_type == "Polygon", "sliver should leave X"
|
|
||||||
assert out["N"].contains(x_sliver.representative_point()), "absorbed into N"
|
|
||||||
after = unary_union([out["X"], out["N"]]).area
|
|
||||||
assert after == pytest.approx(before, rel=1e-6), "coverage conserved, not dropped"
|
|
||||||
|
|
||||||
def test_approx_area_matches_real_metric_area(self):
|
|
||||||
# The latitude-scaled area approximation should be within a few percent of
|
|
||||||
# the true projected area for a building-scale polygon.
|
|
||||||
import pyproj
|
|
||||||
from shapely.ops import transform as transform_geometry
|
|
||||||
|
|
||||||
poly = _mbox(0, 0, 50, 60) # ~3000 m²
|
|
||||||
to_bng = pyproj.Transformer.from_crs(
|
|
||||||
"EPSG:4326", "EPSG:27700", always_xy=True
|
|
||||||
)
|
|
||||||
true_m2 = transform_geometry(to_bng.transform, poly).area
|
|
||||||
assert _approx_area_m2(poly) == pytest.approx(true_m2, rel=0.02)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Greenspace subtraction is connectivity-preserving
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestGreenspaceReconnect:
|
|
||||||
"""Greenspace that CROSSES a postcode must not shatter it: a narrow strip is
|
|
||||||
bridged back (postcode stays one piece), a wide barrier is left subtracted
|
|
||||||
(postcode genuinely splits)."""
|
|
||||||
|
|
||||||
def test_narrow_strip_reconnects_postcode(self):
|
|
||||||
from shapely.strtree import STRtree
|
|
||||||
|
|
||||||
postcode = box(0, 0, 200, 100)
|
|
||||||
strip = box(95, 0, 105, 100) # 10 m wide green strip crossing the postcode
|
|
||||||
# Sanity: a plain difference WOULD split it into two parts.
|
|
||||||
assert postcode.difference(strip).geom_type == "MultiPolygon"
|
|
||||||
|
|
||||||
result = subtract_greenspace(postcode, STRtree([strip]), [strip])
|
|
||||||
assert result.geom_type == "Polygon", "narrow strip should be bridged"
|
|
||||||
assert result.is_valid
|
|
||||||
|
|
||||||
def test_wide_barrier_keeps_postcode_split(self):
|
|
||||||
from shapely.strtree import STRtree
|
|
||||||
|
|
||||||
postcode = box(0, 0, 200, 100)
|
|
||||||
barrier = box(60, 0, 130, 100) # 70 m wide — beyond 2x the bridge width
|
|
||||||
result = subtract_greenspace(postcode, STRtree([barrier]), [barrier])
|
|
||||||
assert result.geom_type == "MultiPolygon", "wide barrier should stay split"
|
|
||||||
assert len(result.geoms) == 2
|
|
||||||
|
|
||||||
def test_edge_greenspace_still_trimmed(self):
|
|
||||||
# Greenspace on the edge (not crossing) is trimmed as before; reconnect is
|
|
||||||
# a no-op because the result stays a single piece.
|
|
||||||
from shapely.strtree import STRtree
|
|
||||||
|
|
||||||
postcode = box(0, 0, 100, 100) # 10000 m²
|
|
||||||
park = box(60, 0, 100, 100) # 4000 m² on the right edge
|
|
||||||
result = subtract_greenspace(postcode, STRtree([park]), [park])
|
|
||||||
assert result.geom_type == "Polygon"
|
|
||||||
assert result.area == pytest.approx(6000, rel=0.01)
|
|
||||||
|
|
||||||
def test_result_is_always_polygonal(self):
|
|
||||||
# Regression: _reconnect_split must never return a GeometryCollection
|
|
||||||
# (line/point debris) — downstream to_wgs84_geojson_multi would truncate a
|
|
||||||
# GC to a single part, silently dropping substantial pieces. Sweep many
|
|
||||||
# strip widths/offsets (incl. coincident-edge-prone integer geometries).
|
|
||||||
from shapely.strtree import STRtree
|
|
||||||
|
|
||||||
postcode = box(0, 0, 200, 120)
|
|
||||||
for w in (2, 5, 8, 10, 25, 49, 50, 51, 60, 90):
|
|
||||||
for x0 in (40, 70, 95, 100, 130):
|
|
||||||
strip = box(x0, -10, x0 + w, 130)
|
|
||||||
result = subtract_greenspace(postcode, STRtree([strip]), [strip])
|
|
||||||
assert result.geom_type in ("Polygon", "MultiPolygon"), (
|
|
||||||
f"w={w} x0={x0} produced {result.geom_type}"
|
|
||||||
)
|
|
||||||
assert result.is_valid and not result.is_empty
|
|
||||||
|
|
||||||
def test_wide_barrier_preserves_all_substantial_parts(self):
|
|
||||||
# The motivating case for the GC fix: a wide barrier genuinely splits the
|
|
||||||
# postcode; ALL substantial parts must survive (not be truncated to one).
|
|
||||||
from shapely.strtree import STRtree
|
|
||||||
|
|
||||||
postcode = box(0, 0, 300, 100)
|
|
||||||
barrier = box(120, 0, 200, 100) # 80 m wide -> beyond 2x bridge
|
|
||||||
result = subtract_greenspace(postcode, STRtree([barrier]), [barrier])
|
|
||||||
assert result.geom_type == "MultiPolygon"
|
|
||||||
areas = sorted(p.area for p in result.geoms)
|
|
||||||
assert areas == pytest.approx([10000, 12000], rel=0.01) # both banks kept
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Estimate current prices for the merged properties, as a standalone artifact.
|
"""Augment properties.parquet with estimated current prices.
|
||||||
|
|
||||||
For properties with a known prior sale, applies the repeat-sales price index
|
For properties with a known prior sale, applies the repeat-sales price index
|
||||||
to adjust the last known price to the current date, then blends with kNN
|
to adjust the last known price to the current date, then blends with kNN
|
||||||
|
|
@ -6,13 +6,8 @@ estimates from nearby recently-sold properties. Includes:
|
||||||
- Capping extreme index adjustments
|
- Capping extreme index adjustments
|
||||||
- kNN spatial blending
|
- kNN spatial blending
|
||||||
|
|
||||||
Reads the slim price_inputs.parquet (built by property_base, independently of
|
Modifies properties.parquet in-place. Temporarily joins postcode.parquet
|
||||||
merge's area features) plus postcode.parquet for the lat/lon kNN needs, and
|
for lat/lon needed by kNN, then drops those columns before writing.
|
||||||
writes a slim price_estimates.parquet of just the natural key (Postcode +
|
|
||||||
coalesced address) and "Estimated current price" / "Est. price per sqm".
|
|
||||||
join_price_estimates.py joins those two columns back onto properties.parquet.
|
|
||||||
Because the inputs do not depend on merge's area columns, adding such a column
|
|
||||||
does not invalidate this step.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -31,27 +26,12 @@ from pipeline.transform.price_estimation.knn import (
|
||||||
from pipeline.transform.price_estimation.utils import (
|
from pipeline.transform.price_estimation.utils import (
|
||||||
CURRENT_FRAC_YEAR,
|
CURRENT_FRAC_YEAR,
|
||||||
CURRENT_YEAR,
|
CURRENT_YEAR,
|
||||||
ESTIMATE_COLUMNS,
|
|
||||||
JOIN_KEYS,
|
|
||||||
MAX_LOG_ADJUSTMENT,
|
MAX_LOG_ADJUSTMENT,
|
||||||
interpolate_log_index,
|
interpolate_log_index,
|
||||||
join_address_expr,
|
|
||||||
sector_expr,
|
sector_expr,
|
||||||
type_group_expr,
|
type_group_expr,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Columns estimate reads from price_inputs.parquet. The two address columns are
|
|
||||||
# only carried to build the natural join key (Postcode + coalesced address).
|
|
||||||
INPUT_COLUMNS = [
|
|
||||||
"Postcode",
|
|
||||||
"Property type",
|
|
||||||
"Total floor area (sqm)",
|
|
||||||
"Last known price",
|
|
||||||
"Date of last transaction",
|
|
||||||
"Address per Property Register",
|
|
||||||
"Address per EPC",
|
|
||||||
]
|
|
||||||
|
|
||||||
MAX_KNN_TO_INDEX_RATIO = 2.0
|
MAX_KNN_TO_INDEX_RATIO = 2.0
|
||||||
MIN_KNN_TO_INDEX_RATIO = 0.5
|
MIN_KNN_TO_INDEX_RATIO = 0.5
|
||||||
# Cap the final estimate at this multiple of the last known price as a guard
|
# Cap the final estimate at this multiple of the last known price as a guard
|
||||||
|
|
@ -181,14 +161,13 @@ def guarded_blend_estimates(
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Estimate current prices for the merged properties"
|
description="Augment properties.parquet with estimated current prices"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--input",
|
"--properties",
|
||||||
type=Path,
|
type=Path,
|
||||||
required=True,
|
required=True,
|
||||||
help="Path to price_inputs.parquet (slim per-dwelling inputs from "
|
help="Path to properties.parquet (modified in-place)",
|
||||||
"property_base)",
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--postcodes",
|
"--postcodes",
|
||||||
|
|
@ -199,23 +178,22 @@ def main():
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--index", type=Path, required=True, help="Path to price_index.parquet"
|
"--index", type=Path, required=True, help="Path to price_index.parquet"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--output",
|
|
||||||
type=Path,
|
|
||||||
required=True,
|
|
||||||
help="Output price_estimates.parquet (natural key + estimate columns)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("Loading price inputs (projection)...")
|
print("Loading properties.parquet...")
|
||||||
df = pl.read_parquet(args.input, columns=INPUT_COLUMNS)
|
df = pl.read_parquet(args.properties)
|
||||||
print(f" {len(df):,} rows, {len(INPUT_COLUMNS)} input columns")
|
print(f" {len(df):,} rows, {len(df.columns)} columns")
|
||||||
|
|
||||||
# Join lat/lon from postcode.parquet for kNN spatial queries
|
# Join lat/lon from postcode.parquet for kNN spatial queries
|
||||||
postcodes = pl.read_parquet(args.postcodes).select("Postcode", "lat", "lon")
|
postcodes = pl.read_parquet(args.postcodes).select("Postcode", "lat", "lon")
|
||||||
df = df.join(postcodes, on="Postcode", how="left")
|
df = df.join(postcodes, on="Postcode", how="left")
|
||||||
print(f" Joined lat/lon from {len(postcodes):,} postcodes")
|
print(f" Joined lat/lon from {len(postcodes):,} postcodes")
|
||||||
|
|
||||||
|
# Drop existing estimated columns if re-running
|
||||||
|
for col in ["Estimated current price", "Est. price per sqm"]:
|
||||||
|
if col in df.columns:
|
||||||
|
df = df.drop(col)
|
||||||
|
|
||||||
# Derive helper columns
|
# Derive helper columns
|
||||||
df = df.with_columns(
|
df = df.with_columns(
|
||||||
sector_expr().alias("_sector"),
|
sector_expr().alias("_sector"),
|
||||||
|
|
@ -377,15 +355,16 @@ def main():
|
||||||
.alias("Est. price per sqm"),
|
.alias("Est. price per sqm"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Emit only the natural join key and the two estimate columns.
|
# Drop all temporary columns and joined lat/lon (those belong in postcode.parquet)
|
||||||
# join_price_estimates.py joins these back onto properties.parquet.
|
temp_cols = [c for c in df.columns if c.startswith("_") or c.startswith("log_idx_")]
|
||||||
result = df.with_columns(join_address_expr()).select(*JOIN_KEYS, *ESTIMATE_COLUMNS)
|
df = df.drop(temp_cols).drop("lat", "lon")
|
||||||
|
|
||||||
result.write_parquet(args.output)
|
df.write_parquet(args.properties)
|
||||||
size_mb = args.output.stat().st_size / (1024 * 1024)
|
size_mb = args.properties.stat().st_size / (1024 * 1024)
|
||||||
print(f"\nWrote {args.output} ({size_mb:.1f} MB)")
|
print(f"\nWrote {args.properties} ({size_mb:.1f} MB)")
|
||||||
n_priced = result.filter(pl.col("Estimated current price").is_not_null()).height
|
print(
|
||||||
print(f" {len(result):,} rows, {n_priced:,} with an estimated current price")
|
f" {len(df):,} rows, {len(df.columns)} columns (including 'Estimated current price')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
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