has issues
This commit is contained in:
parent
2e112d7398
commit
c645b0f1d4
96 changed files with 2147083 additions and 5787 deletions
|
|
@ -30,17 +30,26 @@ EPC := $(MANUAL_DATA)/domestic-csv.zip
|
||||||
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
|
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
|
||||||
CRIME_DIR := $(DATA_DIR)/crime
|
CRIME_DIR := $(DATA_DIR)/crime
|
||||||
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
|
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
|
||||||
|
CRIME_BY_YEAR := $(DATA_DIR)/crime_by_year_by_lsoa.parquet
|
||||||
CRIME_STAMP := $(CRIME_DIR)/.downloaded
|
CRIME_STAMP := $(CRIME_DIR)/.downloaded
|
||||||
|
LSOA_LOOKUP := $(DATA_DIR)/lsoa_2011_to_2021.parquet
|
||||||
NOISE := $(DATA_DIR)/road_noise.parquet
|
NOISE := $(DATA_DIR)/road_noise.parquet
|
||||||
|
NOISE_OVERLAY_TILES := $(DATA_DIR)/noise_lden_10m.pmtiles
|
||||||
|
CRIME_HOTSPOT_TILES := $(DATA_DIR)/crime_hotspots.pmtiles
|
||||||
|
TREE_OVERLAY_TILES := $(DATA_DIR)/trees_outside_woodlands.pmtiles
|
||||||
OFSTED := $(DATA_DIR)/ofsted.parquet
|
OFSTED := $(DATA_DIR)/ofsted.parquet
|
||||||
|
GIAS := $(DATA_DIR)/gias.parquet
|
||||||
NAPTAN := $(DATA_DIR)/naptan.parquet
|
NAPTAN := $(DATA_DIR)/naptan.parquet
|
||||||
BROADBAND := $(DATA_DIR)/broadband.parquet
|
BROADBAND := $(DATA_DIR)/broadband.parquet
|
||||||
|
CONSERVATION_AREAS := $(DATA_DIR)/conservation_areas.gpkg
|
||||||
|
LISTED_BUILDINGS := $(DATA_DIR)/listed_buildings.gpkg
|
||||||
SCHOOL_PROX := $(DATA_DIR)/school_proximity.parquet
|
SCHOOL_PROX := $(DATA_DIR)/school_proximity.parquet
|
||||||
RENTAL := $(DATA_DIR)/rental_prices.parquet
|
RENTAL := $(DATA_DIR)/rental_prices.parquet
|
||||||
INSPIRE_DIR := $(DATA_DIR)/inspire
|
INSPIRE_DIR := $(DATA_DIR)/inspire
|
||||||
OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg
|
OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg
|
||||||
UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet
|
UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet
|
||||||
PC_BOUNDARIES := $(DATA_DIR)/postcode_boundaries
|
PC_BOUNDARIES := $(DATA_DIR)/postcode_boundaries
|
||||||
|
PC_BOUNDARIES_STAMP := $(PC_BOUNDARIES)/.done
|
||||||
TRANSIT_DIR := $(DATA_DIR)/transit
|
TRANSIT_DIR := $(DATA_DIR)/transit
|
||||||
TRANSIT_STAMP := $(TRANSIT_DIR)/.done
|
TRANSIT_STAMP := $(TRANSIT_DIR)/.done
|
||||||
R5_NETWORK_CACHE := $(DATA_DIR)/r5-network/network.dat
|
R5_NETWORK_CACHE := $(DATA_DIR)/r5-network/network.dat
|
||||||
|
|
@ -73,6 +82,15 @@ MERGE_DEPS := pipeline/transform/merge.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
|
||||||
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 \
|
||||||
|
pipeline/transform/postcode_boundaries/greenspace.py \
|
||||||
|
pipeline/transform/postcode_boundaries/inspire.py \
|
||||||
|
pipeline/transform/postcode_boundaries/memory.py \
|
||||||
|
pipeline/transform/postcode_boundaries/oa_boundaries.py \
|
||||||
|
pipeline/transform/postcode_boundaries/output.py \
|
||||||
|
pipeline/transform/postcode_boundaries/process_oa.py \
|
||||||
|
pipeline/transform/postcode_boundaries/uprn.py \
|
||||||
|
pipeline/transform/postcode_boundaries/voronoi.py
|
||||||
CRIME_DOWNLOAD_DEPS := pipeline/download/crime.py
|
CRIME_DOWNLOAD_DEPS := pipeline/download/crime.py
|
||||||
INSPIRE_DOWNLOAD_DEPS := pipeline/download/inspire.py
|
INSPIRE_DOWNLOAD_DEPS := pipeline/download/inspire.py
|
||||||
TRANSIT_DOWNLOAD_DEPS := pipeline/download/transit_network.py pipeline/download/transxchange2gtfs_shim.js
|
TRANSIT_DOWNLOAD_DEPS := pipeline/download/transit_network.py pipeline/download/transxchange2gtfs_shim.js
|
||||||
|
|
@ -80,9 +98,9 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_
|
||||||
|
|
||||||
# ── Phony aliases ─────────────────────────────────────────────────────────────
|
# ── Phony aliases ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.PHONY: prepare merge tiles \
|
.PHONY: prepare merge tiles overlay-tiles noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles \
|
||||||
download-arcgis download-price-paid download-deprivation download-ethnicity \
|
download-arcgis download-price-paid download-deprivation download-ethnicity \
|
||||||
download-naptan download-pois download-grocery-retail-points download-ofsted download-broadband download-rental-prices \
|
download-naptan download-pois download-grocery-retail-points download-ofsted download-gias download-broadband download-conservation-areas download-listed-buildings download-rental-prices \
|
||||||
download-postcodes download-noise download-inspire download-crime \
|
download-postcodes download-noise download-inspire download-crime \
|
||||||
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-ofs-register download-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \
|
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-os-greenspace download-pbf download-fr-tow download-ofs-register download-places download-lsoa-population download-median-age download-england-boundary download-rightmove-outcodes \
|
||||||
download-map-assets \
|
download-map-assets \
|
||||||
|
|
@ -90,11 +108,15 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_
|
||||||
transform-school-proximity transform-tree-density \
|
transform-school-proximity transform-tree-density \
|
||||||
generate-postcode-boundaries generate-travel-times
|
generate-postcode-boundaries generate-travel-times
|
||||||
|
|
||||||
prepare: $(PRICES_STAMP) download-places tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX)
|
prepare: $(PRICES_STAMP) download-places tiles overlay-tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX)
|
||||||
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX)
|
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX)
|
||||||
merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ)
|
merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ)
|
||||||
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ)
|
$(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ)
|
||||||
tiles: $(TILES)
|
tiles: $(TILES)
|
||||||
|
overlay-tiles: noise-overlay-tiles crime-hotspot-tiles tree-overlay-tiles
|
||||||
|
noise-overlay-tiles: $(NOISE_OVERLAY_TILES)
|
||||||
|
crime-hotspot-tiles: $(CRIME_HOTSPOT_TILES)
|
||||||
|
tree-overlay-tiles: $(TREE_OVERLAY_TILES)
|
||||||
download-arcgis: $(ARCGIS)
|
download-arcgis: $(ARCGIS)
|
||||||
download-price-paid: $(PRICE_PAID)
|
download-price-paid: $(PRICE_PAID)
|
||||||
download-deprivation: $(IOD)
|
download-deprivation: $(IOD)
|
||||||
|
|
@ -105,7 +127,10 @@ download-naptan: $(NAPTAN)
|
||||||
download-pois: $(POIS_RAW)
|
download-pois: $(POIS_RAW)
|
||||||
download-grocery-retail-points: $(GROCERY_RETAIL_POINTS)
|
download-grocery-retail-points: $(GROCERY_RETAIL_POINTS)
|
||||||
download-ofsted: $(OFSTED)
|
download-ofsted: $(OFSTED)
|
||||||
|
download-gias: $(GIAS)
|
||||||
download-broadband: $(BROADBAND)
|
download-broadband: $(BROADBAND)
|
||||||
|
download-conservation-areas: $(CONSERVATION_AREAS)
|
||||||
|
download-listed-buildings: $(LISTED_BUILDINGS)
|
||||||
download-postcodes: $(POSTCODES_RAW)
|
download-postcodes: $(POSTCODES_RAW)
|
||||||
download-rental-prices: $(RENTAL)
|
download-rental-prices: $(RENTAL)
|
||||||
download-noise: $(NOISE)
|
download-noise: $(NOISE)
|
||||||
|
|
@ -134,13 +159,17 @@ transform-crime: $(CRIME)
|
||||||
transform-poi-proximity: $(POI_PROXIMITY)
|
transform-poi-proximity: $(POI_PROXIMITY)
|
||||||
transform-school-proximity: $(SCHOOL_PROX)
|
transform-school-proximity: $(SCHOOL_PROX)
|
||||||
transform-tree-density: $(TREE_DENSITY_PC)
|
transform-tree-density: $(TREE_DENSITY_PC)
|
||||||
generate-postcode-boundaries: $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP)
|
generate-postcode-boundaries: $(PC_BOUNDARIES_STAMP)
|
||||||
|
|
||||||
|
$(PC_BOUNDARIES_STAMP): $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) $(PC_BOUNDARIES_DEPS)
|
||||||
|
@rm -f $@
|
||||||
$(VALIDATE_OUTPUTS) --dir $(INSPIRE_DIR) --zip-glob "$(INSPIRE_DIR)::*.zip"
|
$(VALIDATE_OUTPUTS) --dir $(INSPIRE_DIR) --zip-glob "$(INSPIRE_DIR)::*.zip"
|
||||||
uv run python -m pipeline.transform.postcode_boundaries \
|
uv run python -m pipeline.transform.postcode_boundaries \
|
||||||
--uprn $(UPRN_LOOKUP) \
|
--uprn $(UPRN_LOOKUP) \
|
||||||
--oa-boundaries $(OA_BOUNDARIES) \
|
--oa-boundaries $(OA_BOUNDARIES) \
|
||||||
--inspire $(INSPIRE_DIR) \
|
--inspire $(INSPIRE_DIR) \
|
||||||
--output $(PC_BOUNDARIES)
|
--output $(PC_BOUNDARIES)
|
||||||
|
@touch $@
|
||||||
generate-travel-times: $(ARCGIS) $(PLACES) $(PBF) download-transit-network
|
generate-travel-times: $(ARCGIS) $(PLACES) $(PBF) download-transit-network
|
||||||
@if [ -f "$(R5_NETWORK_CACHE)" ] && { [ "$(PBF)" -nt "$(R5_NETWORK_CACHE)" ] || [ "$(TRANSIT_STAMP)" -nt "$(R5_NETWORK_CACHE)" ]; }; then \
|
@if [ -f "$(R5_NETWORK_CACHE)" ] && { [ "$(PBF)" -nt "$(R5_NETWORK_CACHE)" ] || [ "$(TRANSIT_STAMP)" -nt "$(R5_NETWORK_CACHE)" ]; }; then \
|
||||||
echo "R5 inputs are newer than $(R5_NETWORK_CACHE); deleting stale cache"; \
|
echo "R5 inputs are newer than $(R5_NETWORK_CACHE); deleting stale cache"; \
|
||||||
|
|
@ -209,15 +238,33 @@ $(GROCERY_RETAIL_POINTS):
|
||||||
$(OFSTED):
|
$(OFSTED):
|
||||||
uv run python -m pipeline.download.ofsted --output $@
|
uv run python -m pipeline.download.ofsted --output $@
|
||||||
|
|
||||||
|
$(GIAS): pipeline/download/gias.py
|
||||||
|
uv run python -m pipeline.download.gias --output $@
|
||||||
|
|
||||||
$(BROADBAND):
|
$(BROADBAND):
|
||||||
uv run python -m pipeline.download.broadband --output $@
|
uv run python -m pipeline.download.broadband --output $@
|
||||||
|
|
||||||
|
$(CONSERVATION_AREAS): pipeline/download/conservation_areas.py
|
||||||
|
uv run python -m pipeline.download.conservation_areas --output $@
|
||||||
|
|
||||||
|
$(LISTED_BUILDINGS): pipeline/download/listed_buildings.py
|
||||||
|
uv run python -m pipeline.download.listed_buildings --output $@
|
||||||
|
|
||||||
$(POSTCODES_RAW):
|
$(POSTCODES_RAW):
|
||||||
uv run python -m pipeline.download.postcodes --output $@
|
uv run python -m pipeline.download.postcodes --output $@
|
||||||
|
|
||||||
$(NOISE): $(ARCGIS) pipeline/download/noise.py
|
$(NOISE): $(ARCGIS) pipeline/download/noise.py
|
||||||
uv run python -m pipeline.download.noise --arcgis $(ARCGIS) --output $@
|
uv run python -m pipeline.download.noise --arcgis $(ARCGIS) --output $@
|
||||||
|
|
||||||
|
$(NOISE_OVERLAY_TILES): pipeline/transform/noise_overlay_tiles.py pipeline/download/noise.py pipeline/download/tiles.py
|
||||||
|
uv run python -m pipeline.transform.noise_overlay_tiles --output $@ --raster-dir $(DATA_DIR)/noise_overlay_rasters --pmtiles-bin $(DATA_DIR)/pmtiles --pmtiles-version $(PMTILES_VERSION)
|
||||||
|
|
||||||
|
$(CRIME_HOTSPOT_TILES): $(CRIME_STAMP) pipeline/transform/crime_hotspot_tiles.py pipeline/transform/crime.py
|
||||||
|
uv run python -m pipeline.transform.crime_hotspot_tiles --input $(CRIME_DIR) --output $@
|
||||||
|
|
||||||
|
$(TREE_OVERLAY_TILES): $(FR_TOW) pipeline/transform/tree_overlay_tiles.py pipeline/transform/tree_density.py
|
||||||
|
uv run python -m pipeline.transform.tree_overlay_tiles --tow-zip $(FR_TOW) --output $@
|
||||||
|
|
||||||
$(INSPIRE_STAMP): $(INSPIRE_DOWNLOAD_DEPS)
|
$(INSPIRE_STAMP): $(INSPIRE_DOWNLOAD_DEPS)
|
||||||
@rm -f $@
|
@rm -f $@
|
||||||
uv run python -m pipeline.download.inspire --output $(INSPIRE_DIR)
|
uv run python -m pipeline.download.inspire --output $(INSPIRE_DIR)
|
||||||
|
|
@ -272,20 +319,24 @@ $(MAP_ASSETS_STAMP): $(MAP_ASSETS_DEPS)
|
||||||
|
|
||||||
# ── Transforms ────────────────────────────────────────────────────────────────
|
# ── Transforms ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(ENGLAND_BOUNDARY)
|
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(ENGLAND_BOUNDARY) pipeline/transform/transform_poi.py pipeline/utils/england_geometry.py
|
||||||
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --output $@
|
uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --gias $(GIAS) --output $@
|
||||||
|
|
||||||
$(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py
|
$(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py
|
||||||
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
|
||||||
|
|
||||||
$(CRIME): $(CRIME_STAMP)
|
$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(LSOA_LOOKUP) 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 --input $(CRIME_DIR) --output $@
|
uv run python -m pipeline.transform.crime --input $(CRIME_DIR) --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) --lsoa-lookup $(LSOA_LOOKUP)
|
||||||
|
|
||||||
|
$(LSOA_LOOKUP): pipeline/download/lsoa_2011_to_2021.py
|
||||||
|
uv run python -m pipeline.download.lsoa_2011_to_2021 --output $@
|
||||||
|
$(VALIDATE_OUTPUTS) --parquet $@
|
||||||
|
|
||||||
$(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 $@
|
||||||
|
|
||||||
$(SCHOOL_PROX): $(OFSTED) $(ARCGIS)
|
$(SCHOOL_PROX): $(OFSTED) $(ARCGIS) pipeline/transform/school_proximity.py pipeline/utils/poi_counts.py
|
||||||
uv run python -m pipeline.transform.school_proximity --ofsted $(OFSTED) --arcgis $(ARCGIS) --output $@
|
uv run python -m pipeline.transform.school_proximity --ofsted $(OFSTED) --arcgis $(ARCGIS) --output $@
|
||||||
|
|
||||||
$(TREE_DENSITY_PC): $(FR_TOW) $(ARCGIS) $(PRICE_PAID) $(TREE_DENSITY_DEPS)
|
$(TREE_DENSITY_PC): $(FR_TOW) $(ARCGIS) $(PRICE_PAID) $(TREE_DENSITY_DEPS)
|
||||||
|
|
@ -315,7 +366,7 @@ $(PC_BOUNDARIES):
|
||||||
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
|
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
|
||||||
|
|
||||||
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
||||||
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(RENTAL) $(LSOA_POP) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS)
|
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(CONSERVATION_AREAS) $(LISTED_BUILDINGS) $(RENTAL) $(LSOA_POP) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS)
|
||||||
@rm -f $@
|
@rm -f $@
|
||||||
uv run python -m pipeline.transform.merge \
|
uv run python -m pipeline.transform.merge \
|
||||||
--epc-pp $(EPC_PP) \
|
--epc-pp $(EPC_PP) \
|
||||||
|
|
@ -327,6 +378,8 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
|
||||||
--noise $(NOISE) \
|
--noise $(NOISE) \
|
||||||
--school-proximity $(SCHOOL_PROX) \
|
--school-proximity $(SCHOOL_PROX) \
|
||||||
--broadband $(BROADBAND) \
|
--broadband $(BROADBAND) \
|
||||||
|
--conservation-areas $(CONSERVATION_AREAS) \
|
||||||
|
--listed-buildings $(LISTED_BUILDINGS) \
|
||||||
--rental-prices $(RENTAL) \
|
--rental-prices $(RENTAL) \
|
||||||
--lsoa-population $(LSOA_POP) \
|
--lsoa-population $(LSOA_POP) \
|
||||||
--median-age $(MEDIAN_AGE) \
|
--median-age $(MEDIAN_AGE) \
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@ services:
|
||||||
- .:/app
|
- .:/app
|
||||||
- cargo-home:/usr/local/cargo
|
- cargo-home:/usr/local/cargo
|
||||||
- cargo-target:/app/server-rs/target
|
- cargo-target:/app/server-rs/target
|
||||||
- ./property-data2:/app/data:ro
|
- ./property-data:/app/data:ro
|
||||||
- ./finder/data:/app/finder-data:ro
|
- ./finder/data:/app/finder-data:ro
|
||||||
environment:
|
environment:
|
||||||
POCKETBASE_URL: http://pocketbase:8090
|
POCKETBASE_URL: http://pocketbase:8090
|
||||||
|
|
@ -51,6 +51,7 @@ services:
|
||||||
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
|
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
|
||||||
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
|
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
|
||||||
ACTUAL_LISTINGS_PATH: /app/finder-data/online_listings_buy.parquet
|
ACTUAL_LISTINGS_PATH: /app/finder-data/online_listings_buy.parquet
|
||||||
|
CRIME_BY_YEAR_PATH: /app/data/crime_by_year_by_lsoa.parquet
|
||||||
depends_on:
|
depends_on:
|
||||||
screenshot:
|
screenshot:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
|
||||||
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
|
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
|
||||||
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
|
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
|
||||||
|
|
||||||
|
# OnTheMarket
|
||||||
|
ONTHEMARKET_BASE = "https://www.onthemarket.com"
|
||||||
|
|
||||||
# Zoopla
|
# Zoopla
|
||||||
ZOOPLA_BASE = "https://www.zoopla.co.uk"
|
ZOOPLA_BASE = "https://www.zoopla.co.uk"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from pathlib import Path
|
||||||
from constants import DATA_DIR, REPO_DIR
|
from constants import DATA_DIR, REPO_DIR
|
||||||
|
|
||||||
|
|
||||||
SOURCE_CHOICES = ("rightmove", "zoopla", "all")
|
SOURCE_CHOICES = ("rightmove", "onthemarket", "zoopla", "all")
|
||||||
TEST_MAX_PROPERTIES_PER_SOURCE = 100
|
TEST_MAX_PROPERTIES_PER_SOURCE = 100
|
||||||
TEST_OUTCODES = (
|
TEST_OUTCODES = (
|
||||||
"E1",
|
"E1",
|
||||||
|
|
@ -49,7 +49,7 @@ def parse_args() -> argparse.Namespace:
|
||||||
"--source",
|
"--source",
|
||||||
choices=SOURCE_CHOICES,
|
choices=SOURCE_CHOICES,
|
||||||
default="all",
|
default="all",
|
||||||
help="Portal to scrape. 'all' runs Rightmove and Zoopla.",
|
help="Portal to scrape. 'all' runs Rightmove, OnTheMarket, and Zoopla.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output-dir",
|
"--output-dir",
|
||||||
|
|
@ -91,7 +91,7 @@ def configure_logging() -> None:
|
||||||
|
|
||||||
def selected_sources(source: str) -> list[str]:
|
def selected_sources(source: str) -> list[str]:
|
||||||
if source == "all":
|
if source == "all":
|
||||||
return ["rightmove", "zoopla"]
|
return ["rightmove", "onthemarket", "zoopla"]
|
||||||
return [source]
|
return [source]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
282
finder/onthemarket.py
Normal file
282
finder/onthemarket.py
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
"""OnTheMarket (onthemarket.com) scraper — sale properties.
|
||||||
|
|
||||||
|
OnTheMarket serves a Next.js app with the full search-results payload embedded
|
||||||
|
as JSON in a `__NEXT_DATA__` script tag. No JS execution or browser needed:
|
||||||
|
plain HTTP with a Chrome-ish User-Agent is enough.
|
||||||
|
|
||||||
|
Each rendered page contains 30 listings under
|
||||||
|
`props.initialReduxState.results.list`, each with `location.{lat,lon}`,
|
||||||
|
`bedrooms`, `bathrooms`, `price` (formatted £-string), `address`,
|
||||||
|
`humanised-property-type`, `features` (a list where the first element is
|
||||||
|
typically `"Tenure: <value>"`), and `details-url`. Pagination is via
|
||||||
|
`?page=N`; the loop terminates when `paginationControls.next` is null.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from constants import (
|
||||||
|
DELAY_BETWEEN_PAGES,
|
||||||
|
MAX_BEDROOMS,
|
||||||
|
MAX_RETRIES,
|
||||||
|
ONTHEMARKET_BASE,
|
||||||
|
RETRY_BASE_DELAY,
|
||||||
|
)
|
||||||
|
from spatial import PostcodeSpatialIndex
|
||||||
|
from transform import (
|
||||||
|
fix_coords,
|
||||||
|
map_property_type,
|
||||||
|
normalize_sub_type,
|
||||||
|
parse_display_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger("rightmove")
|
||||||
|
|
||||||
|
_NEXT_DATA_RE = re.compile(
|
||||||
|
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
_PRICE_RE = re.compile(r"([\d,]+)")
|
||||||
|
_TENURE_RE = re.compile(r"tenure:\s*(.+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
_HTML_HEADERS = {
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "en-GB,en;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_page_json(client: httpx.Client, outcode: str, page_num: int) -> dict | None:
|
||||||
|
"""GET one search-results page and return the embedded __NEXT_DATA__ JSON.
|
||||||
|
|
||||||
|
Returns None on permanent failure, missing script, or a 3xx redirect
|
||||||
|
(OnTheMarket redirects out-of-range pages, so a redirect = end of results).
|
||||||
|
"""
|
||||||
|
url = f"{ONTHEMARKET_BASE}/for-sale/property/{outcode.lower()}/"
|
||||||
|
params = {"page": str(page_num)} if page_num > 1 else None
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
resp = client.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
headers=_HTML_HEADERS,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadTimeout,
|
||||||
|
httpx.WriteTimeout,
|
||||||
|
httpx.PoolTimeout,
|
||||||
|
) as exc:
|
||||||
|
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
|
||||||
|
log.warning(
|
||||||
|
"%s from %s, retry %d/%d in %.1fs",
|
||||||
|
type(exc).__name__, url, attempt + 1, MAX_RETRIES, delay,
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if 300 <= resp.status_code < 400:
|
||||||
|
log.debug(
|
||||||
|
"OnTheMarket %s page %d redirected (%d) — end of results",
|
||||||
|
outcode, page_num, resp.status_code,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if resp.status_code == 200:
|
||||||
|
match = _NEXT_DATA_RE.search(resp.text)
|
||||||
|
if not match:
|
||||||
|
log.warning(
|
||||||
|
"No __NEXT_DATA__ in OnTheMarket %s page %d", outcode, page_num
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
log.warning(
|
||||||
|
"Failed to parse __NEXT_DATA__ for %s page %d: %s",
|
||||||
|
outcode, page_num, exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if resp.status_code in (429, 500, 502, 503, 504):
|
||||||
|
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
|
||||||
|
log.warning(
|
||||||
|
"HTTP %d from %s, retry %d/%d in %.1fs",
|
||||||
|
resp.status_code, url, attempt + 1, MAX_RETRIES, delay,
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
continue
|
||||||
|
log.error("HTTP %d from %s (non-retryable)", resp.status_code, url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
log.error("All %d retries exhausted for %s page %d", MAX_RETRIES, outcode, page_num)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_price(price_value) -> int:
|
||||||
|
"""Parse a formatted price string like '£450,000' into an integer.
|
||||||
|
Returns 0 for POA/auction/null values."""
|
||||||
|
if price_value is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(price_value, (int, float)):
|
||||||
|
return int(price_value)
|
||||||
|
match = _PRICE_RE.search(str(price_value))
|
||||||
|
if not match:
|
||||||
|
return 0
|
||||||
|
return int(match.group(1).replace(",", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tenure(features: list) -> str | None:
|
||||||
|
"""Pull canonical Freehold/Leasehold out of the features list.
|
||||||
|
OnTheMarket encodes tenure as 'Tenure: Leasehold (NN years remaining)' etc.
|
||||||
|
'Share of freehold' is normalised to Freehold."""
|
||||||
|
if not features:
|
||||||
|
return None
|
||||||
|
for feature in features:
|
||||||
|
if not isinstance(feature, str):
|
||||||
|
continue
|
||||||
|
match = _TENURE_RE.search(feature)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
value = match.group(1).strip().lower()
|
||||||
|
if "freehold" in value:
|
||||||
|
return "Freehold"
|
||||||
|
if "leasehold" in value:
|
||||||
|
return "Leasehold"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_floor_area(features: list) -> float | None:
|
||||||
|
"""Search features for a sq ft / sq m mention and return sqm."""
|
||||||
|
if not features:
|
||||||
|
return None
|
||||||
|
for feature in features:
|
||||||
|
if not isinstance(feature, str):
|
||||||
|
continue
|
||||||
|
sqm = parse_display_size(feature)
|
||||||
|
if sqm is not None:
|
||||||
|
return sqm
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def transform_property(
|
||||||
|
raw: dict, pc_index: PostcodeSpatialIndex
|
||||||
|
) -> dict | None:
|
||||||
|
"""Transform a raw OnTheMarket listing dict into our output schema."""
|
||||||
|
loc = raw.get("location") or {}
|
||||||
|
raw_lat = loc.get("lat")
|
||||||
|
raw_lng = loc.get("lon")
|
||||||
|
if raw_lat is None or raw_lng is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lat, lng = fix_coords(raw_lat, raw_lng)
|
||||||
|
if not (49 <= lat <= 56 and -7 <= lng <= 2):
|
||||||
|
return None
|
||||||
|
|
||||||
|
postcode = pc_index.nearest(lat, lng)
|
||||||
|
if not postcode:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_beds = raw.get("bedrooms") or 0
|
||||||
|
raw_baths = raw.get("bathrooms") or 0
|
||||||
|
bedrooms = raw_beds if 0 <= raw_beds <= MAX_BEDROOMS else 0
|
||||||
|
bathrooms = raw_baths if 0 <= raw_baths <= MAX_BEDROOMS else 0
|
||||||
|
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
|
||||||
|
log.warning(
|
||||||
|
"OnTheMarket %s: implausible beds=%d baths=%d (capped to 0)",
|
||||||
|
raw.get("id", "?"), raw_beds, raw_baths,
|
||||||
|
)
|
||||||
|
|
||||||
|
sub_type = raw.get("humanised-property-type") or ""
|
||||||
|
features = raw.get("features") or []
|
||||||
|
listing_id = str(raw.get("id") or "")
|
||||||
|
if not listing_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
details_url = raw.get("details-url") or ""
|
||||||
|
full_url = (
|
||||||
|
ONTHEMARKET_BASE + details_url
|
||||||
|
if details_url and not details_url.startswith("http")
|
||||||
|
else details_url
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": f"otm_{listing_id}",
|
||||||
|
"Bedrooms": bedrooms,
|
||||||
|
"Bathrooms": bathrooms,
|
||||||
|
"Number of bedrooms & living rooms": bedrooms + bathrooms,
|
||||||
|
"lon": lng,
|
||||||
|
"lat": lat,
|
||||||
|
"Postcode": postcode,
|
||||||
|
"Address per Property Register": raw.get("address", ""),
|
||||||
|
"Leasehold/Freehold": _extract_tenure(features),
|
||||||
|
"Property type": map_property_type(sub_type),
|
||||||
|
"Property sub-type": normalize_sub_type(sub_type),
|
||||||
|
"price": _parse_price(raw.get("price")),
|
||||||
|
"price_frequency": "",
|
||||||
|
"Price qualifier": raw.get("price-qualifier") or "",
|
||||||
|
"Total floor area (sqm)": _extract_floor_area(features),
|
||||||
|
"Listing URL": full_url,
|
||||||
|
"Listing features": [f for f in features if isinstance(f, str)],
|
||||||
|
"first_visible_date": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_outcode(
|
||||||
|
client: httpx.Client,
|
||||||
|
outcode: str,
|
||||||
|
pc_index: PostcodeSpatialIndex,
|
||||||
|
max_properties: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Paginate through OnTheMarket sale results for one outcode."""
|
||||||
|
properties: list[dict] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
page_num = 1
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = _fetch_page_json(client, outcode, page_num)
|
||||||
|
if data is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = data["props"]["initialReduxState"]["results"]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
log.warning(
|
||||||
|
"Unexpected __NEXT_DATA__ shape for %s page %d", outcode, page_num
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
raw_listings = state.get("list") or []
|
||||||
|
if not raw_listings:
|
||||||
|
break
|
||||||
|
|
||||||
|
for raw in raw_listings:
|
||||||
|
listing_id = str(raw.get("id") or "")
|
||||||
|
if listing_id and listing_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(listing_id)
|
||||||
|
try:
|
||||||
|
transformed = transform_property(raw, pc_index)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(
|
||||||
|
"OnTheMarket %s property %s failed to transform: %s",
|
||||||
|
outcode, listing_id or "?", exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if transformed:
|
||||||
|
properties.append(transformed)
|
||||||
|
if max_properties is not None and len(properties) >= max_properties:
|
||||||
|
return properties
|
||||||
|
|
||||||
|
pagination = state.get("paginationControls") or {}
|
||||||
|
if not pagination.get("next"):
|
||||||
|
break
|
||||||
|
|
||||||
|
page_num += 1
|
||||||
|
time.sleep(DELAY_BETWEEN_PAGES)
|
||||||
|
|
||||||
|
return properties
|
||||||
|
|
@ -6,6 +6,6 @@ dependencies = [
|
||||||
"httpx",
|
"httpx",
|
||||||
"polars",
|
"polars",
|
||||||
"fake-useragent>=2.2.0",
|
"fake-useragent>=2.2.0",
|
||||||
"playwright>=1.58.0",
|
"playwright>=1.58.0,<1.60.0",
|
||||||
"camoufox>=0.4.11",
|
"camoufox>=0.4.11",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import time
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
@ -15,6 +18,7 @@ from constants import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from http_client import make_client
|
from http_client import make_client
|
||||||
|
from onthemarket import search_outcode as onthemarket_search_outcode
|
||||||
from rightmove import resolve_outcode_id
|
from rightmove import resolve_outcode_id
|
||||||
from rightmove import search_outcode as rightmove_search_outcode
|
from rightmove import search_outcode as rightmove_search_outcode
|
||||||
from spatial import PostcodeSpatialIndex
|
from spatial import PostcodeSpatialIndex
|
||||||
|
|
@ -25,7 +29,7 @@ from zoopla import search_outcode as zoopla_search_outcode
|
||||||
|
|
||||||
log = logging.getLogger("rightmove")
|
log = logging.getLogger("rightmove")
|
||||||
|
|
||||||
SOURCE_ORDER = ("rightmove", "zoopla")
|
SOURCE_ORDER = ("rightmove", "onthemarket", "zoopla")
|
||||||
SALE_CHANNEL = CHANNELS[0]
|
SALE_CHANNEL = CHANNELS[0]
|
||||||
LONDON_AREAS = sorted({prefix.upper() for prefix in LONDON_OUTCODE_PREFIXES})
|
LONDON_AREAS = sorted({prefix.upper() for prefix in LONDON_OUTCODE_PREFIXES})
|
||||||
OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d[A-Z0-9]?)")
|
OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d[A-Z0-9]?)")
|
||||||
|
|
@ -121,29 +125,6 @@ def load_outcodes() -> list[str]:
|
||||||
return londonish
|
return londonish
|
||||||
|
|
||||||
|
|
||||||
def build_postcode_index() -> PostcodeSpatialIndex:
|
|
||||||
"""Build spatial index from ARCGIS England postcodes."""
|
|
||||||
log.info("Building postcode spatial index from %s", ARCGIS_PATH)
|
|
||||||
postcode_col, country_col = _arcgis_columns()
|
|
||||||
df = pl.read_parquet(
|
|
||||||
ARCGIS_PATH, columns=[postcode_col, country_col, "lat", "long"]
|
|
||||||
)
|
|
||||||
england = df.filter(
|
|
||||||
(pl.col(country_col) == "E92000001")
|
|
||||||
& _londonish_postcode_expr(postcode_col)
|
|
||||||
).drop_nulls(
|
|
||||||
subset=["lat", "long"]
|
|
||||||
)
|
|
||||||
return PostcodeSpatialIndex(
|
|
||||||
england.get_column("lat").to_list(),
|
|
||||||
england.get_column("long").to_list(),
|
|
||||||
[
|
|
||||||
_normalize_postcode(pcd)
|
|
||||||
for pcd in england.get_column(postcode_col).to_list()
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_postcode_coords() -> dict[str, tuple[float, float]]:
|
def build_postcode_coords() -> dict[str, tuple[float, float]]:
|
||||||
"""Build postcode -> (lat, lng) lookup from ARCGIS England postcodes."""
|
"""Build postcode -> (lat, lng) lookup from ARCGIS England postcodes."""
|
||||||
log.info("Building postcode coords lookup from %s", ARCGIS_PATH)
|
log.info("Building postcode coords lookup from %s", ARCGIS_PATH)
|
||||||
|
|
@ -168,6 +149,29 @@ def build_postcode_coords() -> dict[str, tuple[float, float]]:
|
||||||
return coords
|
return coords
|
||||||
|
|
||||||
|
|
||||||
|
def build_postcode_index() -> PostcodeSpatialIndex:
|
||||||
|
"""Build spatial index from ARCGIS England postcodes."""
|
||||||
|
log.info("Building postcode spatial index from %s", ARCGIS_PATH)
|
||||||
|
postcode_col, country_col = _arcgis_columns()
|
||||||
|
df = pl.read_parquet(
|
||||||
|
ARCGIS_PATH, columns=[postcode_col, country_col, "lat", "long"]
|
||||||
|
)
|
||||||
|
england = df.filter(
|
||||||
|
(pl.col(country_col) == "E92000001")
|
||||||
|
& _londonish_postcode_expr(postcode_col)
|
||||||
|
).drop_nulls(
|
||||||
|
subset=["lat", "long"]
|
||||||
|
)
|
||||||
|
return PostcodeSpatialIndex(
|
||||||
|
england.get_column("lat").to_list(),
|
||||||
|
england.get_column("long").to_list(),
|
||||||
|
[
|
||||||
|
_normalize_postcode(pcd)
|
||||||
|
for pcd in england.get_column(postcode_col).to_list()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _source_names(sources: str | Iterable[str] | None) -> list[str]:
|
def _source_names(sources: str | Iterable[str] | None) -> list[str]:
|
||||||
if sources is None:
|
if sources is None:
|
||||||
return list(SOURCE_ORDER)
|
return list(SOURCE_ORDER)
|
||||||
|
|
@ -185,34 +189,44 @@ def _source_names(sources: str | Iterable[str] | None) -> list[str]:
|
||||||
return [source for source in SOURCE_ORDER if source in requested]
|
return [source for source in SOURCE_ORDER if source in requested]
|
||||||
|
|
||||||
|
|
||||||
def _dedup_key(prop: dict) -> tuple:
|
def _dedup_key(prop: dict) -> tuple | None:
|
||||||
return (prop.get("Postcode", ""), prop.get("Bedrooms", 0), prop.get("price", 0))
|
postcode = str(prop.get("Postcode") or "").strip().upper()
|
||||||
|
price = int(prop.get("price") or 0)
|
||||||
|
if not postcode or price <= 0:
|
||||||
|
return None
|
||||||
|
return (postcode, int(prop.get("Bedrooms") or 0), price)
|
||||||
|
|
||||||
|
|
||||||
def _merge_properties(source_results: dict[str, list[dict]]) -> tuple[list[dict], dict, int]:
|
def _merge_properties(source_results: dict[str, list[dict]]) -> tuple[list[dict], dict, int]:
|
||||||
merged: dict[str, dict] = {}
|
merged: dict[str, dict] = {}
|
||||||
seen_keys: set[tuple] = set()
|
seen_keys: dict[tuple, str] = {}
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
counts = {source: 0 for source in SOURCE_ORDER}
|
counts = {source: 0 for source in SOURCE_ORDER}
|
||||||
deduped = 0
|
deduped = 0
|
||||||
|
|
||||||
for source in SOURCE_ORDER:
|
for source in SOURCE_ORDER:
|
||||||
for prop in source_results.get(source, []):
|
for prop in source_results.get(source, []):
|
||||||
prop_id = prop.get("id")
|
key = _dedup_key(prop)
|
||||||
if prop_id is not None:
|
prop_id_raw = prop.get("id")
|
||||||
prop_id = str(prop_id)
|
prop_id = str(prop_id_raw).strip() if prop_id_raw is not None else None
|
||||||
|
if prop_id:
|
||||||
if prop_id in seen_ids:
|
if prop_id in seen_ids:
|
||||||
deduped += 1
|
deduped += 1
|
||||||
continue
|
continue
|
||||||
|
if key is not None:
|
||||||
|
previous_source = seen_keys.get(key)
|
||||||
|
if previous_source is not None and previous_source != source:
|
||||||
|
deduped += 1
|
||||||
|
continue
|
||||||
seen_ids.add(prop_id)
|
seen_ids.add(prop_id)
|
||||||
storage_key = prop_id
|
storage_key = prop_id
|
||||||
else:
|
else:
|
||||||
key = _dedup_key(prop)
|
if key is not None and key in seen_keys:
|
||||||
if key in seen_keys:
|
|
||||||
deduped += 1
|
deduped += 1
|
||||||
continue
|
continue
|
||||||
seen_keys.add(key)
|
|
||||||
storage_key = f"{source}:{len(merged)}"
|
storage_key = f"{source}:{len(merged)}"
|
||||||
|
if key is not None:
|
||||||
|
seen_keys.setdefault(key, source)
|
||||||
merged[storage_key] = prop
|
merged[storage_key] = prop
|
||||||
counts[source] += 1
|
counts[source] += 1
|
||||||
|
|
||||||
|
|
@ -260,38 +274,24 @@ def _store_properties(
|
||||||
return len(selected)
|
return len(selected)
|
||||||
|
|
||||||
|
|
||||||
def _record_error(
|
def _exception_detail(exc: BaseException) -> str:
|
||||||
errors: list[str], source: str, outcode: str, exc: Exception
|
|
||||||
) -> None:
|
|
||||||
detail = " ".join(str(exc).split())
|
detail = " ".join(str(exc).split())
|
||||||
|
if not detail:
|
||||||
|
detail = repr(exc)
|
||||||
if len(detail) > 300:
|
if len(detail) > 300:
|
||||||
detail = f"{detail[:300]}..."
|
detail = f"{detail[:300]}..."
|
||||||
|
return f"{type(exc).__name__}: {detail}"
|
||||||
|
|
||||||
|
|
||||||
|
def _record_error(
|
||||||
|
errors: list[str], source: str, outcode: str, exc: BaseException
|
||||||
|
) -> None:
|
||||||
|
detail = _exception_detail(exc)
|
||||||
message = f"{source} {outcode}: {detail}"
|
message = f"{source} {outcode}: {detail}"
|
||||||
errors.append(message)
|
errors.append(message)
|
||||||
log.warning(message)
|
log.warning(message)
|
||||||
|
|
||||||
|
|
||||||
def _launch_zoopla_with_retries(attempts: int = 3):
|
|
||||||
last_error: Exception | None = None
|
|
||||||
for attempt in range(1, attempts + 1):
|
|
||||||
try:
|
|
||||||
return launch_zoopla_browser()
|
|
||||||
except TurnstileError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = exc
|
|
||||||
log.warning(
|
|
||||||
"Zoopla browser launch failed (%d/%d): %s",
|
|
||||||
attempt,
|
|
||||||
attempts,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
assert last_error is not None
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
|
|
||||||
def _scrape_rightmove(
|
def _scrape_rightmove(
|
||||||
outcodes: list[str],
|
outcodes: list[str],
|
||||||
pc_index: PostcodeSpatialIndex,
|
pc_index: PostcodeSpatialIndex,
|
||||||
|
|
@ -349,6 +349,95 @@ def _scrape_rightmove(
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
class OutcodeTimeout(BaseException):
|
||||||
|
"""Raised when a single outcode exceeds the wall-clock budget.
|
||||||
|
|
||||||
|
Inherits BaseException (not Exception) so the SIGALRM-triggered raise can't
|
||||||
|
be silently swallowed by any of the broad `except Exception:` handlers
|
||||||
|
inside zoopla.py — the signal may fire at any bytecode boundary, including
|
||||||
|
inside those handlers."""
|
||||||
|
|
||||||
|
|
||||||
|
def _zoopla_outcode_timeout_seconds() -> int:
|
||||||
|
raw = os.environ.get("ZOOPLA_OUTCODE_TIMEOUT_SECONDS")
|
||||||
|
if raw is None:
|
||||||
|
return 300
|
||||||
|
try:
|
||||||
|
timeout = int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("ZOOPLA_OUTCODE_TIMEOUT_SECONDS must be an integer") from exc
|
||||||
|
if timeout < 1:
|
||||||
|
raise ValueError("ZOOPLA_OUTCODE_TIMEOUT_SECONDS must be greater than zero")
|
||||||
|
return timeout
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _wall_clock_timeout(seconds: int, label: str):
|
||||||
|
"""SIGALRM-based wall-clock guard (POSIX). Raises OutcodeTimeout on expiry.
|
||||||
|
|
||||||
|
Interrupts a hung Playwright IPC by delivering SIGALRM to the main thread;
|
||||||
|
socket waits return EINTR and the handler raises into the caller. The
|
||||||
|
browser is presumed unhealthy afterwards — caller must relaunch it."""
|
||||||
|
if seconds <= 0:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
def _handler(signum, frame):
|
||||||
|
raise OutcodeTimeout(f"{label} exceeded {seconds}s budget")
|
||||||
|
|
||||||
|
old_handler = signal.signal(signal.SIGALRM, _handler)
|
||||||
|
signal.alarm(seconds)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
signal.alarm(0)
|
||||||
|
signal.signal(signal.SIGALRM, old_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_zoopla_with_retries(attempts: int = 3):
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
try:
|
||||||
|
return launch_zoopla_browser()
|
||||||
|
except TurnstileError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
log.warning(
|
||||||
|
"Zoopla browser launch failed (%d/%d): %s",
|
||||||
|
attempt,
|
||||||
|
attempts,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
assert last_error is not None
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
|
||||||
|
def _close_zoopla_browser(browser, label: str) -> None:
|
||||||
|
try:
|
||||||
|
with _wall_clock_timeout(15, f"{label} browser close"):
|
||||||
|
browser.close()
|
||||||
|
return
|
||||||
|
except (OutcodeTimeout, Exception) as exc:
|
||||||
|
log.warning(
|
||||||
|
"%s browser close failed: %s; force-closing",
|
||||||
|
label,
|
||||||
|
_exception_detail(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
force_close = getattr(browser, "force_close", None)
|
||||||
|
if not callable(force_close):
|
||||||
|
log.warning("%s browser has no force-close hook", label)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
force_close()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("%s browser force-close failed: %s", label, _exception_detail(exc))
|
||||||
|
|
||||||
|
|
||||||
def _scrape_zoopla(
|
def _scrape_zoopla(
|
||||||
outcodes: list[str],
|
outcodes: list[str],
|
||||||
pc_index: PostcodeSpatialIndex,
|
pc_index: PostcodeSpatialIndex,
|
||||||
|
|
@ -364,6 +453,8 @@ def _scrape_zoopla(
|
||||||
log.warning("Zoopla skipped: browser launch failed: %s", exc)
|
log.warning("Zoopla skipped: browser launch failed: %s", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
outcode_timeout = _zoopla_outcode_timeout_seconds()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for outcode in outcodes:
|
for outcode in outcodes:
|
||||||
if _source_remaining(results, "zoopla", max_properties_per_source) == 0:
|
if _source_remaining(results, "zoopla", max_properties_per_source) == 0:
|
||||||
|
|
@ -372,8 +463,7 @@ def _scrape_zoopla(
|
||||||
|
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
# Fetch the outcode page set first; _store_properties applies
|
with _wall_clock_timeout(outcode_timeout, f"zoopla {outcode}"):
|
||||||
# the London-ish postcode filter and source cap after transformation.
|
|
||||||
props, _ = zoopla_search_outcode(
|
props, _ = zoopla_search_outcode(
|
||||||
page,
|
page,
|
||||||
outcode,
|
outcode,
|
||||||
|
|
@ -389,27 +479,74 @@ def _scrape_zoopla(
|
||||||
)
|
)
|
||||||
log.info("Zoopla %s: +%d", outcode, added)
|
log.info("Zoopla %s: +%d", outcode, added)
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except (OutcodeTimeout, Exception) as exc:
|
||||||
if attempt == 1:
|
if attempt == 1:
|
||||||
_record_error(errors, "zoopla", outcode, exc)
|
_record_error(errors, "zoopla", outcode, exc)
|
||||||
if isinstance(exc, TurnstileError):
|
if isinstance(exc, TurnstileError):
|
||||||
return
|
return
|
||||||
break
|
break
|
||||||
|
|
||||||
log.warning("Zoopla %s failed; relaunching browser and retrying", outcode)
|
log.warning(
|
||||||
try:
|
"Zoopla %s attempt %d/2 failed: %s; relaunching browser "
|
||||||
browser.close()
|
"and retrying",
|
||||||
except Exception:
|
outcode,
|
||||||
pass
|
attempt + 1,
|
||||||
|
_exception_detail(exc),
|
||||||
|
)
|
||||||
|
_close_zoopla_browser(browser, f"zoopla {outcode}")
|
||||||
try:
|
try:
|
||||||
browser, page = _launch_zoopla_with_retries()
|
browser, page = _launch_zoopla_with_retries()
|
||||||
|
log.info("Zoopla %s retrying with fresh browser", outcode)
|
||||||
except Exception as relaunch_exc:
|
except Exception as relaunch_exc:
|
||||||
_record_error(errors, "zoopla", outcode, relaunch_exc)
|
_record_error(errors, "zoopla", outcode, relaunch_exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
time.sleep(DELAY_BETWEEN_OUTCODES)
|
time.sleep(DELAY_BETWEEN_OUTCODES)
|
||||||
finally:
|
finally:
|
||||||
browser.close()
|
_close_zoopla_browser(browser, "zoopla final")
|
||||||
|
|
||||||
|
|
||||||
|
def _scrape_onthemarket(
|
||||||
|
outcodes: list[str],
|
||||||
|
pc_index: PostcodeSpatialIndex,
|
||||||
|
results: dict[str, list[dict]],
|
||||||
|
errors: list[str],
|
||||||
|
max_properties_per_source: int | None,
|
||||||
|
) -> None:
|
||||||
|
client = make_client()
|
||||||
|
try:
|
||||||
|
for outcode in outcodes:
|
||||||
|
if (
|
||||||
|
_source_remaining(results, "onthemarket", max_properties_per_source)
|
||||||
|
== 0
|
||||||
|
):
|
||||||
|
log.info("OnTheMarket cap reached")
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining = _source_remaining(
|
||||||
|
results, "onthemarket", max_properties_per_source
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
props = onthemarket_search_outcode(
|
||||||
|
client,
|
||||||
|
outcode,
|
||||||
|
pc_index,
|
||||||
|
max_properties=remaining,
|
||||||
|
)
|
||||||
|
added = _store_properties(
|
||||||
|
results,
|
||||||
|
"onthemarket",
|
||||||
|
props,
|
||||||
|
max_properties_per_source,
|
||||||
|
)
|
||||||
|
log.info("OnTheMarket %s: +%d", outcode, added)
|
||||||
|
except Exception as exc:
|
||||||
|
_record_error(errors, "onthemarket", outcode, exc)
|
||||||
|
|
||||||
|
time.sleep(DELAY_BETWEEN_OUTCODES)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def run_scrape(
|
def run_scrape(
|
||||||
|
|
@ -451,6 +588,15 @@ def run_scrape(
|
||||||
max_properties_per_source,
|
max_properties_per_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "onthemarket" in selected_sources:
|
||||||
|
_scrape_onthemarket(
|
||||||
|
selected_outcodes,
|
||||||
|
pc_index,
|
||||||
|
results,
|
||||||
|
errors,
|
||||||
|
max_properties_per_source,
|
||||||
|
)
|
||||||
|
|
||||||
if "zoopla" in selected_sources:
|
if "zoopla" in selected_sources:
|
||||||
if pc_coords is None:
|
if pc_coords is None:
|
||||||
pc_coords = build_postcode_coords()
|
pc_coords = build_postcode_coords()
|
||||||
|
|
|
||||||
664
finder/uv.lock
generated
664
finder/uv.lock
generated
|
|
@ -17,11 +17,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apify-fingerprint-datapoints"
|
name = "apify-fingerprint-datapoints"
|
||||||
version = "0.11.0"
|
version = "0.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/a9/586b7ebdd682c047cd0b551dc7e154bb1480f8f6548154708e9a6c7844db/apify_fingerprint_datapoints-0.11.0.tar.gz", hash = "sha256:3f905c392b11a27fb59ccfe40891c166abd737ab9c6209733f102bbb3b302515", size = 969830, upload-time = "2026-03-01T01:00:04.737Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/f1/b74f95767581372ab849c8b13e384b62f60d034584892c60c4a3442d9312/apify_fingerprint_datapoints-0.13.0.tar.gz", hash = "sha256:263141c19e9bc90a821e6b4e2b845925f17e0b8fbd53a897fc71546bd50df7f1", size = 934827, upload-time = "2026-05-04T09:08:45.036Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/38/9483eb52fc0f00039c684af627f8a8f994a8a99e8eceb869ba93b3fd740b/apify_fingerprint_datapoints-0.11.0-py3-none-any.whl", hash = "sha256:333340ccc3e520f19b5561e95d7abe2b31702e61d34b6247b328c9b8c93fbe1d", size = 726498, upload-time = "2026-03-01T01:00:03.103Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/58/8402442bf6af5a3a8068fe5431c42ea4f73c1eb18f621f9bf7c5de80caf5/apify_fingerprint_datapoints-0.13.0-py3-none-any.whl", hash = "sha256:0213d42297be19e8035202b41fb2e840a1e5d79874c99c882a5027a7d0b1a0eb", size = 761652, upload-time = "2026-05-04T09:08:43.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -74,87 +74,87 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.6"
|
version = "3.4.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
|
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
|
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
|
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
|
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -168,15 +168,15 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cython"
|
name = "cython"
|
||||||
version = "3.2.4"
|
version = "3.2.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/91/85/7574c9cd44b69a27210444b6650f6477f56c75fee1b70d7672d3e4166167/cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6", size = 3280291, upload-time = "2026-01-04T14:14:14.473Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/3b/ebd94c8b85f8e41b5015a9ed94ee3df866024d480d05cd08b774684fb81d/cython-3.2.5.tar.gz", hash = "sha256:3dd42e4cf36ad15f265bdfec2337cc00c688c8eb6d374ffd13bb19437c27bba1", size = 3286381, upload-time = "2026-05-23T19:34:08.439Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/4d/1eb0c7c196a136b1926f4d7f0492a96c6fabd604d77e6cd43b56a3a16d83/cython-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64d7f71be3dd6d6d4a4c575bb3a4674ea06d1e1e5e4cd1b9882a2bc40ed3c4c9", size = 2970064, upload-time = "2026-01-04T14:15:08.567Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/a6/efc97000fdb2f34e2431eb09a6ab4de9fbd3bcdb73a8f9d224afa4a9abd3/cython-3.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eb38b89e5a8eb2508a1a0832063826b0703dfb02be84e4aa34b8818ce0ca50fe", size = 2979670, upload-time = "2026-05-23T19:34:41.281Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/b5/1cfca43b7d20a0fdb1eac67313d6bb6b18d18897f82dd0f17436bdd2ba7f/cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0", size = 2960506, upload-time = "2026-01-04T14:15:16.733Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/30/f648409de61fd74ae63090071061145059664cc9b9ff8578197601a3beb6/cython-3.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6e5d7a60835345a8bd29d3aa57070880cc3ce017ea0ade7b9f771ce4bf539b1f", size = 2968935, upload-time = "2026-05-23T19:34:49Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/d7/3bda3efce0c5c6ce79cc21285dbe6f60369c20364e112f5a506ee8a1b067/cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa", size = 2971496, upload-time = "2026-01-04T14:15:25.038Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/78/668ef887621f68255feddd482dbcdcf5788b6c91227dd35bd17f128f827b/cython-3.2.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a636c8b7824f3cb587eb2fdde59d8f4a14d433565508081cc290198e37567910", size = 2981525, upload-time = "2026-05-23T19:34:58.445Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/8b/fd393f0923c82be4ec0db712fffb2ff0a7a131707b842c99bf24b549274d/cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf", size = 2875622, upload-time = "2026-01-04T14:15:39.749Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/de/e3e0cf5704fe569d54b8cd5dc316c9fbf08b1b74728732f86e90168b7a3f/cython-3.2.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:224149d18d980e6ea5001b70fc7ce096c1891d59035dfa9cc5ede50f55804913", size = 2879054, upload-time = "2026-05-23T19:35:18.265Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/5c/9cd909e6a8bb178e4e0f9a2a9227c8201a2be38abe45ada4a4c3e9154277/cython-3.2.5-py3-none-any.whl", hash = "sha256:dc1c8cebb7df5bce37f5f8dc1e5bf04313272a5973d50a55c0ec76c83812911b", size = 1257622, upload-time = "2026-05-23T19:34:05.163Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -205,51 +205,75 @@ requires-dist = [
|
||||||
{ name = "camoufox", specifier = ">=0.4.11" },
|
{ name = "camoufox", specifier = ">=0.4.11" },
|
||||||
{ name = "fake-useragent", specifier = ">=2.2.0" },
|
{ name = "fake-useragent", specifier = ">=2.2.0" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "playwright", specifier = ">=1.58.0" },
|
{ name = "playwright", specifier = ">=1.58.0,<1.60.0" },
|
||||||
{ name = "polars" },
|
{ name = "polars" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.3.2"
|
version = "3.5.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
|
{ url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
|
{ url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
|
{ url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
|
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -300,233 +324,233 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "language-tags"
|
name = "language-tags"
|
||||||
version = "1.2.0"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/7e/b6a0efe4fee11e9742c1baaedf7c574084238a70b03c1d8eb2761383848f/language_tags-1.2.0.tar.gz", hash = "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6", size = 207901, upload-time = "2023-01-11T18:38:07.893Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/39/d1/b37165bed9016c19e2cdecf4176fbd902a014fb8a87dd44415438e190969/language_tags-1.3.1.tar.gz", hash = "sha256:b15f05505dad3ad296a1782d5a6083fd141309186094d1ab08095348f4203f37", size = 222642, upload-time = "2026-05-08T11:46:24.418Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/42/327554649ed2dd5ce59d3f5da176c7be20f9352c7c6c51597293660b7b08/language_tags-1.2.0-py3-none-any.whl", hash = "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722", size = 213449, upload-time = "2023-01-11T18:38:05.692Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/21/b48b9e8408b3faec9fb7cb2f68352c7724add35a980c948eaceb29ac41e4/language_tags-1.3.1-py3-none-any.whl", hash = "sha256:f7db7ef8879523019603e09644a86d95ba60595ce5e5ea82d46e8971b0f68f7b", size = 216654, upload-time = "2026-05-08T11:46:17.836Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lxml"
|
name = "lxml"
|
||||||
version = "6.0.2"
|
version = "6.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "2.4.3"
|
version = "2.4.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "orjson"
|
name = "orjson"
|
||||||
version = "3.11.7"
|
version = "3.11.9"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" },
|
{ url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" },
|
{ url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.9.4"
|
version = "4.9.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "playwright"
|
name = "playwright"
|
||||||
version = "1.58.0"
|
version = "1.59.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet" },
|
||||||
{ name = "pyee" },
|
{ name = "pyee" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/48/abab23f40643b4de8f2665816f0a1bf0994eeecda39d6d62f0f292b2ad01/playwright-1.59.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e", size = 43156922, upload-time = "2026-04-29T08:11:08.921Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/71/5e4d98b2ce3641b4343623c6450ff33b9de1c979d12a957505e392338b07/playwright-1.59.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47", size = 41947177, upload-time = "2026-04-29T08:11:12.867Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/91/fd219aa78ca03d37e93aaedaed4e224131e3090a9264f9bb773c8271d67e/playwright-1.59.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6", size = 43156922, upload-time = "2026-04-29T08:11:16.595Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
|
{ url = "https://files.pythonhosted.org/packages/73/0c/1e513d37c5be07d12829ebce93dbfe7baee230084cb66966c423432799c4/playwright-1.59.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d", size = 47151793, upload-time = "2026-04-29T08:11:19.979Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/2d/15f72288cb65d690134e18fefb9483cc4976f7579b580648c45e494481a7/playwright-1.59.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100", size = 46877615, upload-time = "2026-04-29T08:11:23.863Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
|
{ url = "https://files.pythonhosted.org/packages/72/a1/717ac5bc99f387c0f60def91271ea4262125c0815d764a5d1776a272275c/playwright-1.59.0-py3-none-win32.whl", hash = "sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119", size = 37713698, upload-time = "2026-04-29T08:11:27.251Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/a5/4e630ee05d8b46b840f943268e86d6063703e8dadb2d3eb405c7b9b2e48c/playwright-1.59.0-py3-none-win_amd64.whl", hash = "sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c", size = 37713704, upload-time = "2026-04-29T08:11:30.41Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/0c/3ece41761ba13c8321009aefcaec7a016eb42799c42eef5e03ace7f2de5b/playwright-1.59.0-py3-none-win_arm64.whl", hash = "sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9", size = 33956745, upload-time = "2026-04-29T08:11:34.738Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -655,7 +679,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
version = "2.34.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
|
|
@ -663,9 +687,9 @@ dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -704,29 +728,29 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ua-parser"
|
name = "ua-parser"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "ua-parser-builtins" },
|
{ name = "ua-parser-builtins" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/70/0e/ed98be735bc89d5040e0c60f5620d0b8c04e9e7da99ed1459e8050e90a77/ua_parser-1.0.1.tar.gz", hash = "sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d", size = 728106, upload-time = "2025-02-01T14:13:32.508Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/90/98/5e4b52d772a048af122a6fc5ce365c311efb9f5e79c55fd4fdd7c9f59e83/ua_parser-1.0.2.tar.gz", hash = "sha256:bab404ad42fb37f943107da2f6003ffc79724d11cc95076a7a539513371779da", size = 33239, upload-time = "2026-04-05T20:14:28.229Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/37/be6dfbfa45719aa82c008fb4772cfe5c46db765a2ca4b6f524a1fdfee4d7/ua_parser-1.0.1-py3-none-any.whl", hash = "sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea", size = 31410, upload-time = "2025-02-01T14:13:28.458Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/7c/6367995ff57aaa2d9e1055adbaec2519cf5a979780a83a93fdf8c6ec37be/ua_parser-1.0.2-py3-none-any.whl", hash = "sha256:0f8e6d0484af2a9ff804bba5a4fe696e87c028eaba98ad9a7dfae873fef7788a", size = 31219, upload-time = "2026-04-05T20:14:26.913Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ua-parser-builtins"
|
name = "ua-parser-builtins"
|
||||||
version = "202603"
|
version = "202605"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/6f/73a4d37deefb159556d39d654b5bad67b6874d1ad0b20b96fb5a04de3949/ua_parser_builtins-202603-py3-none-any.whl", hash = "sha256:67478397a68fac1a98fd0a31c416ea7c65a719141fc151d0211316f2cd337cc9", size = 89573, upload-time = "2026-03-01T20:50:02.491Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/42/178db21aab1815583fcdb8ae465fc006b384fbe679412b11ddf8aae90f38/ua_parser_builtins-202605-py3-none-any.whl", hash = "sha256:a86976baa4b7c69a54269fe54091e3f0c7666f15a0f893855ff907a3bb6d878c", size = 90591, upload-time = "2026-05-01T21:25:50.636Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.7.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
340
finder/zoopla.py
340
finder/zoopla.py
|
|
@ -21,11 +21,14 @@ Architecture:
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
DELAY_BETWEEN_PAGES,
|
DELAY_BETWEEN_PAGES,
|
||||||
|
|
@ -43,6 +46,119 @@ class TurnstileError(Exception):
|
||||||
"""Raised when Cloudflare Turnstile challenge cannot be passed."""
|
"""Raised when Cloudflare Turnstile challenge cannot be passed."""
|
||||||
|
|
||||||
|
|
||||||
|
def _pid_exists(pid: int) -> bool:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _proc_ppid(pid: int) -> int | None:
|
||||||
|
try:
|
||||||
|
for line in Path(f"/proc/{pid}/status").read_text().splitlines():
|
||||||
|
if line.startswith("PPid:"):
|
||||||
|
return int(line.split()[1])
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _proc_descendants(root_pid: int) -> set[int]:
|
||||||
|
proc_root = Path("/proc")
|
||||||
|
if not proc_root.exists():
|
||||||
|
return set()
|
||||||
|
|
||||||
|
children: dict[int, list[int]] = {}
|
||||||
|
for path in proc_root.iterdir():
|
||||||
|
if not path.name.isdigit():
|
||||||
|
continue
|
||||||
|
pid = int(path.name)
|
||||||
|
ppid = _proc_ppid(pid)
|
||||||
|
if ppid is not None:
|
||||||
|
children.setdefault(ppid, []).append(pid)
|
||||||
|
|
||||||
|
descendants: set[int] = set()
|
||||||
|
stack = list(children.get(root_pid, []))
|
||||||
|
while stack:
|
||||||
|
pid = stack.pop()
|
||||||
|
if pid in descendants:
|
||||||
|
continue
|
||||||
|
descendants.add(pid)
|
||||||
|
stack.extend(children.get(pid, []))
|
||||||
|
return descendants
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_process_tree(root_pid: int, label: str) -> None:
|
||||||
|
if root_pid <= 0 or root_pid == os.getpid():
|
||||||
|
return
|
||||||
|
|
||||||
|
pids = _proc_descendants(root_pid) | {root_pid}
|
||||||
|
for sig, sig_name, delay in (
|
||||||
|
(signal.SIGTERM, "SIGTERM", 1.0),
|
||||||
|
(signal.SIGKILL, "SIGKILL", 0.5),
|
||||||
|
):
|
||||||
|
alive = [pid for pid in sorted(pids, reverse=True) if _pid_exists(pid)]
|
||||||
|
if not alive:
|
||||||
|
return
|
||||||
|
log.warning("%s: sending %s to %d process(es)", label, sig_name, len(alive))
|
||||||
|
for pid in alive:
|
||||||
|
try:
|
||||||
|
os.kill(pid, sig)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
log.debug("%s: could not signal pid %d: %s", label, pid, exc)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
alive = [pid for pid in sorted(pids) if _pid_exists(pid)]
|
||||||
|
if alive:
|
||||||
|
log.warning("%s: process(es) still alive after force close: %s", label, alive)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_cmdline(pid: int) -> str:
|
||||||
|
try:
|
||||||
|
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
return raw.replace(b"\0", b" ").decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_in_live_process(profile_dir: Path) -> bool:
|
||||||
|
proc_root = Path("/proc")
|
||||||
|
if not proc_root.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
needle = str(profile_dir)
|
||||||
|
for path in proc_root.iterdir():
|
||||||
|
if path.name.isdigit() and needle in _process_cmdline(int(path.name)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_stale_profile_locks(profile_dir: Path) -> None:
|
||||||
|
if _profile_in_live_process(profile_dir):
|
||||||
|
return
|
||||||
|
|
||||||
|
for name in (".parentlock", "parent.lock", "lock"):
|
||||||
|
lock_path = profile_dir / name
|
||||||
|
try:
|
||||||
|
if lock_path.exists() or lock_path.is_symlink():
|
||||||
|
lock_path.unlink()
|
||||||
|
log.warning("Removed stale Zoopla profile lock: %s", lock_path)
|
||||||
|
except OSError as exc:
|
||||||
|
log.debug("Could not remove Zoopla profile lock %s: %s", lock_path, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _exception_detail(exc: BaseException) -> str:
|
||||||
|
detail = " ".join(str(exc).split())
|
||||||
|
if not detail:
|
||||||
|
detail = repr(exc)
|
||||||
|
return f"{type(exc).__name__}: {detail}"
|
||||||
|
|
||||||
|
|
||||||
class _ManagedCamoufoxBrowser:
|
class _ManagedCamoufoxBrowser:
|
||||||
def __init__(self, context_manager, browser):
|
def __init__(self, context_manager, browser):
|
||||||
self._context_manager = context_manager
|
self._context_manager = context_manager
|
||||||
|
|
@ -53,11 +169,28 @@ class _ManagedCamoufoxBrowser:
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
try:
|
|
||||||
self._browser.close()
|
self._browser.close()
|
||||||
finally:
|
# Camoufox.__exit__ calls browser.close() itself. The context is already
|
||||||
|
# closed here, so clear it to avoid a second blocking close attempt.
|
||||||
|
self._context_manager.browser = None
|
||||||
self._context_manager.__exit__(None, None, None)
|
self._context_manager.__exit__(None, None, None)
|
||||||
|
|
||||||
|
def force_close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
pid = self._driver_pid()
|
||||||
|
if pid is None:
|
||||||
|
log.warning("Zoopla force-close requested but Playwright driver pid is unknown")
|
||||||
|
return
|
||||||
|
_terminate_process_tree(pid, "Zoopla browser force-close")
|
||||||
|
_remove_stale_profile_locks(_zoopla_profile_dir())
|
||||||
|
|
||||||
|
def _driver_pid(self) -> int | None:
|
||||||
|
connection = getattr(self._context_manager, "_connection", None)
|
||||||
|
transport = getattr(connection, "_transport", None)
|
||||||
|
proc = getattr(transport, "_proc", None)
|
||||||
|
pid = getattr(proc, "pid", None)
|
||||||
|
return pid if isinstance(pid, int) else None
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
return getattr(self._browser, name)
|
return getattr(self._browser, name)
|
||||||
|
|
||||||
|
|
@ -319,6 +452,161 @@ def _challenge_timeout_seconds() -> int:
|
||||||
return timeout
|
return timeout
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gluetun IP rotation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When Cloudflare Turnstile fires mid-scrape, the cheapest unblocker is to
|
||||||
|
# swap the egress IP via Gluetun's HTTP control server. We stop and re-start
|
||||||
|
# the VPN, poll until the public IP changes, drop the stale cf_clearance
|
||||||
|
# cookies (bound to the previous IP), then reload and re-check the challenge.
|
||||||
|
|
||||||
|
|
||||||
|
_GLUETUN_API_KEY = "My8AbvnKhfyFdRhpTVfoTfa5DkAMmg8K"
|
||||||
|
|
||||||
|
|
||||||
|
def _gluetun_base_url() -> str:
|
||||||
|
return os.environ.get("GLUETUN_URL", "http://gluetun:8000").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _gluetun_api_key() -> str | None:
|
||||||
|
return _GLUETUN_API_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def _gluetun_max_rotations() -> int:
|
||||||
|
raw = os.environ.get("GLUETUN_MAX_ROTATIONS", "3")
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("GLUETUN_MAX_ROTATIONS must be an integer") from exc
|
||||||
|
return max(value, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _gluetun_client() -> httpx.Client:
|
||||||
|
headers = {}
|
||||||
|
api_key = _gluetun_api_key()
|
||||||
|
if api_key:
|
||||||
|
headers["X-API-Key"] = api_key
|
||||||
|
return httpx.Client(headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _gluetun_public_ip(client: httpx.Client) -> str | None:
|
||||||
|
try:
|
||||||
|
resp = client.get(f"{_gluetun_base_url()}/v1/publicip/ip", timeout=5.0)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
return None
|
||||||
|
return data.get("public_ip") or data.get("ip")
|
||||||
|
|
||||||
|
|
||||||
|
def _gluetun_set_vpn_status(client: httpx.Client, status: str) -> bool:
|
||||||
|
"""PUT /v1/vpn/status with {'status': status}. Returns True on 2xx."""
|
||||||
|
try:
|
||||||
|
resp = client.put(
|
||||||
|
f"{_gluetun_base_url()}/v1/vpn/status",
|
||||||
|
json={"status": status},
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
log.warning("Gluetun vpn/status %s failed: %s", status, exc)
|
||||||
|
return False
|
||||||
|
if resp.status_code == 401:
|
||||||
|
log.warning(
|
||||||
|
"Gluetun vpn/status %s: 401 Unauthorized — the API key must be "
|
||||||
|
"authorised for 'PUT /v1/vpn/status' in Gluetun's auth config.toml",
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
log.warning(
|
||||||
|
"Gluetun vpn/status %s returned HTTP %d: %s",
|
||||||
|
status, resp.status_code, resp.text[:200],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _rotate_gluetun_ip(wait_seconds: int = 45) -> bool:
|
||||||
|
"""Restart Gluetun's VPN and wait for the public IP to change.
|
||||||
|
|
||||||
|
Returns True if a new IP was observed within wait_seconds."""
|
||||||
|
with _gluetun_client() as client:
|
||||||
|
old_ip = _gluetun_public_ip(client)
|
||||||
|
log.info("Requesting Gluetun IP rotation (current IP: %s)", old_ip or "unknown")
|
||||||
|
|
||||||
|
stop_attempted = False
|
||||||
|
restart_confirmed = False
|
||||||
|
try:
|
||||||
|
stop_attempted = True
|
||||||
|
if not _gluetun_set_vpn_status(client, "stopped"):
|
||||||
|
return False
|
||||||
|
time.sleep(2)
|
||||||
|
restart_confirmed = _gluetun_set_vpn_status(client, "running")
|
||||||
|
if not restart_confirmed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
deadline = time.monotonic() + wait_seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
time.sleep(2)
|
||||||
|
new_ip = _gluetun_public_ip(client)
|
||||||
|
if new_ip and new_ip != old_ip:
|
||||||
|
log.info("Gluetun rotated IP: %s -> %s", old_ip or "?", new_ip)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if stop_attempted and not restart_confirmed:
|
||||||
|
log.warning(
|
||||||
|
"Gluetun VPN may be stopped after failed rotation; attempting recovery start"
|
||||||
|
)
|
||||||
|
if not _gluetun_set_vpn_status(client, "running"):
|
||||||
|
log.error(
|
||||||
|
"Gluetun VPN recovery start failed; manual intervention required"
|
||||||
|
)
|
||||||
|
|
||||||
|
log.warning("Gluetun IP did not change within %ds", wait_seconds)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_cloudflare_cookies(page) -> None:
|
||||||
|
"""Drop cf_clearance / __cf_bm which are bound to the previous egress IP."""
|
||||||
|
try:
|
||||||
|
context = page.context
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for name in ("cf_clearance", "__cf_bm"):
|
||||||
|
try:
|
||||||
|
context.clear_cookies(name=name)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Could not clear cookie %s: %s", name, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _rotate_and_retry_challenge(page, max_rotations: int) -> bool:
|
||||||
|
"""Rotate IP and reload until the challenge clears. Returns True on success."""
|
||||||
|
for attempt in range(1, max_rotations + 1):
|
||||||
|
log.warning(
|
||||||
|
"Cloudflare Turnstile challenge — rotating Gluetun IP (attempt %d/%d)",
|
||||||
|
attempt, max_rotations,
|
||||||
|
)
|
||||||
|
if not _rotate_gluetun_ip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
_clear_cloudflare_cookies(page)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.reload(wait_until="domcontentloaded", timeout=30000)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Reload after IP rotation failed: %s", exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
if not _is_turnstile_challenge(page):
|
||||||
|
log.info("Cloudflare challenge cleared after Gluetun rotation")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_turnstile_challenge(page) -> bool:
|
def _is_turnstile_challenge(page) -> bool:
|
||||||
try:
|
try:
|
||||||
if "just a moment" in page.title().lower():
|
if "just a moment" in page.title().lower():
|
||||||
|
|
@ -341,18 +629,26 @@ def _wait_for_turnstile(page, headless_mode: bool | str) -> None:
|
||||||
if not _is_turnstile_challenge(page):
|
if not _is_turnstile_challenge(page):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Try Gluetun IP rotation first — works in any mode and is the only option
|
||||||
|
# in headless/unattended runs where no human can click the challenge.
|
||||||
|
max_rotations = _gluetun_max_rotations()
|
||||||
|
if max_rotations > 0 and _rotate_and_retry_challenge(page, max_rotations):
|
||||||
|
return
|
||||||
|
|
||||||
profile_dir = _zoopla_profile_dir()
|
profile_dir = _zoopla_profile_dir()
|
||||||
if headless_mode is True or headless_mode == "virtual":
|
if headless_mode is True or headless_mode == "virtual":
|
||||||
raise TurnstileError(
|
raise TurnstileError(
|
||||||
"Cloudflare Turnstile requires a visible browser session. "
|
"Cloudflare Turnstile persisted after "
|
||||||
"Run Zoopla from a desktop session with ZOOPLA_HEADLESS=0; "
|
f"{max_rotations} Gluetun IP rotation(s). "
|
||||||
f"the solved session will be saved in {profile_dir}."
|
"Run Zoopla from a desktop session with ZOOPLA_HEADLESS=0 "
|
||||||
|
f"to solve manually; the session will be saved in {profile_dir}."
|
||||||
)
|
)
|
||||||
|
|
||||||
timeout = _challenge_timeout_seconds()
|
timeout = _challenge_timeout_seconds()
|
||||||
log.warning(
|
log.warning(
|
||||||
"Cloudflare Turnstile challenge shown. Complete it in the Zoopla browser "
|
"Gluetun rotation insufficient — falling back to interactive solve. "
|
||||||
"window; waiting up to %ds. Profile: %s",
|
"Complete the Cloudflare challenge in the Zoopla browser window; "
|
||||||
|
"waiting up to %ds. Profile: %s",
|
||||||
timeout,
|
timeout,
|
||||||
profile_dir,
|
profile_dir,
|
||||||
)
|
)
|
||||||
|
|
@ -390,6 +686,7 @@ def launch_browser():
|
||||||
headless_mode = _zoopla_headless_mode()
|
headless_mode = _zoopla_headless_mode()
|
||||||
profile_dir = _zoopla_profile_dir()
|
profile_dir = _zoopla_profile_dir()
|
||||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
_remove_stale_profile_locks(profile_dir)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Launching Camoufox browser for Zoopla (headless=%s, profile=%s)...",
|
"Launching Camoufox browser for Zoopla (headless=%s, profile=%s)...",
|
||||||
|
|
@ -471,8 +768,11 @@ def _navigate_search(page, outcode: str) -> bool:
|
||||||
try:
|
try:
|
||||||
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("Zoopla direct navigation failed for %s: %s", outcode, exc)
|
detail = _exception_detail(exc)
|
||||||
return False
|
log.warning("Zoopla direct navigation failed for %s: %s", outcode, detail)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Zoopla direct navigation failed for {outcode}: {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
_ensure_not_challenged(page)
|
_ensure_not_challenged(page)
|
||||||
|
|
||||||
|
|
@ -560,8 +860,10 @@ def _find_next_page_url(page) -> str | None:
|
||||||
return href;
|
return href;
|
||||||
}"""
|
}"""
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
return None
|
detail = _exception_detail(exc)
|
||||||
|
log.warning("Zoopla next-page detection failed: %s", detail)
|
||||||
|
raise RuntimeError(f"Zoopla next-page detection failed: {detail}") from exc
|
||||||
if not href:
|
if not href:
|
||||||
return None
|
return None
|
||||||
return urljoin(ZOOPLA_BASE, href)
|
return urljoin(ZOOPLA_BASE, href)
|
||||||
|
|
@ -609,8 +911,9 @@ def _extract_listings(page) -> list[dict]:
|
||||||
|
|
||||||
return listings
|
return listings
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Failed to extract listings from DOM: %s", e)
|
detail = _exception_detail(e)
|
||||||
return []
|
log.warning("Failed to extract listings from DOM: %s", detail)
|
||||||
|
raise RuntimeError(f"Zoopla DOM extraction failed: {detail}") from e
|
||||||
|
|
||||||
|
|
||||||
def _paginate(
|
def _paginate(
|
||||||
|
|
@ -649,8 +952,15 @@ def _paginate(
|
||||||
except TurnstileError:
|
except TurnstileError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug("Pagination navigation failed at page %d: %s", page_num, e)
|
detail = _exception_detail(e)
|
||||||
break
|
log.warning(
|
||||||
|
"Zoopla pagination navigation failed at page %d: %s",
|
||||||
|
page_num,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Zoopla pagination navigation failed at page {page_num}: {detail}"
|
||||||
|
) from e
|
||||||
|
|
||||||
page_listings = _extract_listings(page)
|
page_listings = _extract_listings(page)
|
||||||
if not page_listings:
|
if not page_listings:
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,7 @@ export default function App() {
|
||||||
initialFilters={urlState.filters}
|
initialFilters={urlState.filters}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
initialPOICategories={urlState.poiCategories}
|
initialPOICategories={urlState.poiCategories}
|
||||||
|
initialOverlays={urlState.overlays}
|
||||||
initialTab={urlState.tab}
|
initialTab={urlState.tab}
|
||||||
initialLoading={initialLoading}
|
initialLoading={initialLoading}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|
@ -659,6 +660,7 @@ export default function App() {
|
||||||
initialFilters={mapUrlState.filters}
|
initialFilters={mapUrlState.filters}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
initialPOICategories={mapUrlState.poiCategories}
|
initialPOICategories={mapUrlState.poiCategories}
|
||||||
|
initialOverlays={mapUrlState.overlays}
|
||||||
initialTab={mapUrlState.tab}
|
initialTab={mapUrlState.tab}
|
||||||
initialLoading={initialLoading}
|
initialLoading={initialLoading}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,16 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
||||||
url: 'https://www.forestresearch.gov.uk/tools-and-resources/national-trees-outside-woodland-map/',
|
url: 'https://www.forestresearch.gov.uk/tools-and-resources/national-trees-outside-woodland-map/',
|
||||||
license: 'Open Government Licence v3.0',
|
license: 'Open Government Licence v3.0',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'conservation-areas',
|
||||||
|
url: 'https://opendata-historicengland.hub.arcgis.com/datasets/historicengland::conservation-areas/explore',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'listed-buildings',
|
||||||
|
url: 'https://opendata-historicengland.hub.arcgis.com/datasets/historicengland::national-heritage-list-for-england-nhle/explore?layer=0',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'naptan',
|
id: 'naptan',
|
||||||
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
||||||
|
|
@ -128,6 +138,16 @@ const DS_KEYS: Record<string, [string, string, string]> = {
|
||||||
'learnPage.dsGreenspaceUse',
|
'learnPage.dsGreenspaceUse',
|
||||||
],
|
],
|
||||||
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
|
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
|
||||||
|
'conservation-areas': [
|
||||||
|
'learnPage.dsConservationAreasName',
|
||||||
|
'learnPage.dsConservationAreasOrigin',
|
||||||
|
'learnPage.dsConservationAreasUse',
|
||||||
|
],
|
||||||
|
'listed-buildings': [
|
||||||
|
'learnPage.dsListedBuildingsName',
|
||||||
|
'learnPage.dsListedBuildingsOrigin',
|
||||||
|
'learnPage.dsListedBuildingsUse',
|
||||||
|
],
|
||||||
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
||||||
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
||||||
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
||||||
|
|
@ -235,6 +255,17 @@ export default function LearnPage() {
|
||||||
{ question: t('learnPage.faqDueDiligence4Q'), answer: t('learnPage.faqDueDiligence4A') },
|
{ question: t('learnPage.faqDueDiligence4Q'), answer: t('learnPage.faqDueDiligence4A') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('learnPage.faqBehindDataTitle'),
|
||||||
|
items: [
|
||||||
|
{ question: t('learnPage.faqBehindData1Q'), answer: t('learnPage.faqBehindData1A') },
|
||||||
|
{ question: t('learnPage.faqBehindData2Q'), answer: t('learnPage.faqBehindData2A') },
|
||||||
|
{ question: t('learnPage.faqBehindData3Q'), answer: t('learnPage.faqBehindData3A') },
|
||||||
|
{ question: t('learnPage.faqBehindData4Q'), answer: t('learnPage.faqBehindData4A') },
|
||||||
|
{ question: t('learnPage.faqBehindData5Q'), answer: t('learnPage.faqBehindData5A') },
|
||||||
|
{ question: t('learnPage.faqBehindData6Q'), answer: t('learnPage.faqBehindData6A') },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('learnPage.faqPrivacyTitle'),
|
title: t('learnPage.faqPrivacyTitle'),
|
||||||
items: [{ question: t('learnPage.faqPrivacy1Q'), answer: t('learnPage.faqPrivacy1A') }],
|
items: [{ question: t('learnPage.faqPrivacy1Q'), answer: t('learnPage.faqPrivacy1A') }],
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ 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 ExternalSearchLinks from './ExternalSearchLinks';
|
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||||
import { InfoIcon, TransitIcon } from '../ui/icons';
|
import { InfoIcon, TransitIcon } from '../ui/icons';
|
||||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||||
|
|
@ -264,6 +265,18 @@ 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 is keyed in the API by the bare crime type (e.g. "Burglary").
|
||||||
|
// We also index by the configured feature name (with " (avg/yr)" suffix) so the
|
||||||
|
// metric-row renderer can look it up using the feature name it already has.
|
||||||
|
const crimeByYearByFeatureName = useMemo(() => {
|
||||||
|
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_by_year']>[number]>();
|
||||||
|
for (const entry of stats?.crime_by_year ?? []) {
|
||||||
|
map.set(entry.name, entry);
|
||||||
|
map.set(`${entry.name} (avg/yr)`, entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
const globalFeatureByName = useMemo(
|
const globalFeatureByName = useMemo(
|
||||||
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
||||||
[globalFeatures]
|
[globalFeatures]
|
||||||
|
|
@ -599,6 +612,7 @@ export default function AreaPane({
|
||||||
const globalMean = globalHistogram
|
const globalMean = globalHistogram
|
||||||
? calculateHistogramMean(globalHistogram)
|
? calculateHistogramMean(globalHistogram)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MetricRow
|
<MetricRow
|
||||||
|
|
@ -611,7 +625,9 @@ export default function AreaPane({
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
chart={
|
chart={
|
||||||
numericStats.histogram &&
|
crimeSeries && crimeSeries.points.length > 1 ? (
|
||||||
|
<CrimeYearChart points={crimeSeries.points} />
|
||||||
|
) : (numericStats.histogram &&
|
||||||
(globalHistogram ? (
|
(globalHistogram ? (
|
||||||
<DualHistogram
|
<DualHistogram
|
||||||
localCounts={numericStats.histogram.counts}
|
localCounts={numericStats.histogram.counts}
|
||||||
|
|
@ -648,7 +664,7 @@ export default function AreaPane({
|
||||||
integerAxisLabels={feature.step === 1}
|
integerAxisLabels={feature.step === 1}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
value={formatValue(numericStats.mean, feature)}
|
value={formatValue(numericStats.mean, feature)}
|
||||||
valueTitle={
|
valueTitle={
|
||||||
|
|
|
||||||
102
frontend/src/components/map/CrimeYearChart.tsx
Normal file
102
frontend/src/components/map/CrimeYearChart.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { CrimeYearPoint } from '../../types';
|
||||||
|
|
||||||
|
interface CrimeYearChartProps {
|
||||||
|
points: CrimeYearPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PADDING = { top: 6, right: 4, bottom: 14, left: 4 };
|
||||||
|
const HEIGHT = 48;
|
||||||
|
|
||||||
|
export default function CrimeYearChart({ points }: CrimeYearChartProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const w = entries[0].contentRect.width;
|
||||||
|
if (w > 0) setWidth(w);
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { yearMin, yearMax, max } = useMemo(() => {
|
||||||
|
let yMin = Infinity;
|
||||||
|
let yMax = -Infinity;
|
||||||
|
let m = 0;
|
||||||
|
for (const p of points) {
|
||||||
|
if (p.year < yMin) yMin = p.year;
|
||||||
|
if (p.year > yMax) yMax = p.year;
|
||||||
|
if (p.count > m) m = p.count;
|
||||||
|
}
|
||||||
|
return { yearMin: yMin, yearMax: yMax, max: m };
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plotW = Math.max(0, width - PADDING.left - PADDING.right);
|
||||||
|
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
||||||
|
const yearRange = Math.max(1, yearMax - yearMin);
|
||||||
|
const scaleMax = max > 0 ? max : 1;
|
||||||
|
|
||||||
|
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
|
||||||
|
const scaleY = (count: number) => PADDING.top + (1 - count / scaleMax) * plotH;
|
||||||
|
|
||||||
|
const baseline = PADDING.top + plotH;
|
||||||
|
const lineFill = points
|
||||||
|
.map((p, i) => `${i === 0 ? 'M' : 'L'}${scaleX(p.year)},${scaleY(p.count)}`)
|
||||||
|
.join(' ');
|
||||||
|
const areaFill = `${lineFill} L${scaleX(yearMax)},${baseline} L${scaleX(yearMin)},${baseline} Z`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full">
|
||||||
|
{width > 0 && (
|
||||||
|
<svg width={width} height={HEIGHT} className="block">
|
||||||
|
<path d={areaFill} className="fill-rose-400/30 dark:fill-rose-500/25" />
|
||||||
|
<path
|
||||||
|
d={lineFill}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="stroke-rose-600 dark:stroke-rose-400"
|
||||||
|
/>
|
||||||
|
{points.map((p) => (
|
||||||
|
<circle
|
||||||
|
key={p.year}
|
||||||
|
cx={scaleX(p.year)}
|
||||||
|
cy={scaleY(p.count)}
|
||||||
|
r={1.6}
|
||||||
|
className="fill-rose-700 dark:fill-rose-300"
|
||||||
|
>
|
||||||
|
<title>{`${p.year}: ${p.count.toFixed(1)}/yr`}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
<text
|
||||||
|
x={scaleX(yearMin)}
|
||||||
|
y={HEIGHT - 2}
|
||||||
|
textAnchor="start"
|
||||||
|
fontSize={9}
|
||||||
|
className="fill-warm-500 dark:fill-warm-400"
|
||||||
|
>
|
||||||
|
{yearMin}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={scaleX(yearMax)}
|
||||||
|
y={HEIGHT - 2}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize={9}
|
||||||
|
className="fill-warm-500 dark:fill-warm-400"
|
||||||
|
>
|
||||||
|
{yearMax}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -91,6 +91,8 @@ interface FiltersProps {
|
||||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||||
onTravelTimeDragEnd: (index: number) => void;
|
onTravelTimeDragEnd: (index: number) => void;
|
||||||
onTravelTimeToggleBest: (index: number) => void;
|
onTravelTimeToggleBest: (index: number) => void;
|
||||||
|
onTravelTimeToggleNoChange: (index: number) => void;
|
||||||
|
onTravelTimeToggleNoBuses: (index: number) => void;
|
||||||
aiFilterLoading: boolean;
|
aiFilterLoading: boolean;
|
||||||
aiFilterError: string | null;
|
aiFilterError: string | null;
|
||||||
aiFilterErrorType: AiFilterErrorType | null;
|
aiFilterErrorType: AiFilterErrorType | null;
|
||||||
|
|
@ -136,6 +138,8 @@ export default memo(function Filters({
|
||||||
onTravelTimeRangeChange,
|
onTravelTimeRangeChange,
|
||||||
onTravelTimeDragEnd,
|
onTravelTimeDragEnd,
|
||||||
onTravelTimeToggleBest,
|
onTravelTimeToggleBest,
|
||||||
|
onTravelTimeToggleNoChange,
|
||||||
|
onTravelTimeToggleNoBuses,
|
||||||
aiFilterLoading,
|
aiFilterLoading,
|
||||||
aiFilterError,
|
aiFilterError,
|
||||||
aiFilterErrorType,
|
aiFilterErrorType,
|
||||||
|
|
@ -662,6 +666,8 @@ export default memo(function Filters({
|
||||||
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
||||||
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
||||||
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
||||||
|
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
|
||||||
|
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddFilterPanel
|
<AddFilterPanel
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
import { Layer, Map as MapGL, Source, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
Bounds,
|
Bounds,
|
||||||
MapFlyToOptions,
|
MapFlyToOptions,
|
||||||
ActualListing,
|
ActualListing,
|
||||||
|
SchoolMetadata,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,7 +28,12 @@ import {
|
||||||
getPoiIconUrl,
|
getPoiIconUrl,
|
||||||
getMapCenterForTargetScreenPoint,
|
getMapCenterForTargetScreenPoint,
|
||||||
} from '../../lib/map-utils';
|
} from '../../lib/map-utils';
|
||||||
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts';
|
import {
|
||||||
|
MAP_MIN_ZOOM,
|
||||||
|
MAP_BOUNDS,
|
||||||
|
POI_GROUP_COLORS,
|
||||||
|
POSTCODE_ZOOM_THRESHOLD,
|
||||||
|
} from '../../lib/consts';
|
||||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||||
import MapLegend from './MapLegend';
|
import MapLegend from './MapLegend';
|
||||||
import HoverCard from './HoverCard';
|
import HoverCard from './HoverCard';
|
||||||
|
|
@ -37,12 +43,14 @@ import type { FeatureFilters } from '../../types';
|
||||||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||||
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
|
import type { OverlayId } from '../../lib/overlays';
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
data: HexagonData[];
|
data: HexagonData[];
|
||||||
postcodeData: PostcodeFeature[];
|
postcodeData: PostcodeFeature[];
|
||||||
usePostcodeView: boolean;
|
usePostcodeView: boolean;
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
|
activeOverlays?: Set<OverlayId>;
|
||||||
actualListings?: ActualListing[];
|
actualListings?: ActualListing[];
|
||||||
onViewChange: (params: ViewChangeParams) => void;
|
onViewChange: (params: ViewChangeParams) => void;
|
||||||
viewFeature: string | null;
|
viewFeature: string | null;
|
||||||
|
|
@ -81,6 +89,7 @@ interface MapProps {
|
||||||
|
|
||||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||||
|
const EMPTY_OVERLAYS = new Set<OverlayId>();
|
||||||
|
|
||||||
function formatListingPrice(price: number): string {
|
function formatListingPrice(price: number): string {
|
||||||
return `£${price.toLocaleString()}`;
|
return `£${price.toLocaleString()}`;
|
||||||
|
|
@ -224,6 +233,131 @@ function getPoiGroupColor(group: string): [number, number, number] {
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best-effort web URL from a free-text website field — GIAS stores some with
|
||||||
|
* "http://", some without, and some as bare hostnames. */
|
||||||
|
function normalizeSchoolWebsiteUrl(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||||
|
if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchoolMetadata(school: SchoolMetadata) {
|
||||||
|
// First line collects the headline classification (phase, type, religious
|
||||||
|
// character) so the popup is scannable even when most fields are absent.
|
||||||
|
const headline: string[] = [];
|
||||||
|
if (school.phase) headline.push(school.phase);
|
||||||
|
if (school.type) headline.push(school.type);
|
||||||
|
|
||||||
|
const pupilsLine =
|
||||||
|
school.pupils !== undefined && school.capacity !== undefined
|
||||||
|
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils`
|
||||||
|
: school.pupils !== undefined
|
||||||
|
? `${school.pupils.toLocaleString()} pupils`
|
||||||
|
: school.capacity !== undefined
|
||||||
|
? `Capacity ${school.capacity.toLocaleString()}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Type</dt>
|
||||||
|
<dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.age_range && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Ages</dt>
|
||||||
|
<dd className="dark:text-warm-200">{school.age_range}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.gender && school.gender !== 'Mixed' && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Gender</dt>
|
||||||
|
<dd className="dark:text-warm-200">{school.gender}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{pupilsLine && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Pupils</dt>
|
||||||
|
<dd className="dark:text-warm-200">{pupilsLine}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.fsm_percent !== undefined && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">FSM</dt>
|
||||||
|
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.sixth_form === 'Has a sixth form' && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
|
||||||
|
<dd className="dark:text-warm-200">Yes</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.religious_character &&
|
||||||
|
school.religious_character !== 'Does not apply' &&
|
||||||
|
school.religious_character !== 'None' && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Religion</dt>
|
||||||
|
<dd className="dark:text-warm-200">{school.religious_character}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.admissions_policy && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Admissions</dt>
|
||||||
|
<dd className="dark:text-warm-200">{school.admissions_policy}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.trust && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Trust</dt>
|
||||||
|
<dd className="dark:text-warm-200">{school.trust}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(school.address || school.postcode) && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Address</dt>
|
||||||
|
<dd className="dark:text-warm-200">
|
||||||
|
{[school.address, school.postcode].filter(Boolean).join(', ')}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.local_authority && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">LA</dt>
|
||||||
|
<dd className="dark:text-warm-200">{school.local_authority}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{school.head_name && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Head</dt>
|
||||||
|
<dd className="dark:text-warm-200">{school.head_name}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{websiteUrl && (
|
||||||
|
<>
|
||||||
|
<dt className="text-warm-500 dark:text-warm-400">Website</dt>
|
||||||
|
<dd className="truncate">
|
||||||
|
<a
|
||||||
|
href={websiteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="pointer-events-auto text-teal-600 hover:underline dark:text-teal-400"
|
||||||
|
>
|
||||||
|
{websiteUrl.replace(/^https?:\/\//, '')}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getRenderedViewState(map: MapRef | null): ViewState | null {
|
function getRenderedViewState(map: MapRef | null): ViewState | null {
|
||||||
if (!map) return null;
|
if (!map) return null;
|
||||||
|
|
||||||
|
|
@ -275,11 +409,132 @@ function DeckOverlay({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function overlayTileUrl(path: string): string {
|
||||||
|
return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverlayTileLayers({
|
||||||
|
activeOverlays,
|
||||||
|
zoom,
|
||||||
|
}: {
|
||||||
|
activeOverlays: Set<OverlayId>;
|
||||||
|
zoom: number;
|
||||||
|
}) {
|
||||||
|
if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null;
|
||||||
|
|
||||||
|
const showNoise = activeOverlays.has('noise');
|
||||||
|
const showCrime = activeOverlays.has('crime-hotspots');
|
||||||
|
const showTrees = activeOverlays.has('trees-outside-woodlands');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showNoise && (
|
||||||
|
<Source
|
||||||
|
id="overlay-noise-source"
|
||||||
|
type="raster"
|
||||||
|
tiles={[overlayTileUrl('noise')]}
|
||||||
|
tileSize={256}
|
||||||
|
maxzoom={14}
|
||||||
|
>
|
||||||
|
<Layer
|
||||||
|
id="overlay-noise"
|
||||||
|
type="raster"
|
||||||
|
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||||
|
paint={{
|
||||||
|
'raster-opacity': 0.68,
|
||||||
|
'raster-fade-duration': 120,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCrime && (
|
||||||
|
<Source
|
||||||
|
id="overlay-crime-source"
|
||||||
|
type="vector"
|
||||||
|
tiles={[overlayTileUrl('crime-hotspots')]}
|
||||||
|
maxzoom={15}
|
||||||
|
>
|
||||||
|
<Layer
|
||||||
|
id="overlay-crime-heatmap"
|
||||||
|
type="heatmap"
|
||||||
|
source-layer="crime_hotspots"
|
||||||
|
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||||
|
paint={
|
||||||
|
{
|
||||||
|
'heatmap-weight': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['coalesce', ['get', 'count'], ['get', 'weight'], 1],
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
|
||||||
|
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
|
||||||
|
'heatmap-opacity': 0.72,
|
||||||
|
'heatmap-color': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['heatmap-density'],
|
||||||
|
0,
|
||||||
|
'rgba(0, 0, 0, 0)',
|
||||||
|
0.2,
|
||||||
|
'rgb(253, 224, 71)',
|
||||||
|
0.45,
|
||||||
|
'rgb(249, 115, 22)',
|
||||||
|
0.75,
|
||||||
|
'rgb(220, 38, 38)',
|
||||||
|
1,
|
||||||
|
'rgb(127, 29, 29)',
|
||||||
|
],
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTrees && (
|
||||||
|
<Source
|
||||||
|
id="overlay-trees-source"
|
||||||
|
type="vector"
|
||||||
|
tiles={[overlayTileUrl('trees-outside-woodlands')]}
|
||||||
|
maxzoom={16}
|
||||||
|
>
|
||||||
|
<Layer
|
||||||
|
id="overlay-tree-polygons"
|
||||||
|
type="fill"
|
||||||
|
source-layer="trees_outside_woodlands"
|
||||||
|
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||||
|
paint={
|
||||||
|
{
|
||||||
|
'fill-color': '#1f9d55',
|
||||||
|
'fill-opacity': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['coalesce', ['get', 'area_sqm'], 0],
|
||||||
|
0,
|
||||||
|
0.28,
|
||||||
|
250,
|
||||||
|
0.62,
|
||||||
|
],
|
||||||
|
'fill-outline-color': 'rgba(15, 81, 50, 0.65)',
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(function Map({
|
export default memo(function Map({
|
||||||
data,
|
data,
|
||||||
postcodeData,
|
postcodeData,
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
pois,
|
pois,
|
||||||
|
activeOverlays = EMPTY_OVERLAYS,
|
||||||
actualListings = EMPTY_ACTUAL_LISTINGS,
|
actualListings = EMPTY_ACTUAL_LISTINGS,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
viewFeature,
|
viewFeature,
|
||||||
|
|
@ -514,6 +769,7 @@ export default memo(function Map({
|
||||||
maxBounds={maxBounds}
|
maxBounds={maxBounds}
|
||||||
>
|
>
|
||||||
<DeckOverlay layers={layers} getTooltip={null} />
|
<DeckOverlay layers={layers} getTooltip={null} />
|
||||||
|
<OverlayTileLayers activeOverlays={activeOverlays} zoom={viewState.zoom} />
|
||||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||||
</MapGL>
|
</MapGL>
|
||||||
{screenshotMode ? (
|
{screenshotMode ? (
|
||||||
|
|
@ -666,7 +922,7 @@ export default memo(function Map({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2 max-w-[280px]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src={getPoiIconUrl(
|
src={getPoiIconUrl(
|
||||||
|
|
@ -694,6 +950,7 @@ export default memo(function Map({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{popupInfo.school && renderSchoolMetadata(popupInfo.school)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,28 @@ import { useAiFilters } from '../../hooks/useAiFilters';
|
||||||
import { useUrlSync } from '../../hooks/useUrlSync';
|
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||||
import { useTutorial } from '../../hooks/useTutorial';
|
import { useTutorial } from '../../hooks/useTutorial';
|
||||||
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
||||||
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
|
import {
|
||||||
|
MAX_TRAVEL_MINUTES,
|
||||||
|
parseServerMode,
|
||||||
|
resolveTransitVariant,
|
||||||
|
travelFieldKey,
|
||||||
|
useTravelTime,
|
||||||
|
} from '../../hooks/useTravelTime';
|
||||||
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
import {
|
||||||
|
INITIAL_VIEW_STATE,
|
||||||
|
POSTCODE_SEARCH_ZOOM,
|
||||||
|
POSTCODE_ZOOM_THRESHOLD,
|
||||||
|
} from '../../lib/consts';
|
||||||
|
import type { OverlayId } from '../../lib/overlays';
|
||||||
import { useLicense } from '../../hooks/useLicense';
|
import { useLicense } from '../../hooks/useLicense';
|
||||||
import { stateToParams } from '../../lib/url-state';
|
import { stateToParams } from '../../lib/url-state';
|
||||||
import {
|
import {
|
||||||
AreaPane,
|
AreaPane,
|
||||||
Filters,
|
Filters,
|
||||||
|
OverlayPane,
|
||||||
POIPane,
|
POIPane,
|
||||||
PropertiesPane,
|
PropertiesPane,
|
||||||
UpgradeModal,
|
UpgradeModal,
|
||||||
|
|
@ -62,6 +74,7 @@ export default function MapPage({
|
||||||
initialFilters,
|
initialFilters,
|
||||||
initialViewState,
|
initialViewState,
|
||||||
initialPOICategories,
|
initialPOICategories,
|
||||||
|
initialOverlays,
|
||||||
initialTab,
|
initialTab,
|
||||||
initialLoading,
|
initialLoading,
|
||||||
theme,
|
theme,
|
||||||
|
|
@ -92,11 +105,15 @@ export default function MapPage({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPOICategories, setSelectedPOICategories] =
|
const [selectedPOICategories, setSelectedPOICategories] =
|
||||||
useState<Set<string>>(initialPOICategories);
|
useState<Set<string>>(initialPOICategories);
|
||||||
|
const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
|
||||||
|
() => new Set(initialOverlays ?? [])
|
||||||
|
);
|
||||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
||||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||||
|
const [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
|
||||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -141,6 +158,8 @@ export default function MapPage({
|
||||||
handleSetEntries,
|
handleSetEntries,
|
||||||
handleTimeRangeChange,
|
handleTimeRangeChange,
|
||||||
handleToggleBest,
|
handleToggleBest,
|
||||||
|
handleToggleNoChange,
|
||||||
|
handleToggleNoBuses,
|
||||||
} = useTravelTime(initialTravelTime);
|
} = useTravelTime(initialTravelTime);
|
||||||
|
|
||||||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||||||
|
|
@ -181,8 +200,10 @@ export default function MapPage({
|
||||||
async (query: string) => {
|
async (query: string) => {
|
||||||
const context = {
|
const context = {
|
||||||
filters,
|
filters,
|
||||||
|
// Send the resolved variant so the AI sees the actual server mode in use,
|
||||||
|
// not just the UI-side base mode. Lets the model refine no-change/no-buses.
|
||||||
travelTime: activeEntries.map((entry) => ({
|
travelTime: activeEntries.map((entry) => ({
|
||||||
mode: entry.mode,
|
mode: resolveTransitVariant(entry),
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
min: entry.timeRange?.[0],
|
min: entry.timeRange?.[0],
|
||||||
max: entry.timeRange?.[1],
|
max: entry.timeRange?.[1],
|
||||||
|
|
@ -194,17 +215,29 @@ export default function MapPage({
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
handleSetFilters(result.filters);
|
handleSetFilters(result.filters);
|
||||||
|
// 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.
|
||||||
|
// Otherwise we'd fly to a destination for a mode the user can't see.
|
||||||
|
const representable = result.travelTimeFilters
|
||||||
|
.map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
|
||||||
|
.filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
|
||||||
|
|
||||||
handleSetEntries(
|
handleSetEntries(
|
||||||
result.travelTimeFilters.map((travelTimeFilter) => ({
|
representable.map(({ tt, parsed }) => ({
|
||||||
mode: travelTimeFilter.mode,
|
mode: parsed.mode,
|
||||||
slug: travelTimeFilter.slug,
|
noChange: parsed.noChange,
|
||||||
label: travelTimeFilter.label,
|
noBuses: parsed.noBuses,
|
||||||
timeRange: [travelTimeFilter.min ?? 0, travelTimeFilter.max ?? 120] as [number, number],
|
slug: tt.slug,
|
||||||
|
label: tt.label,
|
||||||
|
timeRange: [
|
||||||
|
tt.min ?? 0,
|
||||||
|
Math.min(tt.max ?? MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES),
|
||||||
|
] as [number, number],
|
||||||
useBest: false,
|
useBest: false,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstTravelTime = result.travelTimeFilters[0];
|
const firstTravelTime = representable[0]?.tt;
|
||||||
if (!firstTravelTime?.slug) return;
|
if (!firstTravelTime?.slug) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -411,6 +444,7 @@ export default function MapPage({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||||
|
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
|
||||||
const actualListingsFilterParam = useMemo(
|
const actualListingsFilterParam = useMemo(
|
||||||
() => buildFilterString(filters, features),
|
() => buildFilterString(filters, features),
|
||||||
[filters, features]
|
[filters, features]
|
||||||
|
|
@ -430,7 +464,8 @@ export default function MapPage({
|
||||||
selectedPOICategories,
|
selectedPOICategories,
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
entries,
|
entries,
|
||||||
shareCode
|
shareCode,
|
||||||
|
activeOverlays
|
||||||
);
|
);
|
||||||
|
|
||||||
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
|
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
|
||||||
|
|
@ -485,6 +520,7 @@ export default function MapPage({
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
travelTimeEntries: entries,
|
travelTimeEntries: entries,
|
||||||
|
selectedOverlays: activeOverlays,
|
||||||
shareCode,
|
shareCode,
|
||||||
t,
|
t,
|
||||||
onExportStateChange,
|
onExportStateChange,
|
||||||
|
|
@ -502,9 +538,19 @@ export default function MapPage({
|
||||||
selectedPOICategories,
|
selectedPOICategories,
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
entries,
|
entries,
|
||||||
shareCode
|
shareCode,
|
||||||
|
activeOverlays
|
||||||
).toString(),
|
).toString(),
|
||||||
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
|
[
|
||||||
|
activeOverlays,
|
||||||
|
entries,
|
||||||
|
features,
|
||||||
|
filters,
|
||||||
|
rightPaneTab,
|
||||||
|
selectedPOICategories,
|
||||||
|
shareCode,
|
||||||
|
shareAndSaveView,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const handleSaveSearch = useCallback(
|
const handleSaveSearch = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
|
|
@ -540,6 +586,7 @@ export default function MapPage({
|
||||||
theme={theme}
|
theme={theme}
|
||||||
ogMode={ogMode}
|
ogMode={ogMode}
|
||||||
travelTimeEntries={entries}
|
travelTimeEntries={entries}
|
||||||
|
activeOverlays={activeOverlays}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -595,6 +642,17 @@ export default function MapPage({
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderOverlayPane = () => (
|
||||||
|
<Suspense fallback={<PaneFallback />}>
|
||||||
|
<OverlayPane
|
||||||
|
selectedOverlays={activeOverlays}
|
||||||
|
onOverlaysChange={setActiveOverlays}
|
||||||
|
zoomedIn={overlaysZoomedIn}
|
||||||
|
onClose={() => setOverlayPaneOpen(false)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
|
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
|
||||||
<Suspense fallback={<PaneFallback />}>
|
<Suspense fallback={<PaneFallback />}>
|
||||||
<Filters
|
<Filters
|
||||||
|
|
@ -620,6 +678,8 @@ export default function MapPage({
|
||||||
onTravelTimeRangeChange={handleTimeRangeChange}
|
onTravelTimeRangeChange={handleTimeRangeChange}
|
||||||
onTravelTimeDragEnd={handleTravelTimeDragEnd}
|
onTravelTimeDragEnd={handleTravelTimeDragEnd}
|
||||||
onTravelTimeToggleBest={handleToggleBest}
|
onTravelTimeToggleBest={handleToggleBest}
|
||||||
|
onTravelTimeToggleNoChange={handleToggleNoChange}
|
||||||
|
onTravelTimeToggleNoBuses={handleToggleNoBuses}
|
||||||
aiFilterLoading={aiFilterLoading}
|
aiFilterLoading={aiFilterLoading}
|
||||||
aiFilterError={aiFilterError}
|
aiFilterError={aiFilterError}
|
||||||
aiFilterErrorType={aiFilterErrorType}
|
aiFilterErrorType={aiFilterErrorType}
|
||||||
|
|
@ -645,7 +705,14 @@ export default function MapPage({
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTogglePoiPane = () => setPoiPaneOpen((open) => !open);
|
const handleTogglePoiPane = () => {
|
||||||
|
setOverlayPaneOpen(false);
|
||||||
|
setPoiPaneOpen((open) => !open);
|
||||||
|
};
|
||||||
|
const handleToggleOverlayPane = () => {
|
||||||
|
setPoiPaneOpen(false);
|
||||||
|
setOverlayPaneOpen((open) => !open);
|
||||||
|
};
|
||||||
const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => {
|
const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => {
|
||||||
if (tab === 'properties') {
|
if (tab === 'properties') {
|
||||||
handlePropertiesTabClick();
|
handlePropertiesTabClick();
|
||||||
|
|
@ -713,6 +780,7 @@ export default function MapPage({
|
||||||
initialLoading={initialLoading}
|
initialLoading={initialLoading}
|
||||||
mapData={mapData}
|
mapData={mapData}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
|
activeOverlays={activeOverlays}
|
||||||
mapViewFeature={mapViewFeature}
|
mapViewFeature={mapViewFeature}
|
||||||
filterRange={filterRange}
|
filterRange={filterRange}
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
|
|
@ -743,6 +811,9 @@ export default function MapPage({
|
||||||
onTogglePoiPane={handleTogglePoiPane}
|
onTogglePoiPane={handleTogglePoiPane}
|
||||||
poiButtonLabel={t('poiPane.pointsOfInterest')}
|
poiButtonLabel={t('poiPane.pointsOfInterest')}
|
||||||
poiPane={renderPOIPane()}
|
poiPane={renderPOIPane()}
|
||||||
|
overlayPaneOpen={overlayPaneOpen}
|
||||||
|
onToggleOverlayPane={handleToggleOverlayPane}
|
||||||
|
overlayPane={renderOverlayPane()}
|
||||||
filtersPane={renderFilters({ destinationDropdownPortal: false })}
|
filtersPane={renderFilters({ destinationDropdownPortal: false })}
|
||||||
mobileLegend={
|
mobileLegend={
|
||||||
<MobileMapLegend
|
<MobileMapLegend
|
||||||
|
|
@ -777,6 +848,7 @@ export default function MapPage({
|
||||||
filtersPane={renderFilters()}
|
filtersPane={renderFilters()}
|
||||||
mapData={mapData}
|
mapData={mapData}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
|
activeOverlays={activeOverlays}
|
||||||
mapViewFeature={mapViewFeature}
|
mapViewFeature={mapViewFeature}
|
||||||
filterRange={filterRange}
|
filterRange={filterRange}
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
|
|
@ -801,6 +873,9 @@ export default function MapPage({
|
||||||
poiPaneOpen={poiPaneOpen}
|
poiPaneOpen={poiPaneOpen}
|
||||||
onTogglePoiPane={handleTogglePoiPane}
|
onTogglePoiPane={handleTogglePoiPane}
|
||||||
poiPane={renderPOIPane()}
|
poiPane={renderPOIPane()}
|
||||||
|
overlayPaneOpen={overlayPaneOpen}
|
||||||
|
onToggleOverlayPane={handleToggleOverlayPane}
|
||||||
|
overlayPane={renderOverlayPane()}
|
||||||
showSelectionPane={!!selectedHexagon}
|
showSelectionPane={!!selectedHexagon}
|
||||||
rightPaneWidth={rightPaneWidth}
|
rightPaneWidth={rightPaneWidth}
|
||||||
rightPaneHandlers={rightPaneHandlers}
|
rightPaneHandlers={rightPaneHandlers}
|
||||||
|
|
|
||||||
81
frontend/src/components/map/OverlayPane.tsx
Normal file
81
frontend/src/components/map/OverlayPane.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { OVERLAYS, type OverlayId } from '../../lib/overlays';
|
||||||
|
import { PillGroup } from '../ui/PillGroup';
|
||||||
|
import { PillToggle } from '../ui/PillToggle';
|
||||||
|
import { CloseIcon } from '../ui/icons';
|
||||||
|
|
||||||
|
interface OverlayPaneProps {
|
||||||
|
selectedOverlays: Set<OverlayId>;
|
||||||
|
onOverlaysChange: (overlays: Set<OverlayId>) => void;
|
||||||
|
zoomedIn: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverlayPane({
|
||||||
|
selectedOverlays,
|
||||||
|
onOverlaysChange,
|
||||||
|
zoomedIn,
|
||||||
|
onClose,
|
||||||
|
}: OverlayPaneProps) {
|
||||||
|
const toggleOverlay = (overlay: OverlayId) => {
|
||||||
|
const next = new Set(selectedOverlays);
|
||||||
|
if (next.has(overlay)) {
|
||||||
|
next.delete(overlay);
|
||||||
|
} else {
|
||||||
|
next.add(overlay);
|
||||||
|
}
|
||||||
|
onOverlaysChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectNone = () => onOverlaysChange(new Set());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
|
||||||
|
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||||
|
Overlays
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||||
|
{selectedOverlays.size}/{OVERLAYS.length}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<CloseIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!zoomedIn && (
|
||||||
|
<div className="mt-2 rounded border border-warm-200 bg-warm-50 px-2 py-1.5 text-xs text-warm-500 dark:border-warm-700 dark:bg-navy-950 dark:text-warm-400">
|
||||||
|
Zoom in to view overlays.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||||
|
<PillGroup className="flex-wrap overflow-x-visible">
|
||||||
|
{OVERLAYS.map((overlay) => (
|
||||||
|
<PillToggle
|
||||||
|
key={overlay.id}
|
||||||
|
label={overlay.label}
|
||||||
|
active={selectedOverlays.has(overlay.id)}
|
||||||
|
onClick={() => toggleOverlay(overlay.id)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PillGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { useMemo, useState, useEffect, type MutableRefObject } from 'react';
|
import { useMemo, useState, useEffect, type MutableRefObject } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Property } from '../../types';
|
import { Property } from '../../types';
|
||||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
import {
|
||||||
|
formatDuration,
|
||||||
|
formatAge,
|
||||||
|
formatNumber,
|
||||||
|
formatTransactionDate,
|
||||||
|
formatYearMonth,
|
||||||
|
} from '../../lib/format';
|
||||||
import { getNum } from '../../lib/property-fields';
|
import { getNum } from '../../lib/property-fields';
|
||||||
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
|
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
|
||||||
import InfoPopup from '../ui/InfoPopup';
|
import InfoPopup from '../ui/InfoPopup';
|
||||||
|
|
@ -183,6 +189,11 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
{t('propertyCard.exCouncilBadge')}
|
{t('propertyCard.exCouncilBadge')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{property.listed_building === 'Yes' && (
|
||||||
|
<span className="text-xs bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full px-1.5 py-0.5 font-medium leading-none">
|
||||||
|
{ts('Listed building')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -239,6 +250,14 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
{formatDuration(property.duration)}
|
{formatDuration(property.duration)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{property.within_conservation_area && (
|
||||||
|
<div>
|
||||||
|
<span className="text-warm-500 dark:text-warm-400">
|
||||||
|
{t('propertyCard.withinConservationArea')}
|
||||||
|
</span>{' '}
|
||||||
|
{ts(property.within_conservation_area)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{floorArea !== undefined && (
|
{floorArea !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.floorArea')}</span>{' '}
|
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.floorArea')}</span>{' '}
|
||||||
|
|
@ -273,24 +292,137 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
<PropertyTimeline property={property} />
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
|
|
||||||
{t('propertyCard.renovations')}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{property.renovation_history.map((reno, idx) => (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className="inline-flex items-center gap-1 text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
|
||||||
>
|
|
||||||
{reno.event}
|
|
||||||
<span className="text-warm-500 dark:text-warm-400">{reno.year}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimelineEvent =
|
||||||
|
| { kind: 'sale'; year: number; month: number; price: number; sortKey: number }
|
||||||
|
| { kind: 'reno'; year: number; event: string; sortKey: number }
|
||||||
|
| { kind: 'built'; year: number; approximate: boolean; sortKey: number };
|
||||||
|
|
||||||
|
function buildTimelineEvents(property: Property): TimelineEvent[] {
|
||||||
|
const events: TimelineEvent[] = [];
|
||||||
|
|
||||||
|
// Skip the most recent sale: it's already shown in the card headline.
|
||||||
|
// historical_prices is sorted oldest→newest by the pipeline.
|
||||||
|
const sales = property.historical_prices ?? [];
|
||||||
|
const olderSales = sales.length > 0 ? sales.slice(0, -1) : [];
|
||||||
|
for (const sale of olderSales) {
|
||||||
|
events.push({
|
||||||
|
kind: 'sale',
|
||||||
|
year: sale.year,
|
||||||
|
month: sale.month,
|
||||||
|
price: sale.price,
|
||||||
|
sortKey: sale.year + (Math.max(sale.month, 1) - 1) / 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const reno of property.renovation_history ?? []) {
|
||||||
|
events.push({
|
||||||
|
kind: 'reno',
|
||||||
|
year: reno.year,
|
||||||
|
event: reno.event,
|
||||||
|
// Mid-year so renos sort between Jan and Dec sales of the same year.
|
||||||
|
sortKey: reno.year + 0.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtYear = getNum(property, 'Construction year');
|
||||||
|
const approximate = property.is_construction_date_approximate ?? true;
|
||||||
|
if (builtYear !== undefined && Number.isFinite(builtYear) && builtYear > 0) {
|
||||||
|
const builtYearRounded = Math.round(builtYear);
|
||||||
|
// New-builds (exact date from price-paid) duplicate the first sale's year.
|
||||||
|
// Suppress the "Built" marker in that case since the sale carries the info.
|
||||||
|
const newBuildDuplicate = !approximate && sales.some((sale) => sale.year === builtYearRounded);
|
||||||
|
if (!newBuildDuplicate) {
|
||||||
|
events.push({
|
||||||
|
kind: 'built',
|
||||||
|
year: builtYearRounded,
|
||||||
|
approximate,
|
||||||
|
sortKey: builtYear,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.sort((a, b) => b.sortKey - a.sortKey);
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyTimeline({ property }: { property: Property }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const events = useMemo(() => buildTimelineEvents(property), [property]);
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1.5">
|
||||||
|
{t('propertyCard.historyTitle')}
|
||||||
|
</div>
|
||||||
|
<ol className="relative ml-1.5 border-l border-warm-200 dark:border-warm-700">
|
||||||
|
{events.map((event, idx) => (
|
||||||
|
<li key={idx} className="relative pl-3 pb-1.5 last:pb-0">
|
||||||
|
<TimelineMarker kind={event.kind} />
|
||||||
|
<div className="text-sm leading-tight">
|
||||||
|
{event.kind === 'sale' && (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold text-teal-700 dark:text-teal-400">
|
||||||
|
£{formatNumber(event.price)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||||
|
{formatYearMonth(event.year, event.month)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{event.kind === 'reno' && (
|
||||||
|
<>
|
||||||
|
<span className="text-warm-700 dark:text-warm-200">{event.event}</span>
|
||||||
|
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||||
|
{event.year}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{event.kind === 'built' && (
|
||||||
|
<>
|
||||||
|
<span className="text-warm-700 dark:text-warm-200">
|
||||||
|
{t('propertyCard.historyBuilt')}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||||
|
{event.approximate ? `~${event.year}` : event.year}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineMarker({ kind }: { kind: TimelineEvent['kind'] }) {
|
||||||
|
const base = 'absolute -left-[5px] top-1 w-2 h-2 rounded-full ring-2';
|
||||||
|
if (kind === 'sale') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`${base} bg-teal-500 dark:bg-teal-400 ring-warm-50 dark:ring-warm-900`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === 'reno') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`${base} bg-warm-400 dark:bg-warm-500 ring-warm-50 dark:ring-warm-900`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`${base} bg-warm-50 border border-warm-400 dark:bg-warm-900 dark:border-warm-500 ring-warm-50 dark:ring-warm-900`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||||
import { formatFilterValue, formatNumber } from '../../lib/format';
|
import { formatFilterValue, formatNumber } from '../../lib/format';
|
||||||
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
||||||
import { MODE_ICONS, useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
|
import {
|
||||||
|
MAX_TRAVEL_MINUTES,
|
||||||
|
MODE_ICONS,
|
||||||
|
useTranslatedModes,
|
||||||
|
type TransportMode,
|
||||||
|
} from '../../hooks/useTravelTime';
|
||||||
|
|
||||||
interface TravelTimeCardProps {
|
interface TravelTimeCardProps {
|
||||||
mode: TransportMode;
|
mode: TransportMode;
|
||||||
|
|
@ -19,6 +24,8 @@ interface TravelTimeCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
timeRange: [number, number] | null;
|
timeRange: [number, number] | null;
|
||||||
useBest: boolean;
|
useBest: boolean;
|
||||||
|
noChange: boolean;
|
||||||
|
noBuses: boolean;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
dragValue: [number, number] | null;
|
dragValue: [number, number] | null;
|
||||||
|
|
@ -29,6 +36,8 @@ interface TravelTimeCardProps {
|
||||||
onDragChange: (value: [number, number]) => void;
|
onDragChange: (value: [number, number]) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
onToggleBest: () => void;
|
onToggleBest: () => void;
|
||||||
|
onToggleNoChange: () => void;
|
||||||
|
onToggleNoBuses: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
filterImpact?: number;
|
filterImpact?: number;
|
||||||
destinationDropdownPortal?: boolean;
|
destinationDropdownPortal?: boolean;
|
||||||
|
|
@ -40,6 +49,8 @@ export function TravelTimeCard({
|
||||||
label,
|
label,
|
||||||
timeRange,
|
timeRange,
|
||||||
useBest,
|
useBest,
|
||||||
|
noChange,
|
||||||
|
noBuses,
|
||||||
isPinned,
|
isPinned,
|
||||||
isActive,
|
isActive,
|
||||||
dragValue,
|
dragValue,
|
||||||
|
|
@ -50,6 +61,8 @@ export function TravelTimeCard({
|
||||||
onDragChange,
|
onDragChange,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onToggleBest,
|
onToggleBest,
|
||||||
|
onToggleNoChange,
|
||||||
|
onToggleNoBuses,
|
||||||
onRemove,
|
onRemove,
|
||||||
filterImpact,
|
filterImpact,
|
||||||
destinationDropdownPortal = true,
|
destinationDropdownPortal = true,
|
||||||
|
|
@ -59,6 +72,8 @@ export function TravelTimeCard({
|
||||||
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
const [showBestInfo, setShowBestInfo] = useState(false);
|
const [showBestInfo, setShowBestInfo] = useState(false);
|
||||||
|
const [showNoChangeInfo, setShowNoChangeInfo] = useState(false);
|
||||||
|
const [showNoBusesInfo, setShowNoBusesInfo] = useState(false);
|
||||||
|
|
||||||
const handleDestinationSelect = useCallback(
|
const handleDestinationSelect = useCallback(
|
||||||
(selectedSlug: string, selectedLabel: string, lat: number, lon: number) => {
|
(selectedSlug: string, selectedLabel: string, lat: number, lon: number) => {
|
||||||
|
|
@ -68,7 +83,7 @@ export function TravelTimeCard({
|
||||||
);
|
);
|
||||||
|
|
||||||
const sliderMin = 0;
|
const sliderMin = 0;
|
||||||
const sliderMax = 120;
|
const sliderMax = MAX_TRAVEL_MINUTES;
|
||||||
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
||||||
|
|
||||||
const ModeIcon = MODE_ICONS[mode];
|
const ModeIcon = MODE_ICONS[mode];
|
||||||
|
|
@ -116,8 +131,9 @@ export function TravelTimeCard({
|
||||||
portal={destinationDropdownPortal}
|
portal={destinationDropdownPortal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
{/* Transit-only toggles — shown when destination is set */}
|
||||||
{slug && mode === 'transit' && (
|
{slug && mode === 'transit' && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<PillToggle
|
<PillToggle
|
||||||
label={t('travel.bestCase')}
|
label={t('travel.bestCase')}
|
||||||
|
|
@ -129,6 +145,29 @@ export function TravelTimeCard({
|
||||||
<InfoIcon className="w-3 h-3" />
|
<InfoIcon className="w-3 h-3" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<PillToggle
|
||||||
|
label={t('travel.noChange')}
|
||||||
|
active={noChange}
|
||||||
|
onClick={onToggleNoChange}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<IconButton onClick={() => setShowNoChangeInfo(true)} title={t('travel.noChangeTitle')}>
|
||||||
|
<InfoIcon className="w-3 h-3" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<PillToggle
|
||||||
|
label={t('travel.noBuses')}
|
||||||
|
active={noBuses}
|
||||||
|
onClick={onToggleNoBuses}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<IconButton onClick={() => setShowNoBusesInfo(true)} title={t('travel.noBusesTitle')}>
|
||||||
|
<InfoIcon className="w-3 h-3" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />}
|
{showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />}
|
||||||
|
|
@ -141,6 +180,22 @@ export function TravelTimeCard({
|
||||||
</InfoPopup>
|
</InfoPopup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showNoChangeInfo && (
|
||||||
|
<InfoPopup title={t('travel.noChangeTitle')} onClose={() => setShowNoChangeInfo(false)}>
|
||||||
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||||
|
<Trans i18nKey="travel.noChangeDesc" components={{ strong: <strong /> }} />
|
||||||
|
</p>
|
||||||
|
</InfoPopup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNoBusesInfo && (
|
||||||
|
<InfoPopup title={t('travel.noBusesTitle')} onClose={() => setShowNoBusesInfo(false)}>
|
||||||
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||||
|
<Trans i18nKey="travel.noBusesDesc" components={{ strong: <strong /> }} />
|
||||||
|
</p>
|
||||||
|
</InfoPopup>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Time range slider — only show when we have data */}
|
{/* Time range slider — only show when we have data */}
|
||||||
{slug && (
|
{slug && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ interface ActiveFilterListProps {
|
||||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||||
onTravelTimeDragEnd: (index: number) => void;
|
onTravelTimeDragEnd: (index: number) => void;
|
||||||
onTravelTimeToggleBest: (index: number) => void;
|
onTravelTimeToggleBest: (index: number) => void;
|
||||||
|
onTravelTimeToggleNoChange: (index: number) => void;
|
||||||
|
onTravelTimeToggleNoBuses: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveFilterList({
|
export function ActiveFilterList({
|
||||||
|
|
@ -85,6 +87,8 @@ export function ActiveFilterList({
|
||||||
onTravelTimeRangeChange,
|
onTravelTimeRangeChange,
|
||||||
onTravelTimeDragEnd,
|
onTravelTimeDragEnd,
|
||||||
onTravelTimeToggleBest,
|
onTravelTimeToggleBest,
|
||||||
|
onTravelTimeToggleNoChange,
|
||||||
|
onTravelTimeToggleNoBuses,
|
||||||
}: ActiveFilterListProps) {
|
}: ActiveFilterListProps) {
|
||||||
const travelCards = (
|
const travelCards = (
|
||||||
<TravelTimeFilterCards
|
<TravelTimeFilterCards
|
||||||
|
|
@ -100,6 +104,8 @@ export function ActiveFilterList({
|
||||||
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
||||||
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
||||||
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
||||||
|
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
|
||||||
|
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragChange={onDragChange}
|
onDragChange={onDragChange}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ interface ActiveFiltersPanelProps {
|
||||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||||
onTravelTimeDragEnd: (index: number) => void;
|
onTravelTimeDragEnd: (index: number) => void;
|
||||||
onTravelTimeToggleBest: (index: number) => void;
|
onTravelTimeToggleBest: (index: number) => void;
|
||||||
|
onTravelTimeToggleNoChange: (index: number) => void;
|
||||||
|
onTravelTimeToggleNoBuses: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveFiltersPanel({
|
export function ActiveFiltersPanel({
|
||||||
|
|
@ -99,6 +101,8 @@ export function ActiveFiltersPanel({
|
||||||
onTravelTimeRangeChange,
|
onTravelTimeRangeChange,
|
||||||
onTravelTimeDragEnd,
|
onTravelTimeDragEnd,
|
||||||
onTravelTimeToggleBest,
|
onTravelTimeToggleBest,
|
||||||
|
onTravelTimeToggleNoChange,
|
||||||
|
onTravelTimeToggleNoBuses,
|
||||||
}: ActiveFiltersPanelProps) {
|
}: ActiveFiltersPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -201,6 +205,8 @@ export function ActiveFiltersPanel({
|
||||||
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
onTravelTimeRangeChange={onTravelTimeRangeChange}
|
||||||
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
onTravelTimeDragEnd={onTravelTimeDragEnd}
|
||||||
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
onTravelTimeToggleBest={onTravelTimeToggleBest}
|
||||||
|
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
|
||||||
|
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ interface TravelTimeFilterCardsProps {
|
||||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||||
onTravelTimeDragEnd: (index: number) => void;
|
onTravelTimeDragEnd: (index: number) => void;
|
||||||
onTravelTimeToggleBest: (index: number) => void;
|
onTravelTimeToggleBest: (index: number) => void;
|
||||||
|
onTravelTimeToggleNoChange: (index: number) => void;
|
||||||
|
onTravelTimeToggleNoBuses: (index: number) => void;
|
||||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||||
onDragChange: (value: [number, number]) => void;
|
onDragChange: (value: [number, number]) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -37,6 +39,8 @@ export function TravelTimeFilterCards({
|
||||||
onTravelTimeRangeChange,
|
onTravelTimeRangeChange,
|
||||||
onTravelTimeDragEnd,
|
onTravelTimeDragEnd,
|
||||||
onTravelTimeToggleBest,
|
onTravelTimeToggleBest,
|
||||||
|
onTravelTimeToggleNoChange,
|
||||||
|
onTravelTimeToggleNoBuses,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragChange,
|
onDragChange,
|
||||||
}: TravelTimeFilterCardsProps) {
|
}: TravelTimeFilterCardsProps) {
|
||||||
|
|
@ -52,6 +56,8 @@ export function TravelTimeFilterCards({
|
||||||
label={entry.label}
|
label={entry.label}
|
||||||
timeRange={entry.timeRange}
|
timeRange={entry.timeRange}
|
||||||
useBest={entry.useBest}
|
useBest={entry.useBest}
|
||||||
|
noChange={entry.noChange ?? false}
|
||||||
|
noBuses={entry.noBuses ?? false}
|
||||||
isPinned={pinnedFeature === fieldKey}
|
isPinned={pinnedFeature === fieldKey}
|
||||||
isActive={activeFeature === fieldKey}
|
isActive={activeFeature === fieldKey}
|
||||||
dragValue={activeFeature === fieldKey ? dragValue : null}
|
dragValue={activeFeature === fieldKey ? dragValue : null}
|
||||||
|
|
@ -64,6 +70,8 @@ export function TravelTimeFilterCards({
|
||||||
onDragChange={onDragChange}
|
onDragChange={onDragChange}
|
||||||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
|
onToggleNoChange={() => onTravelTimeToggleNoChange(index)}
|
||||||
|
onToggleNoBuses={() => onTravelTimeToggleNoBuses(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
filterImpact={filterImpacts?.[fieldKey]}
|
filterImpact={filterImpacts?.[fieldKey]}
|
||||||
destinationDropdownPortal={destinationDropdownPortal}
|
destinationDropdownPortal={destinationDropdownPortal}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ import type { useMapData } from '../../../hooks/useMapData';
|
||||||
import type { useTutorial } from '../../../hooks/useTutorial';
|
import type { useTutorial } from '../../../hooks/useTutorial';
|
||||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
|
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
|
||||||
|
import type { OverlayId } from '../../../lib/overlays';
|
||||||
import type { SearchedLocation } from '../LocationSearch';
|
import type { SearchedLocation } from '../LocationSearch';
|
||||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||||
|
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||||
|
|
@ -35,6 +37,7 @@ interface DesktopMapPageProps {
|
||||||
filtersPane: ReactNode;
|
filtersPane: ReactNode;
|
||||||
mapData: MapData;
|
mapData: MapData;
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
|
activeOverlays: Set<OverlayId>;
|
||||||
mapViewFeature: string | null;
|
mapViewFeature: string | null;
|
||||||
filterRange: [number, number] | null;
|
filterRange: [number, number] | null;
|
||||||
viewSource: 'drag' | 'eye' | null;
|
viewSource: 'drag' | 'eye' | null;
|
||||||
|
|
@ -59,6 +62,9 @@ interface DesktopMapPageProps {
|
||||||
poiPaneOpen: boolean;
|
poiPaneOpen: boolean;
|
||||||
onTogglePoiPane: () => void;
|
onTogglePoiPane: () => void;
|
||||||
poiPane: ReactNode;
|
poiPane: ReactNode;
|
||||||
|
overlayPaneOpen: boolean;
|
||||||
|
onToggleOverlayPane: () => void;
|
||||||
|
overlayPane: ReactNode;
|
||||||
showSelectionPane: boolean;
|
showSelectionPane: boolean;
|
||||||
rightPaneWidth: number;
|
rightPaneWidth: number;
|
||||||
rightPaneHandlers: PaneResizeHandlers;
|
rightPaneHandlers: PaneResizeHandlers;
|
||||||
|
|
@ -81,6 +87,7 @@ export function DesktopMapPage({
|
||||||
filtersPane,
|
filtersPane,
|
||||||
mapData,
|
mapData,
|
||||||
pois,
|
pois,
|
||||||
|
activeOverlays,
|
||||||
mapViewFeature,
|
mapViewFeature,
|
||||||
filterRange,
|
filterRange,
|
||||||
viewSource,
|
viewSource,
|
||||||
|
|
@ -105,6 +112,9 @@ export function DesktopMapPage({
|
||||||
poiPaneOpen,
|
poiPaneOpen,
|
||||||
onTogglePoiPane,
|
onTogglePoiPane,
|
||||||
poiPane,
|
poiPane,
|
||||||
|
overlayPaneOpen,
|
||||||
|
onToggleOverlayPane,
|
||||||
|
overlayPane,
|
||||||
showSelectionPane,
|
showSelectionPane,
|
||||||
rightPaneWidth,
|
rightPaneWidth,
|
||||||
rightPaneHandlers,
|
rightPaneHandlers,
|
||||||
|
|
@ -168,6 +178,7 @@ export function DesktopMapPage({
|
||||||
postcodeData={mapData.postcodeData}
|
postcodeData={mapData.postcodeData}
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
|
activeOverlays={activeOverlays}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={mapViewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
|
|
@ -197,16 +208,30 @@ export function DesktopMapPage({
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<div className="absolute bottom-4 right-4 z-10 flex flex-col items-end gap-2">
|
||||||
|
<button
|
||||||
|
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'}`}
|
||||||
|
>
|
||||||
|
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||||
|
<span className="text-sm font-medium">Overlays</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
data-tutorial="poi-button"
|
data-tutorial="poi-button"
|
||||||
onClick={onTogglePoiPane}
|
onClick={onTogglePoiPane}
|
||||||
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 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 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||||
>
|
>
|
||||||
<MapPinIcon className="w-5 h-5" />
|
<MapPinIcon className="h-5 w-5" />
|
||||||
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
|
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
{overlayPaneOpen && (
|
||||||
|
<div className="absolute bottom-28 right-4 z-10 flex h-[220px] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||||
|
{overlayPane}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{poiPaneOpen && (
|
{poiPaneOpen && (
|
||||||
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
<div className="absolute bottom-28 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||||
{poiPane}
|
{poiPane}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ import type {
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import type { useMapData } from '../../../hooks/useMapData';
|
import type { useMapData } from '../../../hooks/useMapData';
|
||||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
|
import type { OverlayId } from '../../../lib/overlays';
|
||||||
import type { SearchedLocation } from '../LocationSearch';
|
import type { SearchedLocation } from '../LocationSearch';
|
||||||
import MobileBottomSheet from '../MobileBottomSheet';
|
import MobileBottomSheet from '../MobileBottomSheet';
|
||||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||||
|
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||||
import type { MapFlyTo } from './types';
|
import type { MapFlyTo } from './types';
|
||||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||||
|
|
@ -26,6 +28,7 @@ interface MobileMapPageProps {
|
||||||
initialLoading: boolean;
|
initialLoading: boolean;
|
||||||
mapData: MapData;
|
mapData: MapData;
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
|
activeOverlays: Set<OverlayId>;
|
||||||
mapViewFeature: string | null;
|
mapViewFeature: string | null;
|
||||||
filterRange: [number, number] | null;
|
filterRange: [number, number] | null;
|
||||||
viewSource: 'drag' | 'eye' | null;
|
viewSource: 'drag' | 'eye' | null;
|
||||||
|
|
@ -56,6 +59,9 @@ interface MobileMapPageProps {
|
||||||
onTogglePoiPane: () => void;
|
onTogglePoiPane: () => void;
|
||||||
poiButtonLabel: string;
|
poiButtonLabel: string;
|
||||||
poiPane: ReactNode;
|
poiPane: ReactNode;
|
||||||
|
overlayPaneOpen: boolean;
|
||||||
|
onToggleOverlayPane: () => void;
|
||||||
|
overlayPane: ReactNode;
|
||||||
filtersPane: ReactNode;
|
filtersPane: ReactNode;
|
||||||
mobileLegend: ReactNode;
|
mobileLegend: ReactNode;
|
||||||
renderAreaPane: () => ReactNode;
|
renderAreaPane: () => ReactNode;
|
||||||
|
|
@ -69,6 +75,7 @@ export function MobileMapPage({
|
||||||
initialLoading,
|
initialLoading,
|
||||||
mapData,
|
mapData,
|
||||||
pois,
|
pois,
|
||||||
|
activeOverlays,
|
||||||
mapViewFeature,
|
mapViewFeature,
|
||||||
filterRange,
|
filterRange,
|
||||||
viewSource,
|
viewSource,
|
||||||
|
|
@ -99,6 +106,9 @@ export function MobileMapPage({
|
||||||
onTogglePoiPane,
|
onTogglePoiPane,
|
||||||
poiButtonLabel,
|
poiButtonLabel,
|
||||||
poiPane,
|
poiPane,
|
||||||
|
overlayPaneOpen,
|
||||||
|
onToggleOverlayPane,
|
||||||
|
overlayPane,
|
||||||
filtersPane,
|
filtersPane,
|
||||||
mobileLegend,
|
mobileLegend,
|
||||||
renderAreaPane,
|
renderAreaPane,
|
||||||
|
|
@ -119,6 +129,7 @@ export function MobileMapPage({
|
||||||
postcodeData={mapData.postcodeData}
|
postcodeData={mapData.postcodeData}
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
|
activeOverlays={activeOverlays}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={mapViewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
|
|
@ -150,16 +161,31 @@ export function MobileMapPage({
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute right-3 top-3 z-20 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
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'}`}
|
||||||
|
aria-label="Overlays"
|
||||||
|
>
|
||||||
|
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onTogglePoiPane}
|
onClick={onTogglePoiPane}
|
||||||
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${poiPaneOpen ? '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={poiButtonLabel}
|
aria-label={poiButtonLabel}
|
||||||
>
|
>
|
||||||
<MapPinIcon className="w-5 h-5" />
|
<MapPinIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overlayPaneOpen && (
|
||||||
|
<div className="absolute top-24 right-3 left-3 z-20 flex h-[220px] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||||
|
{overlayPane}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{poiPaneOpen && (
|
{poiPaneOpen && (
|
||||||
<div className="absolute top-14 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
<div className="absolute top-24 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||||
{poiPane}
|
{poiPane}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Suspense } from 'react';
|
||||||
import type { FeatureMeta, ViewState } from '../../../types';
|
import type { FeatureMeta, ViewState } from '../../../types';
|
||||||
import type { useMapData } from '../../../hooks/useMapData';
|
import type { useMapData } from '../../../hooks/useMapData';
|
||||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
|
import type { OverlayId } from '../../../lib/overlays';
|
||||||
import { MapFallback } from './Fallbacks';
|
import { MapFallback } from './Fallbacks';
|
||||||
import { Map } from './lazyComponents';
|
import { Map } from './lazyComponents';
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ interface ScreenshotMapPageProps {
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
ogMode?: boolean;
|
ogMode?: boolean;
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
|
activeOverlays: Set<OverlayId>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScreenshotMapPage({
|
export function ScreenshotMapPage({
|
||||||
|
|
@ -30,6 +32,7 @@ export function ScreenshotMapPage({
|
||||||
theme,
|
theme,
|
||||||
ogMode,
|
ogMode,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
|
activeOverlays,
|
||||||
}: ScreenshotMapPageProps) {
|
}: ScreenshotMapPageProps) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
|
|
@ -39,6 +42,7 @@ export function ScreenshotMapPage({
|
||||||
postcodeData={mapData.postcodeData}
|
postcodeData={mapData.postcodeData}
|
||||||
usePostcodeView={mapData.usePostcodeView}
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
pois={[]}
|
pois={[]}
|
||||||
|
activeOverlays={activeOverlays}
|
||||||
onViewChange={mapData.handleViewChange}
|
onViewChange={mapData.handleViewChange}
|
||||||
viewFeature={mapViewFeature}
|
viewFeature={mapViewFeature}
|
||||||
colorRange={mapData.colorRange}
|
colorRange={mapData.colorRange}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { cellToLatLng } from 'h3-js';
|
||||||
import type { FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../../types';
|
import type { FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../../types';
|
||||||
import type { HexagonLocation } from '../../../lib/external-search';
|
import type { HexagonLocation } from '../../../lib/external-search';
|
||||||
import type { useMapData } from '../../../hooks/useMapData';
|
import type { useMapData } from '../../../hooks/useMapData';
|
||||||
import 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 { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
|
import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
|
||||||
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
|
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
|
||||||
|
|
@ -33,7 +33,9 @@ export function getMapPageBackendFeatureName(featureName: string): string {
|
||||||
export function useJourneyDestination(entries: TravelTimeEntry[]) {
|
export function useJourneyDestination(entries: TravelTimeEntry[]) {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const entry = entries.find((item) => item.mode === 'transit' && item.slug);
|
const entry = entries.find((item) => item.mode === 'transit' && item.slug);
|
||||||
return entry ? { mode: entry.mode, slug: entry.slug } : null;
|
// The server needs the resolved variant (e.g. transit-no-change-no-bus)
|
||||||
|
// to look up the correct parquet directory, not the UI-side base mode.
|
||||||
|
return entry ? { mode: resolveTransitVariant(entry), slug: entry.slug } : null;
|
||||||
}, [entries]);
|
}, [entries]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { lazy } from 'react';
|
||||||
export const Map = lazy(() => import('../Map'));
|
export const Map = lazy(() => import('../Map'));
|
||||||
export const Filters = lazy(() => import('../Filters'));
|
export const Filters = lazy(() => import('../Filters'));
|
||||||
export const POIPane = lazy(() => import('../POIPane'));
|
export const POIPane = lazy(() => import('../POIPane'));
|
||||||
|
export const OverlayPane = lazy(() => import('../OverlayPane'));
|
||||||
export const AreaPane = lazy(() => import('../AreaPane'));
|
export const AreaPane = lazy(() => import('../AreaPane'));
|
||||||
export const PropertiesPane = lazy(() =>
|
export const PropertiesPane = lazy(() =>
|
||||||
import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
|
import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
ViewState,
|
ViewState,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
||||||
|
import type { OverlayId } from '../../../lib/overlays';
|
||||||
import type { Page } from '../../ui/Header';
|
import type { Page } from '../../ui/Header';
|
||||||
import type { PointerEvent } from 'react';
|
import type { PointerEvent } from 'react';
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ export interface MapPageProps {
|
||||||
initialFilters: FeatureFilters;
|
initialFilters: FeatureFilters;
|
||||||
initialViewState: ViewState;
|
initialViewState: ViewState;
|
||||||
initialPOICategories: Set<string>;
|
initialPOICategories: Set<string>;
|
||||||
|
initialOverlays?: Set<OverlayId>;
|
||||||
initialTab: 'properties' | 'area';
|
initialTab: 'properties' | 'area';
|
||||||
initialLoading: boolean;
|
initialLoading: boolean;
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
|
||||||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
|
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
|
||||||
import { trackEvent } from '../../../lib/analytics';
|
import { trackEvent } from '../../../lib/analytics';
|
||||||
import type { ExportNotice, ExportState } from './types';
|
import type { ExportNotice, ExportState } from './types';
|
||||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
import { buildTravelParam, dedupeTravelTimeEntries } from '../../../lib/travel-params';
|
import { buildTravelParam, dedupeTravelTimeEntries } from '../../../lib/travel-params';
|
||||||
|
import type { OverlayId } from '../../../lib/overlays';
|
||||||
|
|
||||||
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
|
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
|
||||||
const EXPORT_TIMEOUT_MS = 150_000;
|
const EXPORT_TIMEOUT_MS = 150_000;
|
||||||
|
|
@ -70,7 +71,8 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
|
||||||
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
|
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
|
||||||
for (const entry of dedupeTravelTimeEntries(entries)) {
|
for (const entry of dedupeTravelTimeEntries(entries)) {
|
||||||
if (!entry.slug) continue;
|
if (!entry.slug) continue;
|
||||||
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
const serverMode = resolveTransitVariant(entry);
|
||||||
|
let value = `${serverMode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||||
if (entry.useBest) value += ':b';
|
if (entry.useBest) value += ':b';
|
||||||
if (entry.timeRange) {
|
if (entry.timeRange) {
|
||||||
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||||
|
|
@ -84,6 +86,7 @@ interface UseExportControllerOptions {
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
travelTimeEntries: TravelTimeEntry[];
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
|
selectedOverlays: Set<OverlayId>;
|
||||||
shareCode?: string;
|
shareCode?: string;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
onExportStateChange?: (state: ExportState) => void;
|
onExportStateChange?: (state: ExportState) => void;
|
||||||
|
|
@ -94,6 +97,7 @@ export function useExportController({
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
|
selectedOverlays,
|
||||||
shareCode,
|
shareCode,
|
||||||
t,
|
t,
|
||||||
onExportStateChange,
|
onExportStateChange,
|
||||||
|
|
@ -147,6 +151,9 @@ export function useExportController({
|
||||||
const travelParam = buildTravelParam(travelTimeEntries);
|
const travelParam = buildTravelParam(travelTimeEntries);
|
||||||
if (travelParam) params.set('travel', travelParam);
|
if (travelParam) params.set('travel', travelParam);
|
||||||
appendTravelStateParams(params, travelTimeEntries);
|
appendTravelStateParams(params, travelTimeEntries);
|
||||||
|
for (const overlay of selectedOverlays) {
|
||||||
|
params.append('overlay', overlay);
|
||||||
|
}
|
||||||
if (shareCode) params.set('share', shareCode);
|
if (shareCode) params.set('share', shareCode);
|
||||||
const url = apiUrl('export', params);
|
const url = apiUrl('export', params);
|
||||||
|
|
||||||
|
|
@ -189,6 +196,7 @@ export function useExportController({
|
||||||
exporting,
|
exporting,
|
||||||
features,
|
features,
|
||||||
filters,
|
filters,
|
||||||
|
selectedOverlays,
|
||||||
shareCode,
|
shareCode,
|
||||||
showExportNotice,
|
showExportNotice,
|
||||||
t,
|
t,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export function PillToggle({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-pressed={indeterminate ? 'mixed' : active}
|
||||||
className={`${sizeClasses} ${colorClasses} inline-flex max-w-full shrink-0 items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`}
|
className={`${sizeClasses} ${colorClasses} inline-flex max-w-full shrink-0 items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import type { FeatureFilters } from '../types';
|
import type { FeatureFilters } from '../types';
|
||||||
import type { TransportMode } from './useTravelTime';
|
|
||||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
export interface AiTravelTimeFilter {
|
export interface AiTravelTimeFilter {
|
||||||
mode: TransportMode;
|
/**
|
||||||
|
* Server-side mode string. May be a base mode (car|bicycle|walking|transit)
|
||||||
|
* or a transit variant (transit-no-bus, transit-no-change, …). Callers must
|
||||||
|
* normalise via parseServerMode before constructing a TravelTimeEntry.
|
||||||
|
*/
|
||||||
|
mode: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
label: string;
|
label: string;
|
||||||
min?: number;
|
min?: number;
|
||||||
|
|
@ -132,7 +136,7 @@ export function useAiFilters(): UseAiFiltersResult {
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const travelTimeFilters: AiTravelTimeFilter[] = (json.travel_time_filters || []).map(
|
const travelTimeFilters: AiTravelTimeFilter[] = (json.travel_time_filters || []).map(
|
||||||
(tt: { mode: string; slug: string; label: string; min?: number; max?: number }) => ({
|
(tt: { mode: string; slug: string; label: string; min?: number; max?: number }) => ({
|
||||||
mode: tt.mode as TransportMode,
|
mode: tt.mode,
|
||||||
slug: tt.slug,
|
slug: tt.slug,
|
||||||
label: tt.label,
|
label: tt.label,
|
||||||
min: tt.min,
|
min: tt.min,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ describe('usePoiLayers', () => {
|
||||||
width: 96,
|
width: 96,
|
||||||
height: 48,
|
height: 48,
|
||||||
});
|
});
|
||||||
expect(getSize(waitrose)).toBe(24);
|
expect(getSize(waitrose)).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers POI fascia icon categories for map marker icons', () => {
|
it('prefers POI fascia icon categories for map marker icons', () => {
|
||||||
|
|
@ -98,7 +98,7 @@ describe('usePoiLayers', () => {
|
||||||
width: 96,
|
width: 96,
|
||||||
height: 48,
|
height: 48,
|
||||||
});
|
});
|
||||||
expect(getSize(foodWarehouse)).toBe(24);
|
expect(getSize(foodWarehouse)).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps generic emoji POIs at the compact marker size', () => {
|
it('keeps generic emoji POIs at the compact marker size', () => {
|
||||||
|
|
@ -114,7 +114,7 @@ describe('usePoiLayers', () => {
|
||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
});
|
});
|
||||||
expect(getSize(supermarket)).toBe(18);
|
expect(getSize(supermarket)).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the circular marker badge behind bundled logo icons', () => {
|
it('hides the circular marker badge behind bundled logo icons', () => {
|
||||||
|
|
@ -129,12 +129,12 @@ describe('usePoiLayers', () => {
|
||||||
const getLineColor = backgroundLayer.props.getLineColor as (poi: POI) => PoiColor;
|
const getLineColor = backgroundLayer.props.getLineColor as (poi: POI) => PoiColor;
|
||||||
|
|
||||||
expect(getShadowRadius(waitrose)).toBe(0);
|
expect(getShadowRadius(waitrose)).toBe(0);
|
||||||
expect(getBackgroundRadius(waitrose)).toBe(24);
|
expect(getBackgroundRadius(waitrose)).toBe(14);
|
||||||
expect(getFillColor(waitrose)).toEqual([0, 0, 0, 0]);
|
expect(getFillColor(waitrose)).toEqual([0, 0, 0, 0]);
|
||||||
expect(getLineColor(waitrose)).toEqual([0, 0, 0, 0]);
|
expect(getLineColor(waitrose)).toEqual([0, 0, 0, 0]);
|
||||||
|
|
||||||
expect(getShadowRadius(supermarket)).toBe(16);
|
expect(getShadowRadius(supermarket)).toBe(10);
|
||||||
expect(getBackgroundRadius(supermarket)).toBe(14);
|
expect(getBackgroundRadius(supermarket)).toBe(8);
|
||||||
expect(getFillColor(supermarket)).toEqual([255, 255, 255, 255]);
|
expect(getFillColor(supermarket)).toEqual([255, 255, 255, 255]);
|
||||||
expect(getLineColor(supermarket)).toEqual([34, 197, 94, 255]);
|
expect(getLineColor(supermarket)).toEqual([34, 197, 94, 255]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ 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';
|
||||||
|
|
||||||
import type { POI } from '../types';
|
import type { POI, SchoolMetadata } from '../types';
|
||||||
import {
|
import {
|
||||||
POI_GROUP_COLORS,
|
POI_GROUP_COLORS,
|
||||||
MINOR_POI_CATEGORIES,
|
MINOR_POI_CATEGORIES,
|
||||||
|
|
@ -24,6 +24,7 @@ export interface PopupInfo {
|
||||||
id: string;
|
id: string;
|
||||||
isCluster?: boolean;
|
isCluster?: boolean;
|
||||||
clusterCount?: number;
|
clusterCount?: number;
|
||||||
|
school?: SchoolMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClusterPoint {
|
interface ClusterPoint {
|
||||||
|
|
@ -60,7 +61,7 @@ function getPoiGroupColor(group: string): [number, number, number] {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPoiIconSize(poi: POI): number {
|
function getPoiIconSize(poi: POI): number {
|
||||||
return hasBundledPoiLogo(poi) ? 24 : 18;
|
return hasBundledPoiLogo(poi) ? 14 : 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
|
|
@ -77,6 +78,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
group: info.object.group,
|
group: info.object.group,
|
||||||
emoji: info.object.emoji,
|
emoji: info.object.emoji,
|
||||||
id: info.object.id,
|
id: info.object.id,
|
||||||
|
school: info.object.school,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setPopupInfo(null);
|
setPopupInfo(null);
|
||||||
|
|
@ -162,7 +164,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
id: 'poi-shadow',
|
id: 'poi-shadow',
|
||||||
data: visiblePois,
|
data: visiblePois,
|
||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getRadius: (d) => (hasBundledPoiLogo(d) ? 0 : 16),
|
getRadius: (d) => (hasBundledPoiLogo(d) ? 0 : 10),
|
||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
|
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
|
||||||
pickable: false,
|
pickable: false,
|
||||||
|
|
@ -177,7 +179,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
||||||
id: 'poi-background',
|
id: 'poi-background',
|
||||||
data: visiblePois,
|
data: visiblePois,
|
||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getRadius: (d) => (hasBundledPoiLogo(d) ? 24 : 14),
|
getRadius: (d) => (hasBundledPoiLogo(d) ? 14 : 8),
|
||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
getFillColor: (d) =>
|
getFillColor: (d) =>
|
||||||
hasBundledPoiLogo(d)
|
hasBundledPoiLogo(d)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { logNonAbortError } from '../lib/api';
|
import { logNonAbortError } from '../lib/api';
|
||||||
import type { TransportMode } from './useTravelTime';
|
import { TRANSPORT_MODES, type TransportMode } from './useTravelTime';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server may report transit variants (transit-no-bus, transit-no-change, …)
|
||||||
|
* alongside the four base modes. The UI mode picker only exposes the base modes;
|
||||||
|
* the transit variants are surfaced via toggles on a transit entry. This typing
|
||||||
|
* keeps the data model honest: the server speaks strings, we narrow at the edge.
|
||||||
|
*/
|
||||||
interface TravelModeInfo {
|
interface TravelModeInfo {
|
||||||
mode: TransportMode;
|
mode: string;
|
||||||
destinations: number;
|
destinations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBaseMode(mode: string): mode is TransportMode {
|
||||||
|
return (TRANSPORT_MODES as readonly string[]).includes(mode);
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetches which transport modes have precomputed travel time data. */
|
/** Fetches which transport modes have precomputed travel time data. */
|
||||||
export function useTravelModes() {
|
export function useTravelModes() {
|
||||||
const [availableModes, setAvailableModes] = useState<Set<TransportMode> | null>(null);
|
const [availableModes, setAvailableModes] = useState<Set<TransportMode> | null>(null);
|
||||||
|
|
@ -20,9 +30,19 @@ export function useTravelModes() {
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then((data: { modes: TravelModeInfo[] }) => {
|
.then((data: { modes: TravelModeInfo[] }) => {
|
||||||
const modes = new Set<TransportMode>(
|
const modes = new Set<TransportMode>();
|
||||||
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode)
|
let anyTransitVariantHasData = false;
|
||||||
);
|
for (const m of data.modes) {
|
||||||
|
if (m.destinations <= 0) continue;
|
||||||
|
if (isBaseMode(m.mode)) {
|
||||||
|
modes.add(m.mode);
|
||||||
|
} else if (m.mode.startsWith('transit-')) {
|
||||||
|
// Variant directories ensure the transit mode is reachable even if
|
||||||
|
// someone deletes the base `transit/` parquet folder by mistake.
|
||||||
|
anyTransitVariantHasData = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anyTransitVariantHasData) modes.add('transit');
|
||||||
setAvailableModes(modes);
|
setAvailableModes(modes);
|
||||||
})
|
})
|
||||||
.catch((err) => logNonAbortError('travel modes', err));
|
.catch((err) => logNonAbortError('travel modes', err));
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { travelFieldKey, useTravelTime, type TravelTimeEntry } from './useTravelTime';
|
import {
|
||||||
|
parseServerMode,
|
||||||
|
resolveTransitVariant,
|
||||||
|
travelFieldKey,
|
||||||
|
useTravelTime,
|
||||||
|
type TravelTimeEntry,
|
||||||
|
} from './useTravelTime';
|
||||||
|
|
||||||
describe('useTravelTime', () => {
|
describe('useTravelTime', () => {
|
||||||
it('creates backend field keys from mode and destination slug', () => {
|
it('creates backend field keys from mode and destination slug', () => {
|
||||||
|
|
@ -21,7 +27,15 @@ describe('useTravelTime', () => {
|
||||||
|
|
||||||
act(() => result.current.handleAddEntry('transit'));
|
act(() => result.current.handleAddEntry('transit'));
|
||||||
expect(result.current.entries).toEqual([
|
expect(result.current.entries).toEqual([
|
||||||
{ mode: 'transit', slug: '', label: '', timeRange: null, useBest: false },
|
{
|
||||||
|
mode: 'transit',
|
||||||
|
slug: '',
|
||||||
|
label: '',
|
||||||
|
timeRange: null,
|
||||||
|
useBest: false,
|
||||||
|
noChange: false,
|
||||||
|
noBuses: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.current.activeEntries).toEqual([]);
|
expect(result.current.activeEntries).toEqual([]);
|
||||||
|
|
||||||
|
|
@ -29,7 +43,7 @@ describe('useTravelTime', () => {
|
||||||
expect(result.current.entries[0]).toMatchObject({
|
expect(result.current.entries[0]).toMatchObject({
|
||||||
slug: 'bank',
|
slug: 'bank',
|
||||||
label: 'Bank',
|
label: 'Bank',
|
||||||
timeRange: [0, 120],
|
timeRange: [0, 90],
|
||||||
});
|
});
|
||||||
expect(result.current.activeEntries).toHaveLength(1);
|
expect(result.current.activeEntries).toHaveLength(1);
|
||||||
|
|
||||||
|
|
@ -112,4 +126,99 @@ describe('useTravelTime', () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('toggles noChange and noBuses independently', () => {
|
||||||
|
const { result } = renderHook(() => useTravelTime());
|
||||||
|
act(() => result.current.handleAddEntry('transit'));
|
||||||
|
act(() => result.current.handleSetDestination(0, 'bank', 'Bank'));
|
||||||
|
|
||||||
|
expect(result.current.entries[0]).toMatchObject({ noChange: false, noBuses: false });
|
||||||
|
|
||||||
|
act(() => result.current.handleToggleNoChange(0));
|
||||||
|
expect(result.current.entries[0]).toMatchObject({ noChange: true, noBuses: false });
|
||||||
|
|
||||||
|
act(() => result.current.handleToggleNoBuses(0));
|
||||||
|
expect(result.current.entries[0]).toMatchObject({ noChange: true, noBuses: true });
|
||||||
|
|
||||||
|
act(() => result.current.handleToggleNoChange(0));
|
||||||
|
expect(result.current.entries[0]).toMatchObject({ noChange: false, noBuses: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTransitVariant', () => {
|
||||||
|
const base: TravelTimeEntry = {
|
||||||
|
mode: 'transit',
|
||||||
|
slug: 'bank',
|
||||||
|
label: 'Bank',
|
||||||
|
timeRange: [0, 90],
|
||||||
|
useBest: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('passes non-transit modes through unchanged', () => {
|
||||||
|
expect(resolveTransitVariant({ ...base, mode: 'car' })).toBe('car');
|
||||||
|
expect(resolveTransitVariant({ ...base, mode: 'bicycle' })).toBe('bicycle');
|
||||||
|
expect(resolveTransitVariant({ ...base, mode: 'walking' })).toBe('walking');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps transit toggle combinations to the right variant string', () => {
|
||||||
|
expect(resolveTransitVariant(base)).toBe('transit');
|
||||||
|
expect(resolveTransitVariant({ ...base, noChange: true })).toBe('transit-no-change');
|
||||||
|
expect(resolveTransitVariant({ ...base, noBuses: true })).toBe('transit-no-bus');
|
||||||
|
expect(resolveTransitVariant({ ...base, noChange: true, noBuses: true })).toBe(
|
||||||
|
'transit-no-change-no-bus'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats undefined flags as false', () => {
|
||||||
|
expect(resolveTransitVariant({ ...base, noChange: undefined, noBuses: undefined })).toBe(
|
||||||
|
'transit'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseServerMode', () => {
|
||||||
|
it('round-trips the four toggle-reachable variants', () => {
|
||||||
|
expect(parseServerMode('transit')).toEqual({ mode: 'transit', noChange: false, noBuses: false });
|
||||||
|
expect(parseServerMode('transit-no-bus')).toEqual({
|
||||||
|
mode: 'transit',
|
||||||
|
noChange: false,
|
||||||
|
noBuses: true,
|
||||||
|
});
|
||||||
|
expect(parseServerMode('transit-no-change')).toEqual({
|
||||||
|
mode: 'transit',
|
||||||
|
noChange: true,
|
||||||
|
noBuses: false,
|
||||||
|
});
|
||||||
|
expect(parseServerMode('transit-no-change-no-bus')).toEqual({
|
||||||
|
mode: 'transit',
|
||||||
|
noChange: true,
|
||||||
|
noBuses: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses non-transit base modes', () => {
|
||||||
|
expect(parseServerMode('car')).toEqual({ mode: 'car', noChange: false, noBuses: false });
|
||||||
|
expect(parseServerMode('bicycle')).toEqual({ mode: 'bicycle', noChange: false, noBuses: false });
|
||||||
|
expect(parseServerMode('walking')).toEqual({ mode: 'walking', noChange: false, noBuses: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for variants the UI cannot represent (no silent broadening)', () => {
|
||||||
|
expect(parseServerMode('transit-one-change')).toBeNull();
|
||||||
|
expect(parseServerMode('transit-one-change-no-bus')).toBeNull();
|
||||||
|
expect(parseServerMode('unknown-mode')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('travelFieldKey uses the resolved variant', () => {
|
||||||
|
expect(
|
||||||
|
travelFieldKey({
|
||||||
|
mode: 'transit',
|
||||||
|
slug: 'bank',
|
||||||
|
label: 'Bank',
|
||||||
|
timeRange: [0, 90],
|
||||||
|
useBest: false,
|
||||||
|
noChange: true,
|
||||||
|
noBuses: true,
|
||||||
|
})
|
||||||
|
).toBe('tt_transit-no-change-no-bus_bank');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -64,13 +64,68 @@ export interface TravelTimeEntry {
|
||||||
timeRange: [number, number] | null;
|
timeRange: [number, number] | null;
|
||||||
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
|
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
|
||||||
useBest: boolean;
|
useBest: boolean;
|
||||||
|
/** Restrict transit to walk-transit-walk (0 changes). Optional; defaults to false. */
|
||||||
|
noChange?: boolean;
|
||||||
|
/** Drop buses from the allowed transit modes. Optional; defaults to false. */
|
||||||
|
noBuses?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Field key matching the backend response: tt_{mode}_{slug} */
|
/**
|
||||||
export function travelFieldKey(entry: TravelTimeEntry): string {
|
* The Java pipeline emits 6 transit variants as separate parquet directories.
|
||||||
return `tt_${entry.mode}_${entry.slug}`;
|
* The UI represents transit with two toggle booleans; this maps the toggle
|
||||||
|
* state back to the directory/mode name the server expects.
|
||||||
|
*
|
||||||
|
* For non-transit modes the entry.mode passes through unchanged.
|
||||||
|
*
|
||||||
|
* Note: the transit-one-change* variants exist server-side but are not reachable
|
||||||
|
* from the UI toggles (only no-change + no-buses are exposed). They're available
|
||||||
|
* via direct API access for callers that want them.
|
||||||
|
*/
|
||||||
|
export function resolveTransitVariant(entry: TravelTimeEntry): string {
|
||||||
|
if (entry.mode !== 'transit') return entry.mode;
|
||||||
|
const nc = entry.noChange ?? false;
|
||||||
|
const nb = entry.noBuses ?? false;
|
||||||
|
if (nc && nb) return 'transit-no-change-no-bus';
|
||||||
|
if (nc) return 'transit-no-change';
|
||||||
|
if (nb) return 'transit-no-bus';
|
||||||
|
return 'transit';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a server-side mode string (incl. transit variants) back into a base
|
||||||
|
* TransportMode + UI toggle booleans. Returns null for mode strings the UI
|
||||||
|
* cannot represent (currently: transit-one-change, transit-one-change-no-bus).
|
||||||
|
* Callers should skip entries that parse to null rather than silently
|
||||||
|
* normalising to a different variant.
|
||||||
|
*/
|
||||||
|
export function parseServerMode(
|
||||||
|
modeStr: string
|
||||||
|
): { mode: TransportMode; noChange: boolean; noBuses: boolean } | null {
|
||||||
|
if (modeStr === 'car' || modeStr === 'bicycle' || modeStr === 'walking') {
|
||||||
|
return { mode: modeStr, noChange: false, noBuses: false };
|
||||||
|
}
|
||||||
|
switch (modeStr) {
|
||||||
|
case 'transit':
|
||||||
|
return { mode: 'transit', noChange: false, noBuses: false };
|
||||||
|
case 'transit-no-bus':
|
||||||
|
return { mode: 'transit', noChange: false, noBuses: true };
|
||||||
|
case 'transit-no-change':
|
||||||
|
return { mode: 'transit', noChange: true, noBuses: false };
|
||||||
|
case 'transit-no-change-no-bus':
|
||||||
|
return { mode: 'transit', noChange: true, noBuses: true };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field key matching the backend response: tt_{server-mode}_{slug} */
|
||||||
|
export function travelFieldKey(entry: TravelTimeEntry): string {
|
||||||
|
return `tt_${resolveTransitVariant(entry)}_${entry.slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slider/data ceiling (minutes). Mirrors MAX_TRIP_DURATION_MINUTES in the R5 pipeline. */
|
||||||
|
export const MAX_TRAVEL_MINUTES = 90;
|
||||||
|
|
||||||
export interface TravelTimeInitial {
|
export interface TravelTimeInitial {
|
||||||
entries?: TravelTimeEntry[];
|
entries?: TravelTimeEntry[];
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +136,10 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddEntry = useCallback((mode: TransportMode) => {
|
const handleAddEntry = useCallback((mode: TransportMode) => {
|
||||||
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
|
setEntries((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ mode, slug: '', label: '', timeRange: null, useBest: false, noChange: false, noBuses: false },
|
||||||
|
]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRemoveEntry = useCallback((index: number) => {
|
const handleRemoveEntry = useCallback((index: number) => {
|
||||||
|
|
@ -92,7 +150,9 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
setEntries((prev) =>
|
setEntries((prev) =>
|
||||||
dedupeTravelTimeEntries(
|
dedupeTravelTimeEntries(
|
||||||
prev.map((entry, i) =>
|
prev.map((entry, i) =>
|
||||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
i === index
|
||||||
|
? { ...entry, slug, label, timeRange: slug ? [0, MAX_TRAVEL_MINUTES] : null }
|
||||||
|
: entry
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -114,6 +174,26 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleNoChange = useCallback((index: number) => {
|
||||||
|
setEntries((prev) =>
|
||||||
|
dedupeTravelTimeEntries(
|
||||||
|
prev.map((entry, i) =>
|
||||||
|
i === index ? { ...entry, noChange: !(entry.noChange ?? false) } : entry
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleNoBuses = useCallback((index: number) => {
|
||||||
|
setEntries((prev) =>
|
||||||
|
dedupeTravelTimeEntries(
|
||||||
|
prev.map((entry, i) =>
|
||||||
|
i === index ? { ...entry, noBuses: !(entry.noBuses ?? false) } : entry
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
||||||
setEntries(dedupeTravelTimeEntries(newEntries));
|
setEntries(dedupeTravelTimeEntries(newEntries));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -130,5 +210,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
||||||
handleSetEntries,
|
handleSetEntries,
|
||||||
handleTimeRangeChange,
|
handleTimeRangeChange,
|
||||||
handleToggleBest,
|
handleToggleBest,
|
||||||
|
handleToggleNoChange,
|
||||||
|
handleToggleNoBuses,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { stateToParams } from '../lib/url-state';
|
import { stateToParams } from '../lib/url-state';
|
||||||
|
import type { OverlayId } from '../lib/overlays';
|
||||||
import type { TravelTimeEntry } from './useTravelTime';
|
import type { TravelTimeEntry } from './useTravelTime';
|
||||||
|
|
||||||
const URL_DEBOUNCE_MS = 300;
|
const URL_DEBOUNCE_MS = 300;
|
||||||
|
|
@ -12,7 +13,8 @@ export function useUrlSync(
|
||||||
selectedPOICategories: Set<string>,
|
selectedPOICategories: Set<string>,
|
||||||
rightPaneTab: 'properties' | 'area',
|
rightPaneTab: 'properties' | 'area',
|
||||||
travelTimeEntries?: TravelTimeEntry[],
|
travelTimeEntries?: TravelTimeEntry[],
|
||||||
share?: string
|
share?: string,
|
||||||
|
selectedOverlays?: Set<OverlayId>
|
||||||
) {
|
) {
|
||||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|
@ -28,7 +30,8 @@ export function useUrlSync(
|
||||||
selectedPOICategories,
|
selectedPOICategories,
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
share
|
share,
|
||||||
|
selectedOverlays
|
||||||
);
|
);
|
||||||
const search = params.toString();
|
const search = params.toString();
|
||||||
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||||
|
|
@ -46,5 +49,6 @@ export function useUrlSync(
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
share,
|
share,
|
||||||
|
selectedOverlays,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
|
'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
|
||||||
'Interior height (m)': 'Hauteur moyenne d’étage selon le diagnostic EPC',
|
'Interior height (m)': 'Hauteur moyenne d’étage selon le diagnostic EPC',
|
||||||
'Street tree density percentile': 'Percentile estimé de couverture arborée pour la rue du bien',
|
'Street tree density percentile': 'Percentile estimé de couverture arborée pour la rue du bien',
|
||||||
|
'Within conservation area':
|
||||||
|
'Indique si le point représentatif du code postal se trouve dans une zone de conservation',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
|
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -124,6 +126,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Interior height (m)': 'Durchschnittliche Geschosshöhe laut EPC-Gutachten',
|
'Interior height (m)': 'Durchschnittliche Geschosshöhe laut EPC-Gutachten',
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
'Geschätztes Perzentil der Baumkronenbedeckung auf der Straße der Immobilie',
|
'Geschätztes Perzentil der Baumkronenbedeckung auf der Straße der Immobilie',
|
||||||
|
'Within conservation area':
|
||||||
|
'Ob der repräsentative Punkt der Postleitzahl in einer Conservation Area liegt',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
|
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -219,6 +223,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Potential energy rating': '实施所有建议改进后的潜在EPC评级',
|
'Potential energy rating': '实施所有建议改进后的潜在EPC评级',
|
||||||
'Interior height (m)': 'EPC评估的平均层高',
|
'Interior height (m)': 'EPC评估的平均层高',
|
||||||
'Street tree density percentile': '该房产所在街道的估计树冠覆盖率百分位',
|
'Street tree density percentile': '该房产所在街道的估计树冠覆盖率百分位',
|
||||||
|
'Within conservation area': '邮编代表点是否位于指定保护区内',
|
||||||
'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
|
'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
|
||||||
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
||||||
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
||||||
|
|
@ -293,6 +298,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Potential energy rating': 'सभी सुझाए गए सुधार होने पर संभावित EPC रेटिंग',
|
'Potential energy rating': 'सभी सुझाए गए सुधार होने पर संभावित EPC रेटिंग',
|
||||||
'Interior height (m)': 'EPC सर्वेक्षण के अनुसार औसत अंदरूनी ऊंचाई',
|
'Interior height (m)': 'EPC सर्वेक्षण के अनुसार औसत अंदरूनी ऊंचाई',
|
||||||
'Street tree density percentile': 'संपत्ति वाली सड़क का अनुमानित वृक्ष आच्छादन प्रतिशतक',
|
'Street tree density percentile': 'संपत्ति वाली सड़क का अनुमानित वृक्ष आच्छादन प्रतिशतक',
|
||||||
|
'Within conservation area': 'पोस्टकोड प्रतिनिधि बिंदु नामित संरक्षण क्षेत्र में है या नहीं',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
|
'2 किमी के भीतर Ofsted से अच्छी या उत्कृष्ट रेटिंग वाले प्राइमरी स्कूल',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -379,6 +385,8 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
|
'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
'Az ingatlan utcájának becsült lombkorona-fedettségi percentilise',
|
'Az ingatlan utcájának becsült lombkorona-fedettségi percentilise',
|
||||||
|
'Within conservation area':
|
||||||
|
'Az irányítószám reprezentatív pontja kijelölt conservation area területre esik-e',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
|
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
"Hauteur intérieure moyenne (sol au plafond) en mètres telle qu'enregistrée lors de l'évaluation du certificat de performance énergétique (EPC). Calculée en divisant le volume intérieur total par la surface habitable totale.",
|
"Hauteur intérieure moyenne (sol au plafond) en mètres telle qu'enregistrée lors de l'évaluation du certificat de performance énergétique (EPC). Calculée en divisant le volume intérieur total par la surface habitable totale.",
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
"Couverture arborée approximative autour du centroïde du code postal, dérivée de la carte Trees Outside Woodland 2025 de Forest Research. Les polygones de couvert arboré des arbres isolés et groupes d'arbres sont comptés dans un rayon de 50 m de chaque centroïde de code postal, puis convertis en percentile parmi les codes postaux anglais. Il s'agit d'un proxy de centroïde de code postal, pas d'une mesure exacte du bien ou du segment de rue.",
|
"Couverture arborée approximative autour du centroïde du code postal, dérivée de la carte Trees Outside Woodland 2025 de Forest Research. Les polygones de couvert arboré des arbres isolés et groupes d'arbres sont comptés dans un rayon de 50 m de chaque centroïde de code postal, puis convertis en percentile parmi les codes postaux anglais. Il s'agit d'un proxy de centroïde de code postal, pas d'une mesure exacte du bien ou du segment de rue.",
|
||||||
|
'Within conservation area':
|
||||||
|
"Limites de zones de conservation de Historic England, rattachées au point représentatif du code postal. Le jeu de données national est indicatif plutôt que définitif ; les décisions sensibles aux limites doivent être vérifiées auprès de l'autorité locale de planification.",
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -183,6 +185,8 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Durchschnittliche lichte Raumhöhe in Metern, wie während der Energieausweis-Begutachtung erfasst. Berechnet durch Division des gesamten Innenvolumens durch die Gesamtwohnfläche.',
|
'Durchschnittliche lichte Raumhöhe in Metern, wie während der Energieausweis-Begutachtung erfasst. Berechnet durch Division des gesamten Innenvolumens durch die Gesamtwohnfläche.',
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
'Ungefähre Baumkronenbedeckung rund um den Postleitzahlen-Zentroiden aus der Forest-Research-Karte Trees Outside Woodland 2025. Baumkronen-Polygone für Einzelbäume und Baumgruppen werden im Umkreis von 50 m um jeden Postleitzahlen-Zentroiden gezählt und dann in ein Perzentil über englische Postleitzahlen umgerechnet. Dies ist ein Postleitzahlen-Zentroid-Proxy, keine exakte Messung für Immobilie oder Straßenabschnitt.',
|
'Ungefähre Baumkronenbedeckung rund um den Postleitzahlen-Zentroiden aus der Forest-Research-Karte Trees Outside Woodland 2025. Baumkronen-Polygone für Einzelbäume und Baumgruppen werden im Umkreis von 50 m um jeden Postleitzahlen-Zentroiden gezählt und dann in ein Perzentil über englische Postleitzahlen umgerechnet. Dies ist ein Postleitzahlen-Zentroid-Proxy, keine exakte Messung für Immobilie oder Straßenabschnitt.',
|
||||||
|
'Within conservation area':
|
||||||
|
'Historic-England-Grenzen für Conservation Areas, dem repräsentativen Punkt der Postleitzahl zugeordnet. Der nationale Datensatz ist indikativ und nicht rechtsverbindlich; grenznahe Entscheidungen sollten bei der lokalen Planungsbehörde geprüft werden.',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von Gut oder Hervorragend. Noch nicht inspizierte Schulen sind ausgeschlossen.',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -329,6 +333,8 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'EPC评估期间记录的平均室内净高(米)。通过将室内总容积除以总建筑面积计算得出。',
|
'EPC评估期间记录的平均室内净高(米)。通过将室内总容积除以总建筑面积计算得出。',
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
'基于 Forest Research 2025 年 Trees Outside Woodland 地图估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木和树群树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
|
'基于 Forest Research 2025 年 Trees Outside Woodland 地图估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木和树群树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
|
||||||
|
'Within conservation area':
|
||||||
|
'Historic England 保护区边界,与邮编代表点匹配。全国数据集是指示性而非最终权威;涉及边界的决策应向地方规划部门核实。',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'2km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
|
'2km范围内Ofsted评级为“良好”或“优秀”的公立小学数量。尚未接受评估的学校不计入。',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -467,6 +473,8 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'EPC आकलन के दौरान दर्ज औसत अंदरूनी फर्श-से-छत ऊंचाई, मीटर में. कुल आंतरिक आयतन को कुल फर्श क्षेत्र से भाग देकर निकाली जाती है.',
|
'EPC आकलन के दौरान दर्ज औसत अंदरूनी फर्श-से-छत ऊंचाई, मीटर में. कुल आंतरिक आयतन को कुल फर्श क्षेत्र से भाग देकर निकाली जाती है.',
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
|
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
|
||||||
|
'Within conservation area':
|
||||||
|
'Historic England संरक्षण क्षेत्र सीमाएं पोस्टकोड प्रतिनिधि बिंदु से मिलाई जाती हैं. राष्ट्रीय डेटासेट संकेतक है, अंतिम आधिकारिक नहीं; सीमा-संवेदनशील निर्णय स्थानीय योजना प्राधिकरण से जांचे जाने चाहिए.',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
|
'2 km के भीतर सरकारी वित्तपोषित प्राइमरी स्कूल जिनकी मौजूदा Ofsted रेटिंग अच्छी या उत्कृष्ट है. जिन स्कूलों का अभी निरीक्षण नहीं हुआ है, उन्हें शामिल नहीं किया गया है.',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
@ -613,6 +621,8 @@ export const details: Record<string, Record<string, string>> = {
|
||||||
'Az EPC-tanúsítvány felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
|
'Az EPC-tanúsítvány felmérése során rögzített átlagos belső padló-mennyezet magasság méterben. A teljes belső térfogatot osztják a teljes alapterülettel.',
|
||||||
'Street tree density percentile':
|
'Street tree density percentile':
|
||||||
'A Forest Research 2025-os Trees Outside Woodland térképéből származó hozzávetőleges lombkorona-fedettség az irányítószám-középpont körül. A magányos fák és facsoportok lombkorona-poligonjait minden irányítószám-középpont 50 méteres körzetében számoljuk, majd az angliai irányítószámok közötti percentilissé alakítjuk. Ez irányítószám-középponti proxy, nem pontos ingatlan- vagy utcaszakasz-mérés.',
|
'A Forest Research 2025-os Trees Outside Woodland térképéből származó hozzávetőleges lombkorona-fedettség az irányítószám-középpont körül. A magányos fák és facsoportok lombkorona-poligonjait minden irányítószám-középpont 50 méteres körzetében számoljuk, majd az angliai irányítószámok közötti percentilissé alakítjuk. Ez irányítószám-középponti proxy, nem pontos ingatlan- vagy utcaszakasz-mérés.',
|
||||||
|
'Within conservation area':
|
||||||
|
'Historic England conservation area határok az irányítószám reprezentatív pontjához rendelve. Az országos adatállomány tájékoztató jellegű, nem végleges; határérzékeny döntéseknél a helyi tervezési hatóság adatait kell ellenőrizni.',
|
||||||
'Good+ primary schools within 2km':
|
'Good+ primary schools within 2km':
|
||||||
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
|
||||||
'Good+ secondary schools within 2km':
|
'Good+ secondary schools within 2km':
|
||||||
|
|
|
||||||
|
|
@ -736,6 +736,14 @@ const de: Translations = {
|
||||||
bestCaseTitle: 'Bestmögliche Reisezeit',
|
bestCaseTitle: 'Bestmögliche Reisezeit',
|
||||||
bestCaseDesc:
|
bestCaseDesc:
|
||||||
'Verwendet die schnellste realistische Reisezeit (bei guter Abfahrtsplanung und guten Anschlüssen). Standard ist der <strong>Median</strong>, der eine typische Fahrt unabhängig vom Abfahrtszeitpunkt darstellt.',
|
'Verwendet die schnellste realistische Reisezeit (bei guter Abfahrtsplanung und guten Anschlüssen). Standard ist der <strong>Median</strong>, der eine typische Fahrt unabhängig vom Abfahrtszeitpunkt darstellt.',
|
||||||
|
noChange: 'Ohne Umstieg',
|
||||||
|
noChangeTitle: 'Nur direkte Verbindungen',
|
||||||
|
noChangeDesc:
|
||||||
|
'Beschränkt auf Fahrten <strong>ohne Umstiege</strong> —gehen, in ein Verkehrsmittel einsteigen, zum Ziel gehen.',
|
||||||
|
noBuses: 'Ohne Bus',
|
||||||
|
noBusesTitle: 'Busse ausschließen',
|
||||||
|
noBusesDesc:
|
||||||
|
'Schließt Busse aus —nur <strong>Bahn, U-Bahn, Tram und Fähre</strong>. Praktisch, um staufreie Verbindungen zu finden.',
|
||||||
previewOnMap: 'Auf Karte anzeigen',
|
previewOnMap: 'Auf Karte anzeigen',
|
||||||
stopPreviewing: 'Vorschau beenden',
|
stopPreviewing: 'Vorschau beenden',
|
||||||
removeTravelTime: 'Reisezeit entfernen',
|
removeTravelTime: 'Reisezeit entfernen',
|
||||||
|
|
@ -807,6 +815,7 @@ const de: Translations = {
|
||||||
type: 'Typ:',
|
type: 'Typ:',
|
||||||
builtForm: 'Bauweise:',
|
builtForm: 'Bauweise:',
|
||||||
tenure: 'Besitzart:',
|
tenure: 'Besitzart:',
|
||||||
|
withinConservationArea: 'In Erhaltungsgebiet:',
|
||||||
floorArea: 'Wohnfläche:',
|
floorArea: 'Wohnfläche:',
|
||||||
rooms: 'Zimmer:',
|
rooms: 'Zimmer:',
|
||||||
built: 'Baujahr:',
|
built: 'Baujahr:',
|
||||||
|
|
@ -816,6 +825,9 @@ const de: Translations = {
|
||||||
epcPotential: 'EPC-Potenzial:',
|
epcPotential: 'EPC-Potenzial:',
|
||||||
renovations: 'Renovierungen',
|
renovations: 'Renovierungen',
|
||||||
perSqm: '/m²',
|
perSqm: '/m²',
|
||||||
|
historyTitle: 'Verlauf',
|
||||||
|
historySale: 'Verkauf',
|
||||||
|
historyBuilt: 'Gebaut',
|
||||||
searchPlaceholder: 'Nach Adresse oder Postleitzahl suchen...',
|
searchPlaceholder: 'Nach Adresse oder Postleitzahl suchen...',
|
||||||
propertyData: 'Immobiliendaten',
|
propertyData: 'Immobiliendaten',
|
||||||
propertyDataDesc:
|
propertyDataDesc:
|
||||||
|
|
@ -1126,6 +1138,14 @@ const de: Translations = {
|
||||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||||
dsTowUse:
|
dsTowUse:
|
||||||
'Baumkronen-Polygone für Einzelbäume, Baumgruppen und kleine Gehölze in England. Hier verwendet, um Baumdeckungs-Perzentile rund um Postleitzahlen-Zentroide zu schätzen.',
|
'Baumkronen-Polygone für Einzelbäume, Baumgruppen und kleine Gehölze in England. Hier verwendet, um Baumdeckungs-Perzentile rund um Postleitzahlen-Zentroide zu schätzen.',
|
||||||
|
dsConservationAreasName: 'Historic England Erhaltungsgebiete',
|
||||||
|
dsConservationAreasOrigin: 'Historic England und lokale Planungsbehörden',
|
||||||
|
dsConservationAreasUse:
|
||||||
|
'Grenzen ausgewiesener Conservation Areas in England. Wird genutzt, um zu kennzeichnen, ob der repräsentative Punkt einer Postleitzahl innerhalb eines solchen Gebiets liegt.',
|
||||||
|
dsListedBuildingsName: 'Historic England denkmalgeschützte Gebäude',
|
||||||
|
dsListedBuildingsOrigin: 'Historic England National Heritage List for England',
|
||||||
|
dsListedBuildingsUse:
|
||||||
|
'Punktdaten zu denkmalgeschützten Gebäuden in England. Wird genutzt, um Immobilien zu kennzeichnen, deren Adresse offenbar zu einem nahegelegenen Listeneintrag passt.',
|
||||||
dsNaptanName: 'NaPTAN (Haltestellen des öffentlichen Verkehrs)',
|
dsNaptanName: 'NaPTAN (Haltestellen des öffentlichen Verkehrs)',
|
||||||
dsNaptanOrigin: 'Department for Transport',
|
dsNaptanOrigin: 'Department for Transport',
|
||||||
dsNaptanUse:
|
dsNaptanUse:
|
||||||
|
|
@ -1166,6 +1186,7 @@ const de: Translations = {
|
||||||
faqWhyTitle: 'Warum Perfect Postcode',
|
faqWhyTitle: 'Warum Perfect Postcode',
|
||||||
faqPricingTitle: 'Zugang',
|
faqPricingTitle: 'Zugang',
|
||||||
faqTipsTitle: 'Kartentipps',
|
faqTipsTitle: 'Kartentipps',
|
||||||
|
faqBehindDataTitle: 'Hinter den Daten',
|
||||||
// FAQ items — Finding Your Area
|
// FAQ items — Finding Your Area
|
||||||
faqFinding1Q: 'Wo soll ich suchen, wenn die offensichtlichen Gebiete zu teuer sind?',
|
faqFinding1Q: 'Wo soll ich suchen, wenn die offensichtlichen Gebiete zu teuer sind?',
|
||||||
faqFinding1A:
|
faqFinding1A:
|
||||||
|
|
@ -1267,6 +1288,28 @@ const de: Translations = {
|
||||||
faqTips3Q: 'Wie aktualisiere ich die Kartenfarben?',
|
faqTips3Q: 'Wie aktualisiere ich die Kartenfarben?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'Wenn ein Merkmal die Karte einfärbt, nutzen Sie „Farbskala zurücksetzen“ in der Kartenlegende, um die Farben für die aktuell angezeigten Ergebnisse zu aktualisieren. Das ist nach Verschieben, Zoomen oder geänderten Filtern nützlich.',
|
'Wenn ein Merkmal die Karte einfärbt, nutzen Sie „Farbskala zurücksetzen“ in der Kartenlegende, um die Farben für die aktuell angezeigten Ergebnisse zu aktualisieren. Das ist nach Verschieben, Zoomen oder geänderten Filtern nützlich.',
|
||||||
|
|
||||||
|
// FAQ items — Behind The Data
|
||||||
|
faqBehindData1Q: 'Warum wirkt ein Flughafen manchmal leiser als die Straßen drumherum?',
|
||||||
|
faqBehindData1A:
|
||||||
|
'Der Lärmwert einer Postleitzahl ist der lauteste der drei Defra-Quellen — Straße, Schiene und Flugzeug — modelliert in 4 m Höhe als 24-Stunden-Mittelwert (Lden). In einer belebten Wohnstraße dominiert der Straßenverkehr, typischerweise 65–75 dB. Innerhalb eines Flughafenzauns gibt es keine großen öffentlichen Straßen, also fällt der Straßenanteil weg, und nur der Flugzeug-Durchschnitt bleibt übrig. London City Airport etwa hat eine Nachtruhe und begrenzte Bewegungen, sodass sein 24-Stunden-Flugzeug-Lden moderat ist (ca. 60–66 dB an der Startbahn) — und das Flughafengelände erscheint leiser als die A-Straßen drumherum. Dasselbe gilt für Heathrow. Das ist ein echter Effekt der Messung von Verkehrslärm auf Wohn-Empfängerhöhe, kein Fehler.',
|
||||||
|
faqBehindData2Q:
|
||||||
|
'Warum erscheint der Flughafen, die Autobahn oder ein Park als eine große Fläche?',
|
||||||
|
faqBehindData2A:
|
||||||
|
'In Großbritannien haben Postleitzahlen offiziell keine Grenzen — Royal Mail definiert eine Postleitzahl als Liste von Zustelladressen, nicht als Fläche. Perfect Postcode erzeugt die Polygone, indem jeder Adresse ihr Anteil der umgebenden Fläche zugeordnet wird. Orte ohne Adressen, etwa eine Startbahn, eine Autobahnspur, ein Park oder ein Stausee, werden von der nächstgelegenen Wohn-Postleitzahl ausgefüllt. Daher erscheint ein Flughafen oder eine offene Fläche oft als ein einziges großes Polygon, und der angezeigte Wert stammt von den wenigen Postleitzahlen innerhalb des Geländes.',
|
||||||
|
faqBehindData3Q: 'Warum zeigen benachbarte Postleitzahlen identische Kriminalitätszahlen?',
|
||||||
|
faqBehindData3A:
|
||||||
|
'Polizeilich erfasste Kriminalität auf Straßenebene wird auf LSOA-Ebene veröffentlicht — kleine Nachbarschaftsgebiete mit etwa 1.500 Einwohnern. Jede Postleitzahl innerhalb derselben LSOA übernimmt dieselben Jahreszahlen, sodass eine ruhige Wohnstraße und eine Hauptstraße einen Block entfernt identische Werte zeigen können, wenn sie auf derselben Seite der Grenze liegen. Die Pro-Kopf-Rate kann in Postleitzahlen mit Krankenhäusern, Universitätsgeländen oder Industriegebieten ungewöhnlich hoch wirken, weil dort viele Vorfälle gezählt werden, aber wenige Einwohner gemeldet sind.',
|
||||||
|
faqBehindData4Q: 'Bedeutet „Gute Schulen in 2 km Umkreis“, dass mein Kind dorthin gehen kann?',
|
||||||
|
faqBehindData4A:
|
||||||
|
'Nein. Die Zählung sucht staatliche Schulen, deren eigene Postleitzahl in einem Kreis um Ihren Postleitzahl-Mittelpunkt liegt. Einzugsgebiete, konfessionelle oder selektive Aufnahmekriterien, Geschwisterregelungen und Anmeldebedingungen werden nicht modelliert — eine nahegelegene Gute oder Hervorragende Schule kann von Ihrer Adresse aus unerreichbar sein. Nutzen Sie die Zahl, um Gebiete zu vergleichen, und prüfen Sie tatsächliche Aufnahmebedingungen bei der Schule oder Gemeinde, bevor Sie sich darauf verlassen.',
|
||||||
|
faqBehindData5Q:
|
||||||
|
'Warum zeigt eine Postleitzahl „Gigabit“, wenn nicht jedes Haus Glasfaser hat?',
|
||||||
|
faqBehindData5A:
|
||||||
|
'Breitbandabdeckung aus Ofcom Connected Nations wird pro Postleitzahl als Prozentsatz der Wohnungen ausgewiesen, die jede Geschwindigkeitsstufe erreichen können. Wir zeigen die höchste Stufe mit irgendeiner Verfügbarkeit, also liest sich eine Postleitzahl als „Gigabit verfügbar“, wenn auch nur ein einziges Zuhause es bekommen kann. Das ist die richtige Antwort auf „gibt es überhaupt Glasfaser auf dieser Straße?“, aber keine Garantie, dass jede Wohnung im Block heute bestellbar ist. Prüfen Sie immer mit den Anbietern für Ihre konkrete Adresse, bevor Sie unterschreiben.',
|
||||||
|
faqBehindData6Q: 'Warum ändern sich die ÖPNV-Zeiten nicht für Abende oder Wochenenden?',
|
||||||
|
faqBehindData6A:
|
||||||
|
'ÖPNV-Zeiten werden pro Ziel einmal für ein Dienstag-Morgen-Zeitfenster (07:30–08:30) anhand vollständiger GTFS-Fahrpläne berechnet. Der „normale“ Wert ist die Median-Fahrt in diesem Fenster, der „Bestfall“ das 5. Perzentil. Nebenzeiten, Spätabende und Wochenenden werden nicht modelliert, sodass eine Postleitzahl mit nur einem Hauptzeit-Bus auf der Karte trotzdem gut angebunden aussehen kann. Verstehen Sie die Zahlen als wochentägliche Pendel-Schätzung, nicht als Tagesdurchschnitt.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Account Page ───────────────────────────────────
|
// ── Account Page ───────────────────────────────────
|
||||||
|
|
@ -1420,6 +1463,8 @@ const de: Translations = {
|
||||||
'Potential energy rating': 'Potenzielle Energiebewertung',
|
'Potential energy rating': 'Potenzielle Energiebewertung',
|
||||||
'Interior height (m)': 'Raumhöhe (m)',
|
'Interior height (m)': 'Raumhöhe (m)',
|
||||||
'Street tree density percentile': 'Perzentil der Straßenbaumdichte',
|
'Street tree density percentile': 'Perzentil der Straßenbaumdichte',
|
||||||
|
'Within conservation area': 'In Erhaltungsgebiet',
|
||||||
|
'Listed building': 'Denkmalgeschütztes Gebäude',
|
||||||
|
|
||||||
// ─ Feature names (Transport) ─
|
// ─ Feature names (Transport) ─
|
||||||
'Travel time to nearest train or tube station (min)':
|
'Travel time to nearest train or tube station (min)':
|
||||||
|
|
|
||||||
|
|
@ -712,6 +712,14 @@ const en = {
|
||||||
bestCaseTitle: 'Best case travel time',
|
bestCaseTitle: 'Best case travel time',
|
||||||
bestCaseDesc:
|
bestCaseDesc:
|
||||||
'Uses the fastest realistic journey time (if you time your departure well and catch good connections). The default uses the <strong>median</strong>, representing a typical journey regardless of when you leave.',
|
'Uses the fastest realistic journey time (if you time your departure well and catch good connections). The default uses the <strong>median</strong>, representing a typical journey regardless of when you leave.',
|
||||||
|
noChange: 'No change',
|
||||||
|
noChangeTitle: 'No-change journeys only',
|
||||||
|
noChangeDesc:
|
||||||
|
'Restricts to journeys with <strong>no transfers</strong> —walk, board one transit service, then walk to the destination. Useful when you want a single straight-through commute.',
|
||||||
|
noBuses: 'No buses',
|
||||||
|
noBusesTitle: 'Excluding buses',
|
||||||
|
noBusesDesc:
|
||||||
|
'Drops bus services from the allowed transit modes —<strong>rail, tube, tram and ferry only</strong>. Helpful for filtering to journeys that avoid traffic delays.',
|
||||||
previewOnMap: 'Preview on map',
|
previewOnMap: 'Preview on map',
|
||||||
stopPreviewing: 'Stop previewing',
|
stopPreviewing: 'Stop previewing',
|
||||||
removeTravelTime: 'Remove travel time',
|
removeTravelTime: 'Remove travel time',
|
||||||
|
|
@ -783,6 +791,7 @@ const en = {
|
||||||
type: 'Type:',
|
type: 'Type:',
|
||||||
builtForm: 'Built form:',
|
builtForm: 'Built form:',
|
||||||
tenure: 'Tenure:',
|
tenure: 'Tenure:',
|
||||||
|
withinConservationArea: 'Within conservation area:',
|
||||||
floorArea: 'Floor area:',
|
floorArea: 'Floor area:',
|
||||||
rooms: 'Rooms:',
|
rooms: 'Rooms:',
|
||||||
built: 'Built:',
|
built: 'Built:',
|
||||||
|
|
@ -792,6 +801,9 @@ const en = {
|
||||||
epcPotential: 'EPC potential:',
|
epcPotential: 'EPC potential:',
|
||||||
renovations: 'Renovations',
|
renovations: 'Renovations',
|
||||||
perSqm: '/m²',
|
perSqm: '/m²',
|
||||||
|
historyTitle: 'History',
|
||||||
|
historySale: 'Sale',
|
||||||
|
historyBuilt: 'Built',
|
||||||
searchPlaceholder: 'Search by address or postcode...',
|
searchPlaceholder: 'Search by address or postcode...',
|
||||||
propertyData: 'Property Data',
|
propertyData: 'Property Data',
|
||||||
propertyDataDesc:
|
propertyDataDesc:
|
||||||
|
|
@ -1095,6 +1107,14 @@ const en = {
|
||||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||||
dsTowUse:
|
dsTowUse:
|
||||||
'Tree canopy polygons for lone trees, groups of trees, and small woodlands in England. Used here to estimate tree coverage percentiles around postcode centroids.',
|
'Tree canopy polygons for lone trees, groups of trees, and small woodlands in England. Used here to estimate tree coverage percentiles around postcode centroids.',
|
||||||
|
dsConservationAreasName: 'Historic England Conservation Areas',
|
||||||
|
dsConservationAreasOrigin: 'Historic England and local planning authorities',
|
||||||
|
dsConservationAreasUse:
|
||||||
|
'Designated conservation area boundaries for England. Used to flag whether a postcode representative point falls within a conservation area.',
|
||||||
|
dsListedBuildingsName: 'Historic England Listed Buildings',
|
||||||
|
dsListedBuildingsOrigin: 'Historic England National Heritage List for England',
|
||||||
|
dsListedBuildingsUse:
|
||||||
|
'Listed-building point records for England. Used to flag properties whose address appears to match a nearby listed-building entry.',
|
||||||
dsNaptanName: 'NaPTAN (Public Transport Stops)',
|
dsNaptanName: 'NaPTAN (Public Transport Stops)',
|
||||||
dsNaptanOrigin: 'Department for Transport',
|
dsNaptanOrigin: 'Department for Transport',
|
||||||
dsNaptanUse:
|
dsNaptanUse:
|
||||||
|
|
@ -1135,6 +1155,7 @@ const en = {
|
||||||
faqWhyTitle: 'Why Perfect Postcode',
|
faqWhyTitle: 'Why Perfect Postcode',
|
||||||
faqPricingTitle: 'Access',
|
faqPricingTitle: 'Access',
|
||||||
faqTipsTitle: 'Map Tips',
|
faqTipsTitle: 'Map Tips',
|
||||||
|
faqBehindDataTitle: 'Behind The Data',
|
||||||
// FAQ items — Finding Your Area
|
// FAQ items — Finding Your Area
|
||||||
faqFinding1Q: 'Where should I look once the obvious areas are too expensive?',
|
faqFinding1Q: 'Where should I look once the obvious areas are too expensive?',
|
||||||
faqFinding1A:
|
faqFinding1A:
|
||||||
|
|
@ -1234,6 +1255,26 @@ const en = {
|
||||||
faqTips3Q: 'How do I refresh the map colours?',
|
faqTips3Q: 'How do I refresh the map colours?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'When a feature is colouring the map, use Reset colour scale in the map legend to refresh the colours for the results you’re looking at now. This is useful after moving the map, zooming, or changing filters.',
|
'When a feature is colouring the map, use Reset colour scale in the map legend to refresh the colours for the results you’re looking at now. This is useful after moving the map, zooming, or changing filters.',
|
||||||
|
|
||||||
|
// FAQ items — Behind The Data
|
||||||
|
faqBehindData1Q: 'Why does an airport sometimes look quieter than the streets around it?',
|
||||||
|
faqBehindData1A:
|
||||||
|
'The noise figure shown for a postcode is the loudest of three Defra sources — road, rail, and aircraft — modelled at 4m above ground as a 24-hour weighted average (Lden). On a busy residential street the road component dominates, typically 65–75 dB. Inside an airport perimeter there are no major public roads, so the road term drops and only the aircraft average is left. London City Airport has a curfew and limited movements, so its 24-hour aircraft Lden is moderate (around 60–66 dB at the runway), and the airfield ends up looking quieter than the A-roads that flank it. The same effect appears at Heathrow. It’s a real artefact of measuring transport noise at residential receptor height, not a bug.',
|
||||||
|
faqBehindData2Q: 'Why does the airport, motorway or park show up as one big shape?',
|
||||||
|
faqBehindData2A:
|
||||||
|
'Postcodes don’t officially have boundaries in the UK — Royal Mail defines a postcode as a list of delivery addresses, not as an area. Perfect Postcode synthesises the polygons by giving each address its share of the surrounding land. Places with no addresses, such as a runway, motorway carriageway, park or reservoir, get filled in by whichever nearby residential postcode is closest. That is why an airport or open space often appears as a single large polygon rather than many small ones, and the value shown for that polygon comes from the handful of postcodes that happen to sit inside the perimeter.',
|
||||||
|
faqBehindData3Q: 'Why do nearby postcodes share the same crime numbers?',
|
||||||
|
faqBehindData3A:
|
||||||
|
'Police-recorded street-level crime is published at LSOA level — small neighbourhood areas of about 1,500 residents. Every postcode inside the same LSOA inherits the same yearly totals, so a quiet residential street and a high street one block over can show identical figures if they fall on the same side of the boundary. Per-capita rates can also look unusually high in postcodes covering hospitals, university campuses or industrial estates, because those areas record incidents normally but have very few residents on paper to divide the count across.',
|
||||||
|
faqBehindData4Q: 'Does "Good schools within 2km" mean my child can attend them?',
|
||||||
|
faqBehindData4A:
|
||||||
|
'No. The count looks for state schools whose own postcode falls inside a circle around your postcode’s centroid. Catchment areas, faith and selection criteria, sibling priority and admission rules are not modelled — a Good or Outstanding school nearby may still be unreachable from your address. Use the count to compare areas, then confirm actual admissions with the school or local authority before relying on it for a decision.',
|
||||||
|
faqBehindData5Q: 'Why does a postcode show "Gigabit" when not every home has fibre?',
|
||||||
|
faqBehindData5A:
|
||||||
|
'Broadband coverage from Ofcom Connected Nations is reported per postcode as the percentage of premises that can get each speed tier. We display the highest tier with any availability, so a postcode where even one home can reach Gigabit reads "Gigabit available". It is the right answer for "is full-fibre on this street at all?", but does not guarantee every flat in a block can be ordered today. Always verify with the providers for your specific address before signing.',
|
||||||
|
faqBehindData6Q: 'Why don’t the public-transport times change for evenings or weekends?',
|
||||||
|
faqBehindData6A:
|
||||||
|
'Transit times are computed once per destination for a Tuesday morning departure window (07:30–08:30) using full GTFS timetables. The "normal" figure is the median journey in that window, and "best case" is the 5th percentile. Off-peak, late-night and weekend services are not modelled, so a postcode that only has a peak-only bus can still look transit-good on the map. Treat the numbers as a weekday commute proxy rather than an all-day estimate.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Account Page ───────────────────────────────────
|
// ── Account Page ───────────────────────────────────
|
||||||
|
|
@ -1385,6 +1426,8 @@ const en = {
|
||||||
'Potential energy rating': 'Potential energy rating',
|
'Potential energy rating': 'Potential energy rating',
|
||||||
'Interior height (m)': 'Interior height (m)',
|
'Interior height (m)': 'Interior height (m)',
|
||||||
'Street tree density percentile': 'Street tree density percentile',
|
'Street tree density percentile': 'Street tree density percentile',
|
||||||
|
'Within conservation area': 'Within conservation area',
|
||||||
|
'Listed building': 'Listed building',
|
||||||
|
|
||||||
// ─ Feature names (Transport) ─
|
// ─ Feature names (Transport) ─
|
||||||
'Travel time to nearest train or tube station (min)':
|
'Travel time to nearest train or tube station (min)':
|
||||||
|
|
|
||||||
|
|
@ -741,6 +741,14 @@ const fr: Translations = {
|
||||||
bestCaseTitle: 'Meilleur temps de trajet',
|
bestCaseTitle: 'Meilleur temps de trajet',
|
||||||
bestCaseDesc:
|
bestCaseDesc:
|
||||||
'Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l’heure de départ.',
|
'Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l’heure de départ.',
|
||||||
|
noChange: 'Sans correspondance',
|
||||||
|
noChangeTitle: 'Trajets directs uniquement',
|
||||||
|
noChangeDesc:
|
||||||
|
'Limite aux trajets <strong>sans correspondance</strong> —marche, un seul service de transport, marche jusqu’à destination.',
|
||||||
|
noBuses: 'Sans bus',
|
||||||
|
noBusesTitle: 'Exclure les bus',
|
||||||
|
noBusesDesc:
|
||||||
|
'Exclut les bus —uniquement <strong>train, métro, tram et ferry</strong>. Pratique pour éviter les retards liés au trafic.',
|
||||||
previewOnMap: 'Aperçu sur la carte',
|
previewOnMap: 'Aperçu sur la carte',
|
||||||
stopPreviewing: 'Arrêter l’aperçu',
|
stopPreviewing: 'Arrêter l’aperçu',
|
||||||
removeTravelTime: 'Supprimer le temps de trajet',
|
removeTravelTime: 'Supprimer le temps de trajet',
|
||||||
|
|
@ -813,6 +821,7 @@ const fr: Translations = {
|
||||||
type: 'Type :',
|
type: 'Type :',
|
||||||
builtForm: 'Forme du bâti :',
|
builtForm: 'Forme du bâti :',
|
||||||
tenure: 'Régime foncier :',
|
tenure: 'Régime foncier :',
|
||||||
|
withinConservationArea: 'Dans une zone protégée :',
|
||||||
floorArea: 'Surface :',
|
floorArea: 'Surface :',
|
||||||
rooms: 'Pièces :',
|
rooms: 'Pièces :',
|
||||||
built: 'Construction :',
|
built: 'Construction :',
|
||||||
|
|
@ -822,6 +831,9 @@ const fr: Translations = {
|
||||||
epcPotential: 'Potentiel DPE :',
|
epcPotential: 'Potentiel DPE :',
|
||||||
renovations: 'Rénovations',
|
renovations: 'Rénovations',
|
||||||
perSqm: '/m²',
|
perSqm: '/m²',
|
||||||
|
historyTitle: 'Historique',
|
||||||
|
historySale: 'Vente',
|
||||||
|
historyBuilt: 'Construit',
|
||||||
searchPlaceholder: 'Rechercher par adresse ou code postal...',
|
searchPlaceholder: 'Rechercher par adresse ou code postal...',
|
||||||
propertyData: 'Données immobilières',
|
propertyData: 'Données immobilières',
|
||||||
propertyDataDesc:
|
propertyDataDesc:
|
||||||
|
|
@ -1130,6 +1142,14 @@ const fr: Translations = {
|
||||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||||
dsTowUse:
|
dsTowUse:
|
||||||
'Polygones de couvert arboré pour les arbres isolés, groupes d’arbres et petits bois en Angleterre. Utilisés ici pour estimer les percentiles de couvert arboré autour des centroïdes de codes postaux.',
|
'Polygones de couvert arboré pour les arbres isolés, groupes d’arbres et petits bois en Angleterre. Utilisés ici pour estimer les percentiles de couvert arboré autour des centroïdes de codes postaux.',
|
||||||
|
dsConservationAreasName: 'Zones de conservation de Historic England',
|
||||||
|
dsConservationAreasOrigin: 'Historic England et autorités locales de planification',
|
||||||
|
dsConservationAreasUse:
|
||||||
|
'Limites des zones de conservation désignées en Angleterre. Utilisées pour indiquer si le point représentatif d’un code postal se trouve dans une zone de conservation.',
|
||||||
|
dsListedBuildingsName: 'Bâtiments classés Historic England',
|
||||||
|
dsListedBuildingsOrigin: 'National Heritage List for England de Historic England',
|
||||||
|
dsListedBuildingsUse:
|
||||||
|
'Points de bâtiments classés en Angleterre. Utilisés pour indiquer les biens dont l’adresse semble correspondre à une entrée classée proche.',
|
||||||
dsNaptanName: 'NaPTAN (arrêts de transport public)',
|
dsNaptanName: 'NaPTAN (arrêts de transport public)',
|
||||||
dsNaptanOrigin: 'Department for Transport',
|
dsNaptanOrigin: 'Department for Transport',
|
||||||
dsNaptanUse:
|
dsNaptanUse:
|
||||||
|
|
@ -1170,6 +1190,7 @@ const fr: Translations = {
|
||||||
faqWhyTitle: 'Pourquoi Perfect Postcode',
|
faqWhyTitle: 'Pourquoi Perfect Postcode',
|
||||||
faqPricingTitle: 'Accès',
|
faqPricingTitle: 'Accès',
|
||||||
faqTipsTitle: 'Astuces carte',
|
faqTipsTitle: 'Astuces carte',
|
||||||
|
faqBehindDataTitle: 'Dans les coulisses',
|
||||||
// FAQ items — Finding Your Area
|
// FAQ items — Finding Your Area
|
||||||
faqFinding1Q: 'Où chercher quand les zones évidentes sont trop chères ?',
|
faqFinding1Q: 'Où chercher quand les zones évidentes sont trop chères ?',
|
||||||
faqFinding1A:
|
faqFinding1A:
|
||||||
|
|
@ -1273,6 +1294,30 @@ const fr: Translations = {
|
||||||
faqTips3Q: 'Comment actualiser les couleurs de la carte ?',
|
faqTips3Q: 'Comment actualiser les couleurs de la carte ?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'Lorsqu’un critère colore la carte, utilisez Réinitialiser l’échelle de couleur dans la légende de la carte pour actualiser les couleurs des résultats affichés. C’est utile après un déplacement, un zoom ou une modification des filtres.',
|
'Lorsqu’un critère colore la carte, utilisez Réinitialiser l’échelle de couleur dans la légende de la carte pour actualiser les couleurs des résultats affichés. C’est utile après un déplacement, un zoom ou une modification des filtres.',
|
||||||
|
|
||||||
|
// FAQ items — Behind The Data
|
||||||
|
faqBehindData1Q: 'Pourquoi un aéroport peut-il sembler plus calme que les rues alentour ?',
|
||||||
|
faqBehindData1A:
|
||||||
|
'Le niveau de bruit affiché pour un code postal est le plus fort des trois sources Defra — route, rail et avion — modélisé à 4 m du sol comme une moyenne pondérée sur 24 h (Lden). Dans une rue résidentielle passante, le bruit routier domine, généralement 65–75 dB. À l’intérieur du périmètre d’un aéroport, il n’y a pas de grands axes publics, le terme routier chute et il ne reste que la moyenne aérienne. London City Airport, par exemple, a un couvre-feu et un trafic limité, donc son Lden aérien sur 24 h reste modéré (environ 60–66 dB près de la piste) — l’aéroport apparaît donc plus calme que les axes A qui le bordent. On retrouve le même effet à Heathrow. C’est un artefact réel de la mesure du bruit des transports à hauteur de récepteur résidentiel, pas un bug.',
|
||||||
|
faqBehindData2Q:
|
||||||
|
'Pourquoi l’aéroport, l’autoroute ou un parc apparaît-il comme une grande forme ?',
|
||||||
|
faqBehindData2A:
|
||||||
|
'Au Royaume-Uni, les codes postaux n’ont pas de frontières officielles — Royal Mail définit un code postal comme une liste d’adresses de livraison, pas comme une zone. Perfect Postcode synthétise les polygones en donnant à chaque adresse sa part de terrain alentour. Les endroits sans adresse (une piste, une chaussée d’autoroute, un parc, un réservoir) sont remplis par le code postal résidentiel le plus proche. C’est pourquoi un aéroport ou un espace ouvert apparaît souvent comme un seul grand polygone, et sa valeur vient des quelques codes postaux situés à l’intérieur du périmètre.',
|
||||||
|
faqBehindData3Q:
|
||||||
|
'Pourquoi des codes postaux voisins partagent-ils les mêmes chiffres de criminalité ?',
|
||||||
|
faqBehindData3A:
|
||||||
|
'La criminalité enregistrée par la police au niveau rue est publiée à l’échelle LSOA — de petits quartiers d’environ 1 500 habitants. Chaque code postal situé dans la même LSOA hérite des mêmes totaux annuels, donc une rue résidentielle calme et une rue commerçante un pâté de maisons plus loin peuvent afficher des chiffres identiques si elles sont du même côté de la limite. Les taux par habitant peuvent sembler anormalement élevés dans des codes postaux couvrant des hôpitaux, des campus ou des zones industrielles, car ils enregistrent des incidents normalement mais comptent peu de résidents officiels.',
|
||||||
|
faqBehindData4Q:
|
||||||
|
'« Bonnes écoles dans un rayon de 2 km » signifie-t-il que mon enfant peut y aller ?',
|
||||||
|
faqBehindData4A:
|
||||||
|
'Non. Le décompte cherche les écoles publiques dont le propre code postal tombe dans un cercle autour du centroïde de votre code postal. Les secteurs scolaires, critères religieux ou sélectifs, priorité fratrie et règles d’admission ne sont pas modélisés — une école Bonne ou Excellente proche peut rester inaccessible depuis votre adresse. Utilisez le décompte pour comparer des zones, puis confirmez les conditions d’admission auprès de l’école ou de la mairie avant de vous y fier.',
|
||||||
|
faqBehindData5Q:
|
||||||
|
'Pourquoi un code postal affiche-t-il « Gigabit » quand toutes les maisons n’en ont pas ?',
|
||||||
|
faqBehindData5A:
|
||||||
|
'La couverture haut débit d’Ofcom Connected Nations est publiée par code postal comme le pourcentage de locaux pouvant atteindre chaque palier de débit. Nous affichons le palier le plus élevé avec une disponibilité non nulle, donc un code postal où un seul logement peut obtenir le Gigabit affiche « Gigabit disponible ». C’est la bonne réponse à « y a-t-il de la fibre dans cette rue ? », mais cela ne garantit pas que tout appartement de l’immeuble soit éligible aujourd’hui. Vérifiez toujours auprès des opérateurs pour votre adresse précise avant de signer.',
|
||||||
|
faqBehindData6Q: 'Pourquoi les temps de transport ne changent-ils pas le soir ou le week-end ?',
|
||||||
|
faqBehindData6A:
|
||||||
|
'Les temps de transport sont calculés une fois par destination pour une fenêtre de départ un mardi matin (07:30–08:30) à partir des horaires GTFS complets. La valeur « normale » est la médiane des trajets dans cette fenêtre, et le « meilleur cas » est le 5ᵉ percentile. Les services hors heures de pointe, nocturnes et de week-end ne sont pas modélisés, donc un code postal desservi uniquement par un bus de pointe peut quand même paraître bien desservi sur la carte. Considérez ces chiffres comme une estimation de trajet en semaine, pas une moyenne sur la journée.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Account Page ───────────────────────────────────
|
// ── Account Page ───────────────────────────────────
|
||||||
|
|
@ -1427,6 +1472,8 @@ const fr: Translations = {
|
||||||
'Potential energy rating': 'Classement énergétique potentiel',
|
'Potential energy rating': 'Classement énergétique potentiel',
|
||||||
'Interior height (m)': 'Hauteur intérieure (m)',
|
'Interior height (m)': 'Hauteur intérieure (m)',
|
||||||
'Street tree density percentile': 'Percentile de densité arborée de la rue',
|
'Street tree density percentile': 'Percentile de densité arborée de la rue',
|
||||||
|
'Within conservation area': 'Dans une zone protégée',
|
||||||
|
'Listed building': 'Bâtiment classé',
|
||||||
|
|
||||||
// ─ Feature names (Transport) ─
|
// ─ Feature names (Transport) ─
|
||||||
'Travel time to nearest train or tube station (min)':
|
'Travel time to nearest train or tube station (min)':
|
||||||
|
|
|
||||||
|
|
@ -707,6 +707,14 @@ const hi: Translations = {
|
||||||
bestCaseTitle: 'सर्वश्रेष्ठ स्थिति यात्रा समय',
|
bestCaseTitle: 'सर्वश्रेष्ठ स्थिति यात्रा समय',
|
||||||
bestCaseDesc:
|
bestCaseDesc:
|
||||||
'सबसे तेज यथार्थवादी यात्रा समय का उपयोग करता है (अगर आप प्रस्थान का समय सही रखें और अच्छे कनेक्शन मिलें). डिफॉल्ट <strong>मीडियन</strong> का उपयोग करता है, जो आपके निकलने के समय से स्वतंत्र एक सामान्य यात्रा दिखाता है.',
|
'सबसे तेज यथार्थवादी यात्रा समय का उपयोग करता है (अगर आप प्रस्थान का समय सही रखें और अच्छे कनेक्शन मिलें). डिफॉल्ट <strong>मीडियन</strong> का उपयोग करता है, जो आपके निकलने के समय से स्वतंत्र एक सामान्य यात्रा दिखाता है.',
|
||||||
|
noChange: 'बिना बदलाव',
|
||||||
|
noChangeTitle: 'केवल सीधी यात्राएँ',
|
||||||
|
noChangeDesc:
|
||||||
|
'<strong>बिना ट्रांसफर</strong> वाली यात्राओं तक सीमित — पैदल, एक परिवहन सेवा, फिर पैदल गंतव्य तक.',
|
||||||
|
noBuses: 'बिना बसें',
|
||||||
|
noBusesTitle: 'बसों को बाहर रखें',
|
||||||
|
noBusesDesc:
|
||||||
|
'बस सेवाओं को छोड़ देता है — केवल <strong>ट्रेन, ट्यूब, ट्राम और फ़ेरी</strong>. ट्रैफिक देरी से बचने वाली यात्राओं को छानने के लिए उपयोगी.',
|
||||||
previewOnMap: 'मानचित्र पर पूर्वावलोकन',
|
previewOnMap: 'मानचित्र पर पूर्वावलोकन',
|
||||||
stopPreviewing: 'पूर्वावलोकन रोकें',
|
stopPreviewing: 'पूर्वावलोकन रोकें',
|
||||||
removeTravelTime: 'यात्रा समय हटाएं',
|
removeTravelTime: 'यात्रा समय हटाएं',
|
||||||
|
|
@ -773,6 +781,7 @@ const hi: Translations = {
|
||||||
type: 'प्रकार:',
|
type: 'प्रकार:',
|
||||||
builtForm: 'निर्माण रूप:',
|
builtForm: 'निर्माण रूप:',
|
||||||
tenure: 'कार्यकाल:',
|
tenure: 'कार्यकाल:',
|
||||||
|
withinConservationArea: 'संरक्षण क्षेत्र में:',
|
||||||
floorArea: 'फर्श क्षेत्र:',
|
floorArea: 'फर्श क्षेत्र:',
|
||||||
rooms: 'कमरे:',
|
rooms: 'कमरे:',
|
||||||
built: 'निर्माण:',
|
built: 'निर्माण:',
|
||||||
|
|
@ -782,6 +791,9 @@ const hi: Translations = {
|
||||||
epcPotential: 'EPC संभावित:',
|
epcPotential: 'EPC संभावित:',
|
||||||
renovations: 'नवीनीकरण',
|
renovations: 'नवीनीकरण',
|
||||||
perSqm: '/वर्ग मी',
|
perSqm: '/वर्ग मी',
|
||||||
|
historyTitle: 'इतिहास',
|
||||||
|
historySale: 'बिक्री',
|
||||||
|
historyBuilt: 'निर्मित',
|
||||||
searchPlaceholder: 'पते या पोस्टकोड से खोजें...',
|
searchPlaceholder: 'पते या पोस्टकोड से खोजें...',
|
||||||
propertyData: 'संपत्ति डेटा',
|
propertyData: 'संपत्ति डेटा',
|
||||||
propertyDataDesc:
|
propertyDataDesc:
|
||||||
|
|
@ -1074,6 +1086,14 @@ const hi: Translations = {
|
||||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||||
dsTowUse:
|
dsTowUse:
|
||||||
'इंग्लैंड में अकेले पेड़ों, पेड़ों के समूहों और छोटे वन क्षेत्रों के वृक्ष आच्छादन बहुभुज. यहां पोस्टकोड केंद्रों के आसपास वृक्ष आच्छादन प्रतिशतक का अनुमान लगाने के लिए उपयोग किया गया है.',
|
'इंग्लैंड में अकेले पेड़ों, पेड़ों के समूहों और छोटे वन क्षेत्रों के वृक्ष आच्छादन बहुभुज. यहां पोस्टकोड केंद्रों के आसपास वृक्ष आच्छादन प्रतिशतक का अनुमान लगाने के लिए उपयोग किया गया है.',
|
||||||
|
dsConservationAreasName: 'Historic England संरक्षण क्षेत्र',
|
||||||
|
dsConservationAreasOrigin: 'Historic England और स्थानीय योजना प्राधिकरण',
|
||||||
|
dsConservationAreasUse:
|
||||||
|
'इंग्लैंड में नामित संरक्षण क्षेत्रों की सीमाएं. इसका उपयोग यह दिखाने के लिए किया जाता है कि पोस्टकोड का प्रतिनिधि बिंदु संरक्षण क्षेत्र में आता है या नहीं.',
|
||||||
|
dsListedBuildingsName: 'Historic England सूचीबद्ध भवन',
|
||||||
|
dsListedBuildingsOrigin: 'Historic England National Heritage List for England',
|
||||||
|
dsListedBuildingsUse:
|
||||||
|
'इंग्लैंड के सूचीबद्ध भवनों के बिंदु रिकॉर्ड. इसका उपयोग उन संपत्तियों को चिह्नित करने के लिए किया जाता है जिनका पता किसी पास की सूची प्रविष्टि से मेल खाता दिखता है.',
|
||||||
dsNaptanName: 'NaPTAN (सार्वजनिक परिवहन स्टॉप)',
|
dsNaptanName: 'NaPTAN (सार्वजनिक परिवहन स्टॉप)',
|
||||||
dsNaptanOrigin: 'Department for Transport',
|
dsNaptanOrigin: 'Department for Transport',
|
||||||
dsNaptanUse:
|
dsNaptanUse:
|
||||||
|
|
@ -1113,6 +1133,7 @@ const hi: Translations = {
|
||||||
faqWhyTitle: 'Perfect Postcode क्यों',
|
faqWhyTitle: 'Perfect Postcode क्यों',
|
||||||
faqPricingTitle: 'एक्सेस',
|
faqPricingTitle: 'एक्सेस',
|
||||||
faqTipsTitle: 'मानचित्र टिप्स',
|
faqTipsTitle: 'मानचित्र टिप्स',
|
||||||
|
faqBehindDataTitle: 'डेटा के पीछे',
|
||||||
faqFinding1Q: 'जब स्पष्ट क्षेत्र बहुत महंगे हों तो मुझे कहां देखना चाहिए?',
|
faqFinding1Q: 'जब स्पष्ट क्षेत्र बहुत महंगे हों तो मुझे कहां देखना चाहिए?',
|
||||||
faqFinding1A:
|
faqFinding1A:
|
||||||
'जिन बातों पर आप समझौता नहीं कर सकते उनसे शुरू करें: बजट, घर का प्रकार, जगह, आवागमन, स्कूल, सुरक्षा, शोर, इंटरनेट, पार्क और बाकी जरूरी बातें. मानचित्र वे जगहें छिपा देता है जो फिट नहीं बैठतीं, ताकि लिस्टिंग देखने से पहले कम स्पष्ट विकल्प सामने आ सकें.',
|
'जिन बातों पर आप समझौता नहीं कर सकते उनसे शुरू करें: बजट, घर का प्रकार, जगह, आवागमन, स्कूल, सुरक्षा, शोर, इंटरनेट, पार्क और बाकी जरूरी बातें. मानचित्र वे जगहें छिपा देता है जो फिट नहीं बैठतीं, ताकि लिस्टिंग देखने से पहले कम स्पष्ट विकल्प सामने आ सकें.',
|
||||||
|
|
@ -1200,6 +1221,26 @@ const hi: Translations = {
|
||||||
faqTips3Q: 'मानचित्र के रंग कैसे ताज़ा करें?',
|
faqTips3Q: 'मानचित्र के रंग कैसे ताज़ा करें?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'जब कोई फीचर मानचित्र को रंग रहा हो, तो मानचित्र संकेतक में रंग स्केल रीसेट करें उपयोग करें ताकि अभी दिख रहे परिणामों के रंग ताज़ा हों. मानचित्र खिसकाने, ज़ूम करने या फिल्टर बदलने के बाद यह उपयोगी है.',
|
'जब कोई फीचर मानचित्र को रंग रहा हो, तो मानचित्र संकेतक में रंग स्केल रीसेट करें उपयोग करें ताकि अभी दिख रहे परिणामों के रंग ताज़ा हों. मानचित्र खिसकाने, ज़ूम करने या फिल्टर बदलने के बाद यह उपयोगी है.',
|
||||||
|
|
||||||
|
// FAQ items — Behind The Data
|
||||||
|
faqBehindData1Q: 'कभी-कभी एयरपोर्ट आसपास की सड़कों से शांत क्यों दिखता है?',
|
||||||
|
faqBehindData1A:
|
||||||
|
'किसी पोस्टकोड के लिए दिखाया गया शोर स्तर Defra के तीन स्रोतों — सड़क, रेल और विमान — में से सबसे ऊँचा होता है, जो ज़मीन से 4 मीटर ऊपर 24-घंटे के भारित औसत (Lden) के रूप में मॉडल किया जाता है. व्यस्त आवासीय सड़क पर सड़क शोर हावी रहता है, आमतौर पर 65–75 dB. एयरपोर्ट की सीमा के भीतर कोई बड़ी सार्वजनिक सड़कें नहीं होतीं, इसलिए सड़क घटक गिर जाता है और केवल विमान औसत बचता है. उदाहरण के लिए, London City Airport में कर्फ्यू है और सीमित उड़ानें हैं, इसलिए इसका 24-घंटे विमान Lden मध्यम रहता है (रनवे पर लगभग 60–66 dB) — और एयरपोर्ट का भीतरी हिस्सा उसके आसपास की A-सड़कों से शांत दिखता है. यही प्रभाव Heathrow में भी दिखाई देता है. यह आवासीय रिसेप्टर ऊँचाई पर परिवहन शोर मापने का वास्तविक परिणाम है, बग नहीं.',
|
||||||
|
faqBehindData2Q: 'एयरपोर्ट, हाईवे या पार्क एक बड़े आकार के रूप में क्यों दिखता है?',
|
||||||
|
faqBehindData2A:
|
||||||
|
'यूके में पोस्टकोड्स की आधिकारिक सीमाएँ नहीं होतीं — Royal Mail पोस्टकोड को डिलीवरी पतों की सूची के रूप में परिभाषित करता है, क्षेत्र के रूप में नहीं. Perfect Postcode प्रत्येक पते को आसपास की भूमि का उसका हिस्सा देकर बहुभुजों का संश्लेषण करता है. बिना पतों वाली जगहों (रनवे, हाईवे लेन, पार्क, जलाशय) को निकटतम आवासीय पोस्टकोड भर देता है. इसलिए एयरपोर्ट या खुला क्षेत्र अक्सर एकल बड़े बहुभुज के रूप में दिखता है, और उसका मान सीमा के भीतर मौजूद कुछ पोस्टकोड्स से आता है.',
|
||||||
|
faqBehindData3Q: 'पास के पोस्टकोड्स में अपराध संख्या समान क्यों होती है?',
|
||||||
|
faqBehindData3A:
|
||||||
|
'पुलिस द्वारा दर्ज सड़क-स्तरीय अपराध डेटा LSOA स्तर पर प्रकाशित होता है — लगभग 1,500 निवासियों वाले छोटे पड़ोस क्षेत्र. एक ही LSOA के सभी पोस्टकोड्स को समान वार्षिक संख्याएँ मिलती हैं, इसलिए एक शांत आवासीय सड़क और एक ब्लॉक दूर मुख्य सड़क समान आँकड़े दिखा सकती हैं अगर वे सीमा के एक ही ओर हों. अस्पतालों, विश्वविद्यालय परिसरों या औद्योगिक क्षेत्रों को कवर करने वाले पोस्टकोड्स में प्रति-व्यक्ति दर असामान्य रूप से ऊँची लग सकती है, क्योंकि वहाँ घटनाएँ सामान्य रूप से दर्ज होती हैं पर कागज़ पर निवासी कम होते हैं.',
|
||||||
|
faqBehindData4Q: '"2 किमी के भीतर अच्छे स्कूल" का मतलब क्या मेरा बच्चा वहाँ जा सकता है?',
|
||||||
|
faqBehindData4A:
|
||||||
|
'नहीं. यह गणना उन सरकारी स्कूलों को खोजती है जिनका अपना पोस्टकोड आपके पोस्टकोड के केंद्र के चारों ओर एक वृत्त के भीतर आता है. कैचमेंट क्षेत्र, धार्मिक या चयन मानदंड, भाई-बहन प्राथमिकता और प्रवेश नियम मॉडल नहीं किए जाते — पास का अच्छा या उत्कृष्ट स्कूल आपके पते से अप्राप्य भी हो सकता है. क्षेत्रों की तुलना के लिए इस संख्या का उपयोग करें, फिर निर्णय से पहले स्कूल या स्थानीय प्राधिकरण से वास्तविक प्रवेश की पुष्टि करें.',
|
||||||
|
faqBehindData5Q: 'जब हर घर में फ़ाइबर नहीं है, तो पोस्टकोड "Gigabit" क्यों दिखाता है?',
|
||||||
|
faqBehindData5A:
|
||||||
|
'Ofcom Connected Nations का ब्रॉडबैंड कवरेज प्रति पोस्टकोड उस प्रतिशत के रूप में दिया जाता है जो प्रत्येक गति स्तर प्राप्त कर सकते हैं. हम किसी भी उपलब्धता वाले सर्वोच्च स्तर को दिखाते हैं, इसलिए जिस पोस्टकोड में सिर्फ एक घर Gigabit प्राप्त कर सकता है वह "Gigabit उपलब्ध" दिखाता है. "क्या इस सड़क पर बिल्कुल फ़ाइबर है?" का यह सही उत्तर है, पर इसकी गारंटी नहीं कि ब्लॉक के हर फ्लैट को आज ऑर्डर किया जा सके. हस्ताक्षर से पहले अपने सटीक पते के लिए हमेशा प्रदाताओं से जाँच करें.',
|
||||||
|
faqBehindData6Q: 'सार्वजनिक परिवहन के समय शाम या सप्ताहांत में क्यों नहीं बदलते?',
|
||||||
|
faqBehindData6A:
|
||||||
|
'परिवहन समय प्रति गंतव्य एक मंगलवार सुबह की प्रस्थान विंडो (07:30–08:30) के लिए पूर्ण GTFS समय-सारणी से एक बार गणित किए जाते हैं. "सामान्य" मान उस विंडो में यात्राओं का माध्यिका है, और "सर्वोत्तम केस" 5वाँ प्रतिशतक है. ऑफ-पीक, देर रात और सप्ताहांत सेवाएँ मॉडल नहीं की गईं, इसलिए केवल पीक-समय बस वाला पोस्टकोड भी मानचित्र पर अच्छा-कनेक्टेड दिख सकता है. इन्हें कार्यदिवस यात्रा अनुमान के रूप में लें, पूरे दिन के औसत के रूप में नहीं.',
|
||||||
},
|
},
|
||||||
|
|
||||||
accountPage: {
|
accountPage: {
|
||||||
|
|
@ -1340,6 +1381,8 @@ const hi: Translations = {
|
||||||
'Potential energy rating': 'संभावित ऊर्जा रेटिंग',
|
'Potential energy rating': 'संभावित ऊर्जा रेटिंग',
|
||||||
'Interior height (m)': 'भीतरी ऊंचाई (मी)',
|
'Interior height (m)': 'भीतरी ऊंचाई (मी)',
|
||||||
'Street tree density percentile': 'सड़क वृक्ष घनत्व प्रतिशतक',
|
'Street tree density percentile': 'सड़क वृक्ष घनत्व प्रतिशतक',
|
||||||
|
'Within conservation area': 'संरक्षण क्षेत्र में',
|
||||||
|
'Listed building': 'सूचीबद्ध भवन',
|
||||||
'Travel time to nearest train or tube station (min)':
|
'Travel time to nearest train or tube station (min)':
|
||||||
'निकटतम ट्रेन या ट्यूब स्टेशन तक यात्रा समय (मिनट)',
|
'निकटतम ट्रेन या ट्यूब स्टेशन तक यात्रा समय (मिनट)',
|
||||||
'Good+ primary schools within 2km': '2 किमी के अंदर अच्छी या बेहतर रेटिंग वाले प्राथमिक स्कूल',
|
'Good+ primary schools within 2km': '2 किमी के अंदर अच्छी या बेहतर रेटिंग वाले प्राथमिक स्कूल',
|
||||||
|
|
|
||||||
|
|
@ -726,6 +726,14 @@ const hu: Translations = {
|
||||||
bestCaseTitle: 'Legjobb utazási idő',
|
bestCaseTitle: 'Legjobb utazási idő',
|
||||||
bestCaseDesc:
|
bestCaseDesc:
|
||||||
'A leggyorsabb reális utazási időt használja (ha jól időzíted az indulást és jó csatlakozásokat érsz el). Az alapértelmezett a <strong>mediánt</strong> használja, ami egy átlagos utazást képvisel, függetlenül az indulás idejétől.',
|
'A leggyorsabb reális utazási időt használja (ha jól időzíted az indulást és jó csatlakozásokat érsz el). Az alapértelmezett a <strong>mediánt</strong> használja, ami egy átlagos utazást képvisel, függetlenül az indulás idejétől.',
|
||||||
|
noChange: 'Átszállás nélkül',
|
||||||
|
noChangeTitle: 'Csak közvetlen járatok',
|
||||||
|
noChangeDesc:
|
||||||
|
'<strong>Átszállás nélküli</strong> utazásokra korlátoz —gyaloglás, egy közlekedési eszköz, gyaloglás a célig.',
|
||||||
|
noBuses: 'Busz nélkül',
|
||||||
|
noBusesTitle: 'Buszok kizárása',
|
||||||
|
noBusesDesc:
|
||||||
|
'Kihagyja a buszokat —csak <strong>vonat, metró, villamos és komp</strong>. Hasznos a forgalmi késések elkerüléséhez.',
|
||||||
previewOnMap: 'Előnézet a térképen',
|
previewOnMap: 'Előnézet a térképen',
|
||||||
stopPreviewing: 'Előnézet leállítása',
|
stopPreviewing: 'Előnézet leállítása',
|
||||||
removeTravelTime: 'Utazási idő eltávolítása',
|
removeTravelTime: 'Utazási idő eltávolítása',
|
||||||
|
|
@ -796,6 +804,7 @@ const hu: Translations = {
|
||||||
type: 'Típus:',
|
type: 'Típus:',
|
||||||
builtForm: 'Épületforma:',
|
builtForm: 'Épületforma:',
|
||||||
tenure: 'Tulajdonforma:',
|
tenure: 'Tulajdonforma:',
|
||||||
|
withinConservationArea: 'Védett területen:',
|
||||||
floorArea: 'Alapterület:',
|
floorArea: 'Alapterület:',
|
||||||
rooms: 'Szobák:',
|
rooms: 'Szobák:',
|
||||||
built: 'Építve:',
|
built: 'Építve:',
|
||||||
|
|
@ -805,6 +814,9 @@ const hu: Translations = {
|
||||||
epcPotential: 'EPC potenciál:',
|
epcPotential: 'EPC potenciál:',
|
||||||
renovations: 'Felújítások',
|
renovations: 'Felújítások',
|
||||||
perSqm: '/m²',
|
perSqm: '/m²',
|
||||||
|
historyTitle: 'Előzmények',
|
||||||
|
historySale: 'Eladás',
|
||||||
|
historyBuilt: 'Építés',
|
||||||
searchPlaceholder: 'Keresés cím vagy irányítószám alapján...',
|
searchPlaceholder: 'Keresés cím vagy irányítószám alapján...',
|
||||||
propertyData: 'Ingatlanadatok',
|
propertyData: 'Ingatlanadatok',
|
||||||
propertyDataDesc:
|
propertyDataDesc:
|
||||||
|
|
@ -1112,6 +1124,14 @@ const hu: Translations = {
|
||||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||||
dsTowUse:
|
dsTowUse:
|
||||||
'Fa lombkorona-poligonok magányos fákhoz, facsoportokhoz és kisebb erdőfoltokhoz Angliában. Itt az irányítószám-középpontok körüli lombkorona-fedettségi percentilisek becslésére használjuk.',
|
'Fa lombkorona-poligonok magányos fákhoz, facsoportokhoz és kisebb erdőfoltokhoz Angliában. Itt az irányítószám-középpontok körüli lombkorona-fedettségi percentilisek becslésére használjuk.',
|
||||||
|
dsConservationAreasName: 'Historic England műemléki területek',
|
||||||
|
dsConservationAreasOrigin: 'Historic England és helyi tervezési hatóságok',
|
||||||
|
dsConservationAreasUse:
|
||||||
|
'Anglia kijelölt conservation area határai. Annak jelzésére használjuk, hogy egy irányítószám reprezentatív pontja ilyen területre esik-e.',
|
||||||
|
dsListedBuildingsName: 'Historic England műemlék épületek',
|
||||||
|
dsListedBuildingsOrigin: 'Historic England National Heritage List for England',
|
||||||
|
dsListedBuildingsUse:
|
||||||
|
'Anglia műemlék épületeinek pontadatai. Annak jelzésére használjuk, ha egy ingatlan címe egy közeli listabejegyzéshez illeszkedni látszik.',
|
||||||
dsNaptanName: 'NaPTAN (Tömegközlekedési megállók)',
|
dsNaptanName: 'NaPTAN (Tömegközlekedési megállók)',
|
||||||
dsNaptanOrigin: 'Department for Transport',
|
dsNaptanOrigin: 'Department for Transport',
|
||||||
dsNaptanUse:
|
dsNaptanUse:
|
||||||
|
|
@ -1152,6 +1172,7 @@ const hu: Translations = {
|
||||||
faqWhyTitle: 'Miért a Perfect Postcode',
|
faqWhyTitle: 'Miért a Perfect Postcode',
|
||||||
faqPricingTitle: 'Hozzáférés',
|
faqPricingTitle: 'Hozzáférés',
|
||||||
faqTipsTitle: 'Térképtippek',
|
faqTipsTitle: 'Térképtippek',
|
||||||
|
faqBehindDataTitle: 'A háttérben',
|
||||||
// FAQ items — Finding Your Area
|
// FAQ items — Finding Your Area
|
||||||
faqFinding1Q: 'Hol keressek, ha a nyilvánvaló környékek túl drágák?',
|
faqFinding1Q: 'Hol keressek, ha a nyilvánvaló környékek túl drágák?',
|
||||||
faqFinding1A:
|
faqFinding1A:
|
||||||
|
|
@ -1253,6 +1274,27 @@ const hu: Translations = {
|
||||||
faqTips3Q: 'Hogyan frissíthetem a térkép színeit?',
|
faqTips3Q: 'Hogyan frissíthetem a térkép színeit?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'Amikor egy jellemző színezi a térképet, a jelmagyarázatban a Színskála visszaállítása gombbal frissítheted az aktuálisan látott eredmények színeit. Ez hasznos térképmozgatás, nagyítás vagy szűrőmódosítás után.',
|
'Amikor egy jellemző színezi a térképet, a jelmagyarázatban a Színskála visszaállítása gombbal frissítheted az aktuálisan látott eredmények színeit. Ez hasznos térképmozgatás, nagyítás vagy szűrőmódosítás után.',
|
||||||
|
|
||||||
|
// FAQ items — Behind The Data
|
||||||
|
faqBehindData1Q: 'Miért tűnik egy repülőtér néha csendesebbnek, mint a körülötte lévő utcák?',
|
||||||
|
faqBehindData1A:
|
||||||
|
'Egy irányítószámhoz tartozó zajérték a Defra három forrásának (közút, vasút, repülő) közül a leghangosabb, 4 méter magasságban modellezve, 24 órás súlyozott átlagként (Lden). Egy forgalmas lakóutcán a közúti komponens dominál, jellemzően 65–75 dB. A repülőtér kerítésén belül nincsenek főutak, ezért a közúti tag leesik, és csak a repülőzaj átlaga marad. A London City repülőtér például éjszakai zárva tartással és korlátozott forgalommal üzemel, így a 24 órás repülő-átlag mérsékelt (a futópálya mellett kb. 60–66 dB), a repülőtér belseje pedig csendesebbnek látszik, mint a mellette húzódó főutak. Ugyanez figyelhető meg a Heathrow-nál is. Nem hiba, hanem a lakossági receptormagasságon mért közlekedési zaj természetes velejárója.',
|
||||||
|
faqBehindData2Q: 'Miért látszik a repülőtér, autópálya vagy park egyetlen nagy foltként?',
|
||||||
|
faqBehindData2A:
|
||||||
|
'Az Egyesült Királyságban az irányítószámoknak hivatalosan nincs határa — a Royal Mail az irányítószámot kézbesítési címek listájaként definiálja, nem területként. A Perfect Postcode úgy állítja elő a poligonokat, hogy minden címhez hozzárendeli a körülötte lévő terület arányos részét. A címek nélküli helyeket (futópálya, autópálya, park, víztározó) a legközelebbi lakott irányítószám tölti ki. Ezért jelenik meg a repülőtér vagy nyílt terület gyakran egyetlen nagy poligonként, és az értéke a perimetren belül lévő néhány irányítószámból származik.',
|
||||||
|
faqBehindData3Q: 'Miért egyezik meg több közeli irányítószám bűnözési száma?',
|
||||||
|
faqBehindData3A:
|
||||||
|
'A rendőrségi utcaszintű bűnözési adatok LSOA-szinten kerülnek közzétételre — ezek kb. 1500 lakosú kis környékek. Az ugyanazon LSOA-ban lévő minden irányítószám ugyanazokat az éves számokat kapja, így egy csendes lakóutca és egy egy háztömbnyire lévő főutca azonos értékeket mutathat, ha ugyanazon az oldalon vannak a határnak. Az egy főre jutó ráta szokatlanul magasnak tűnhet kórházakat, egyetemi kampuszokat vagy ipari területeket lefedő irányítószámoknál, mert ott normál mennyiségű incidens történik, de papíron kevés a lakos.',
|
||||||
|
faqBehindData4Q:
|
||||||
|
'A „2 km-en belüli Jó iskolák” azt jelenti, hogy oda be is iratkozhat a gyerekem?',
|
||||||
|
faqBehindData4A:
|
||||||
|
'Nem. A számláló azokat az állami iskolákat keresi, amelyek saját irányítószáma az irányítószámod középpontja körüli körben van. A körzethatárokat, vallási vagy felvételi kritériumokat, testvérprioritást és felvételi szabályokat nem modellezzük — egy közeli Jó vagy Kiváló iskola lehet, hogy a te címedről mégsem elérhető. Használd a számot területek összehasonlítására, majd a tényleges felvételi feltételeket egyeztesd az iskolával vagy az önkormányzattal, mielőtt erre alapoznál.',
|
||||||
|
faqBehindData5Q: 'Miért mutat „Gigabit”-et egy irányítószám, ha nem minden otthon kapja?',
|
||||||
|
faqBehindData5A:
|
||||||
|
'Az Ofcom Connected Nations szélessáv-lefedettsége irányítószámonként az egyes sebességszinteket elérni képes helyiségek százalékát adja meg. Mi a legmagasabb elérhető szintet jelenítjük meg, így ha akár egyetlen otthon eléri a Gigabit-et, az irányítószám „Gigabit elérhető”-ként jelenik meg. Ez jól válaszol arra, hogy „van-e egyáltalán üvegszál ezen az utcán?”, de nem garantálja, hogy a tömbben minden lakásba megrendelhető. Mindig ellenőrizd a szolgáltatóknál a saját címedre vonatkozóan, mielőtt szerződnél.',
|
||||||
|
faqBehindData6Q: 'Miért nem változnak az utazási idők estére vagy hétvégére?',
|
||||||
|
faqBehindData6A:
|
||||||
|
'A tömegközlekedési időket célállomásonként egyszer számoljuk ki, egy keddi reggeli indulási ablakra (07:30–08:30) a teljes GTFS menetrendek alapján. A „normál” érték az ablakon belüli utak mediánja, a „legjobb eset” pedig az 5. percentilis. A csúcsidőn kívüli, késő esti és hétvégi járatokat nem modellezzük, így egy irányítószám, amelyhez csak csúcsidőben jár busz, a térképen ettől függetlenül jó közlekedésűnek tűnhet. Tekintsd a számokat munkanapi ingázási becslésnek, nem egész napos átlagnak.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Account Page ───────────────────────────────────
|
// ── Account Page ───────────────────────────────────
|
||||||
|
|
@ -1407,6 +1449,8 @@ const hu: Translations = {
|
||||||
'Potential energy rating': 'Potenciális energetikai minősítés',
|
'Potential energy rating': 'Potenciális energetikai minősítés',
|
||||||
'Interior height (m)': 'Belmagasság (m)',
|
'Interior height (m)': 'Belmagasság (m)',
|
||||||
'Street tree density percentile': 'Utcai fasűrűségi percentilis',
|
'Street tree density percentile': 'Utcai fasűrűségi percentilis',
|
||||||
|
'Within conservation area': 'Védett területen',
|
||||||
|
'Listed building': 'Műemlék épület',
|
||||||
|
|
||||||
// ─ Feature names (Transport) ─
|
// ─ Feature names (Transport) ─
|
||||||
'Travel time to nearest train or tube station (min)':
|
'Travel time to nearest train or tube station (min)':
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,14 @@ const zh: Translations = {
|
||||||
bestCaseTitle: '最佳通勤时间',
|
bestCaseTitle: '最佳通勤时间',
|
||||||
bestCaseDesc:
|
bestCaseDesc:
|
||||||
'使用最快的实际出行时间(如果您把握好出发时间并赶上良好的换乘)。默认使用<strong>中位数</strong>,代表无论何时出发的典型出行时间。',
|
'使用最快的实际出行时间(如果您把握好出发时间并赶上良好的换乘)。默认使用<strong>中位数</strong>,代表无论何时出发的典型出行时间。',
|
||||||
|
noChange: '无换乘',
|
||||||
|
noChangeTitle: '仅直达行程',
|
||||||
|
noChangeDesc:
|
||||||
|
'仅限<strong>无换乘</strong>行程 —步行、乘坐一次公共交通、再步行到目的地。',
|
||||||
|
noBuses: '不含公交',
|
||||||
|
noBusesTitle: '排除公交',
|
||||||
|
noBusesDesc:
|
||||||
|
'排除公交服务 —仅 <strong>火车、地铁、有轨电车和渡轮</strong>。便于筛选避开交通拥堵的行程。',
|
||||||
previewOnMap: '在地图上预览',
|
previewOnMap: '在地图上预览',
|
||||||
stopPreviewing: '停止预览',
|
stopPreviewing: '停止预览',
|
||||||
removeTravelTime: '移除通勤时间',
|
removeTravelTime: '移除通勤时间',
|
||||||
|
|
@ -744,6 +752,7 @@ const zh: Translations = {
|
||||||
type: '类型:',
|
type: '类型:',
|
||||||
builtForm: '建筑形式:',
|
builtForm: '建筑形式:',
|
||||||
tenure: '产权:',
|
tenure: '产权:',
|
||||||
|
withinConservationArea: '位于保护区内:',
|
||||||
floorArea: '建筑面积:',
|
floorArea: '建筑面积:',
|
||||||
rooms: '房间:',
|
rooms: '房间:',
|
||||||
built: '建造年份:',
|
built: '建造年份:',
|
||||||
|
|
@ -753,6 +762,9 @@ const zh: Translations = {
|
||||||
epcPotential: '潜在能源评级:',
|
epcPotential: '潜在能源评级:',
|
||||||
renovations: '翻新记录',
|
renovations: '翻新记录',
|
||||||
perSqm: '/m²',
|
perSqm: '/m²',
|
||||||
|
historyTitle: '历史',
|
||||||
|
historySale: '出售',
|
||||||
|
historyBuilt: '建成',
|
||||||
searchPlaceholder: '按地址或邮编搜索...',
|
searchPlaceholder: '按地址或邮编搜索...',
|
||||||
propertyData: '房产数据',
|
propertyData: '房产数据',
|
||||||
propertyDataDesc:
|
propertyDataDesc:
|
||||||
|
|
@ -1047,6 +1059,13 @@ const zh: Translations = {
|
||||||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||||
dsTowUse:
|
dsTowUse:
|
||||||
'英格兰孤立树木、树群和小片林地的树冠多边形。此处用于估算邮编质心周围的树冠覆盖率百分位。',
|
'英格兰孤立树木、树群和小片林地的树冠多边形。此处用于估算邮编质心周围的树冠覆盖率百分位。',
|
||||||
|
dsConservationAreasName: 'Historic England 保护区',
|
||||||
|
dsConservationAreasOrigin: 'Historic England 和地方规划部门',
|
||||||
|
dsConservationAreasUse: '英格兰指定保护区边界。用于标记邮编代表点是否位于保护区内。',
|
||||||
|
dsListedBuildingsName: 'Historic England 登录建筑',
|
||||||
|
dsListedBuildingsOrigin: 'Historic England 英格兰国家遗产名录',
|
||||||
|
dsListedBuildingsUse:
|
||||||
|
'英格兰登录建筑点位记录。用于标记地址似乎与附近登录建筑条目匹配的房产。',
|
||||||
dsNaptanName: 'NaPTAN(公共交通站点)',
|
dsNaptanName: 'NaPTAN(公共交通站点)',
|
||||||
dsNaptanOrigin: 'Department for Transport',
|
dsNaptanOrigin: 'Department for Transport',
|
||||||
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
|
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
|
||||||
|
|
@ -1085,6 +1104,7 @@ const zh: Translations = {
|
||||||
faqWhyTitle: '为什么选择 Perfect Postcode',
|
faqWhyTitle: '为什么选择 Perfect Postcode',
|
||||||
faqPricingTitle: '访问权限',
|
faqPricingTitle: '访问权限',
|
||||||
faqTipsTitle: '使用技巧',
|
faqTipsTitle: '使用技巧',
|
||||||
|
faqBehindDataTitle: '数据背后',
|
||||||
// FAQ items — Finding Your Area
|
// FAQ items — Finding Your Area
|
||||||
faqFinding1Q: '明显的区域太贵时,我应该去哪里找?',
|
faqFinding1Q: '明显的区域太贵时,我应该去哪里找?',
|
||||||
faqFinding1A:
|
faqFinding1A:
|
||||||
|
|
@ -1184,6 +1204,26 @@ const zh: Translations = {
|
||||||
faqTips3Q: '如何刷新地图颜色?',
|
faqTips3Q: '如何刷新地图颜色?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'当眼睛预览正在给地图着色时,在地图图例里点"重置颜色比例"即可按当前结果重新配色。平移、缩放或调整筛选之后,特别管用。',
|
'当眼睛预览正在给地图着色时,在地图图例里点"重置颜色比例"即可按当前结果重新配色。平移、缩放或调整筛选之后,特别管用。',
|
||||||
|
|
||||||
|
// FAQ items — Behind The Data
|
||||||
|
faqBehindData1Q: '为什么机场有时看起来比周围的街道更安静?',
|
||||||
|
faqBehindData1A:
|
||||||
|
'邮编上显示的噪音值是 Defra 三个来源(道路、铁路、飞机)中最大的一个,按离地 4 米建模为 24 小时加权平均(Lden)。在繁忙住宅街道上,道路噪音通常占主导,约 65–75 dB。机场围栏内没有大型公共道路,道路项下降,只剩下飞机平均值。例如伦敦城市机场有宵禁、航班受限,因此其 24 小时飞机 Lden 较温和(跑道处约 60–66 dB)——所以机场内部看起来比两侧 A 级公路更安静。希思罗机场也存在同样现象。这是在住宅接收点高度测量交通噪音的真实表现,而不是 bug。',
|
||||||
|
faqBehindData2Q: '为什么机场、高速公路或公园会显示为一大块?',
|
||||||
|
faqBehindData2A:
|
||||||
|
'英国的邮编没有官方边界——Royal Mail 把邮编定义为投递地址列表,而不是一个区域。Perfect Postcode 把每个地址的周围土地分配给它,从而合成多边形。没有地址的地方(跑道、高速车道、公园、水库)由最近的住宅邮编填充。因此机场或开阔区域常常显示为一个大多边形,其数值来自围栏内的少数几个邮编。',
|
||||||
|
faqBehindData3Q: '为什么相邻邮编的犯罪数字相同?',
|
||||||
|
faqBehindData3A:
|
||||||
|
'警方街道级犯罪数据按 LSOA 发布——大约 1,500 居民的小型社区单元。同一 LSOA 内每个邮编都继承同一年度总数,因此一条安静的住宅街和一个街区外的繁华街道,如果在同一边界内,可能显示完全相同的数据。覆盖医院、大学校园或工业园区的邮编,人均率可能异常偏高,因为那里事件数正常但纸面居民很少。',
|
||||||
|
faqBehindData4Q: '"2 公里内的好学校"是否意味着我孩子可以入读?',
|
||||||
|
faqBehindData4A:
|
||||||
|
'不一定。统计查找的是自身邮编落在您邮编中心点周围圆形范围内的公立学校。学区、宗教或选拔标准、兄弟姐妹优先以及录取规则都没有建模——附近的"好"或"杰出"学校可能从您家其实无法入读。请用此数字对比区域,决策前向学校或地方政府确认实际录取条件。',
|
||||||
|
faqBehindData5Q: '为什么并非每户都有光纤的邮编也显示"Gigabit"?',
|
||||||
|
faqBehindData5A:
|
||||||
|
'Ofcom Connected Nations 的宽带覆盖按邮编给出可达到每个速度档的物业百分比。我们显示有任何可用性的最高档,因此只要邮编内有一户能达到 Gigabit,就会显示"Gigabit 可用"。这正确回答了"这条街上到底有没有光纤?",但并不保证楼里每一套今天都能下单。签约前,请始终就您的具体地址向运营商核实。',
|
||||||
|
faqBehindData6Q: '为什么公共交通时间在晚上或周末不变?',
|
||||||
|
faqBehindData6A:
|
||||||
|
'每个目的地的公交时间是基于完整 GTFS 时刻表,按一个周二早上的出发窗口(07:30–08:30)一次性计算的。"普通"值是该窗口内行程的中位数,"最佳情况"是第 5 百分位。非高峰、深夜和周末班次没有建模,因此只有早高峰公交的邮编在地图上仍可能显示交通便利。请把这些数字当作工作日通勤估算,而不是全天平均。',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Account Page ───────────────────────────────────
|
// ── Account Page ───────────────────────────────────
|
||||||
|
|
@ -1332,6 +1372,8 @@ const zh: Translations = {
|
||||||
'Potential energy rating': '潜在能源评级',
|
'Potential energy rating': '潜在能源评级',
|
||||||
'Interior height (m)': '室内层高(米)',
|
'Interior height (m)': '室内层高(米)',
|
||||||
'Street tree density percentile': '街道树木覆盖率百分位',
|
'Street tree density percentile': '街道树木覆盖率百分位',
|
||||||
|
'Within conservation area': '位于保护区内',
|
||||||
|
'Listed building': '登录建筑',
|
||||||
|
|
||||||
// ─ Feature names (Transport) ─
|
// ─ Feature names (Transport) ─
|
||||||
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
|
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,14 @@ export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number
|
||||||
Yes: [239, 68, 68],
|
Yes: [239, 68, 68],
|
||||||
No: [34, 197, 94],
|
No: [34, 197, 94],
|
||||||
},
|
},
|
||||||
|
'Within conservation area': {
|
||||||
|
Yes: [20, 184, 166],
|
||||||
|
No: [107, 114, 128],
|
||||||
|
},
|
||||||
|
'Listed building': {
|
||||||
|
Yes: [245, 158, 11],
|
||||||
|
No: [107, 114, 128],
|
||||||
|
},
|
||||||
'Current energy rating': {
|
'Current energy rating': {
|
||||||
A: [22, 163, 74],
|
A: [22, 163, 74],
|
||||||
B: [132, 204, 22],
|
B: [132, 204, 22],
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,20 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
||||||
<path d="M15 11c2.5 0 4.5 1.7 4.5 4 0 2.1-1.7 3.5-4 3.5" />
|
<path d="M15 11c2.5 0 4.5 1.7 4.5 4 0 2.1-1.7 3.5-4 3.5" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
'Within conservation area': (
|
||||||
|
<>
|
||||||
|
<path d="M4 7l6-3 10 4-2 10-8 3-6-4z" />
|
||||||
|
<path d="M9 12l2 2 4-5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
'Listed building': (
|
||||||
|
<>
|
||||||
|
<path d="M4 21V9l8-6 8 6v12" />
|
||||||
|
<path d="M9 21v-6h6v6" />
|
||||||
|
<path d="M8 11h1m3 0h1m3 0h1" />
|
||||||
|
<path d="M7 21h10" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
// ── Transport ────────────────────────────────
|
// ── Transport ────────────────────────────────
|
||||||
'Travel time to nearest train or tube station (min)': (
|
'Travel time to nearest train or tube station (min)': (
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,14 @@ export function formatTransactionDate(fractionalYear: number): string {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatYearMonth(year: number, month: number): string {
|
||||||
|
const monthIndex = Math.min(Math.max(month - 1, 0), 11);
|
||||||
|
const language = i18n.language || undefined;
|
||||||
|
return new Intl.DateTimeFormat(language, { month: 'short', year: 'numeric' }).format(
|
||||||
|
new Date(Date.UTC(year, monthIndex, 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAge(value: number, approximate = true): string {
|
export function formatAge(value: number, approximate = true): string {
|
||||||
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
||||||
return Math.round(value).toString();
|
return Math.round(value).toString();
|
||||||
|
|
|
||||||
33
frontend/src/lib/overlays.ts
Normal file
33
frontend/src/lib/overlays.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export const OVERLAY_IDS = ['noise', 'crime-hotspots', 'trees-outside-woodlands'] as const;
|
||||||
|
|
||||||
|
export type OverlayId = (typeof OVERLAY_IDS)[number];
|
||||||
|
|
||||||
|
export interface OverlayDefinition {
|
||||||
|
id: OverlayId;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OVERLAYS: OverlayDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'noise',
|
||||||
|
label: 'Noise',
|
||||||
|
description: 'High-resolution Defra Lden noise raster',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crime-hotspots',
|
||||||
|
label: 'Crime hotspots',
|
||||||
|
description: 'Approximate police.uk street-crime heatmap',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trees-outside-woodlands',
|
||||||
|
label: 'Trees',
|
||||||
|
description: 'Trees Outside Woodland canopy polygons',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const OVERLAY_ID_SET = new Set<string>(OVERLAY_IDS);
|
||||||
|
|
||||||
|
export function isOverlayId(value: string): value is OverlayId {
|
||||||
|
return OVERLAY_ID_SET.has(value);
|
||||||
|
}
|
||||||
|
|
@ -94,4 +94,54 @@ describe('travel-params', () => {
|
||||||
)
|
)
|
||||||
).toBe('transit:bank-tube-station:0:1440');
|
).toBe('transit:bank-tube-station:0:1440');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('encodes transit variants in the mode field when no-change/no-buses are set', () => {
|
||||||
|
expect(
|
||||||
|
buildTravelParam([
|
||||||
|
{
|
||||||
|
mode: 'transit',
|
||||||
|
slug: 'bank-tube-station',
|
||||||
|
label: 'Bank',
|
||||||
|
timeRange: [0, 60],
|
||||||
|
useBest: false,
|
||||||
|
noChange: true,
|
||||||
|
noBuses: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe('transit-no-change:bank-tube-station:0:60');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildTravelParam([
|
||||||
|
{
|
||||||
|
mode: 'transit',
|
||||||
|
slug: 'bank-tube-station',
|
||||||
|
label: 'Bank',
|
||||||
|
timeRange: [0, 60],
|
||||||
|
useBest: true,
|
||||||
|
noChange: true,
|
||||||
|
noBuses: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe('transit-no-change-no-bus:bank-tube-station:best:0:60');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps different transit variants as separate entries (no dedupe across variants)', () => {
|
||||||
|
const transit: TravelTimeEntry = {
|
||||||
|
mode: 'transit',
|
||||||
|
slug: 'bank-tube-station',
|
||||||
|
label: 'Bank',
|
||||||
|
timeRange: [0, 90],
|
||||||
|
useBest: false,
|
||||||
|
};
|
||||||
|
const transitNoBus: TravelTimeEntry = {
|
||||||
|
...transit,
|
||||||
|
noBuses: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deduped = dedupeTravelTimeEntries([transit, transitNoBus]);
|
||||||
|
expect(deduped).toHaveLength(2);
|
||||||
|
expect(buildTravelParam([transit, transitNoBus])).toBe(
|
||||||
|
'transit:bank-tube-station:0:90|transit-no-bus:bank-tube-station:0:90'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { TravelTimeEntry } from '../hooks/useTravelTime';
|
import { resolveTransitVariant, type TravelTimeEntry } from '../hooks/useTravelTime';
|
||||||
|
|
||||||
function mergeTimeRanges(
|
function mergeTimeRanges(
|
||||||
current: [number, number] | null,
|
current: [number, number] | null,
|
||||||
|
|
@ -9,6 +9,15 @@ function mergeTimeRanges(
|
||||||
return [Math.max(current[0], next[0]), Math.min(current[1], next[1])];
|
return [Math.max(current[0], next[0]), Math.min(current[1], next[1])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedupe key includes the resolved transit variant so distinct toggle
|
||||||
|
* combinations (e.g. `transit` vs `transit-no-change`) for the same slug
|
||||||
|
* are kept as separate filters rather than collapsed.
|
||||||
|
*/
|
||||||
|
function entryDedupeKey(entry: TravelTimeEntry): string {
|
||||||
|
return `${resolveTransitVariant(entry)}:${entry.slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeEntry[] {
|
export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeEntry[] {
|
||||||
const result: TravelTimeEntry[] = [];
|
const result: TravelTimeEntry[] = [];
|
||||||
const indexByKey = new Map<string, number>();
|
const indexByKey = new Map<string, number>();
|
||||||
|
|
@ -19,7 +28,7 @@ export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeE
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `${entry.mode}:${entry.slug}`;
|
const key = entryDedupeKey(entry);
|
||||||
const existingIndex = indexByKey.get(key);
|
const existingIndex = indexByKey.get(key);
|
||||||
if (existingIndex == null) {
|
if (existingIndex == null) {
|
||||||
indexByKey.set(key, result.length);
|
indexByKey.set(key, result.length);
|
||||||
|
|
@ -33,6 +42,11 @@ export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeE
|
||||||
label: existing.label || entry.label,
|
label: existing.label || entry.label,
|
||||||
timeRange: mergeTimeRanges(existing.timeRange, entry.timeRange),
|
timeRange: mergeTimeRanges(existing.timeRange, entry.timeRange),
|
||||||
useBest: existing.useBest || entry.useBest,
|
useBest: existing.useBest || entry.useBest,
|
||||||
|
// noChange/noBuses are part of the dedupe key, so for two entries to
|
||||||
|
// collide here they must already have matching flags. Carry them through
|
||||||
|
// explicitly so unset (undefined) doesn't clobber set (false/true).
|
||||||
|
noChange: existing.noChange ?? entry.noChange,
|
||||||
|
noBuses: existing.noBuses ?? entry.noBuses,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,10 +63,11 @@ export function buildTravelParam(
|
||||||
for (const entry of dedupeTravelTimeEntries(entries)) {
|
for (const entry of dedupeTravelTimeEntries(entries)) {
|
||||||
if (!entry.slug) continue;
|
if (!entry.slug) continue;
|
||||||
|
|
||||||
let segment = `${entry.mode}:${entry.slug}`;
|
const serverMode = resolveTransitVariant(entry);
|
||||||
|
let segment = `${serverMode}:${entry.slug}`;
|
||||||
if (entry.useBest) segment += ':best';
|
if (entry.useBest) segment += ':best';
|
||||||
|
|
||||||
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
|
const isExcluded = excludeFieldKey === `tt_${serverMode}_${entry.slug}`;
|
||||||
if (isExcluded && includeUnboundedExcludedRange) {
|
if (isExcluded && includeUnboundedExcludedRange) {
|
||||||
segment += ':0:1440';
|
segment += ':0:1440';
|
||||||
} else if (!isExcluded && entry.timeRange) {
|
} else if (!isExcluded && entry.timeRange) {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ describe('url-state', () => {
|
||||||
label: 'Kings Cross',
|
label: 'Kings Cross',
|
||||||
timeRange: [0, 30],
|
timeRange: [0, 30],
|
||||||
useBest: true,
|
useBest: true,
|
||||||
|
noChange: false,
|
||||||
|
noBuses: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -115,6 +117,8 @@ describe('url-state', () => {
|
||||||
label: 'Bank',
|
label: 'Bank',
|
||||||
timeRange: [10, 45],
|
timeRange: [10, 45],
|
||||||
useBest: false,
|
useBest: false,
|
||||||
|
noChange: false,
|
||||||
|
noBuses: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -149,6 +153,26 @@ describe('url-state', () => {
|
||||||
expect(state.poiCategories).toEqual(new Set());
|
expect(state.poiCategories).toEqual(new Set());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips overlay selections', () => {
|
||||||
|
const params = stateToParams(
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
[],
|
||||||
|
new Set(),
|
||||||
|
'area',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
new Set(['noise', 'crime-hotspots'])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(params.getAll('overlay')).toEqual(['noise', 'crime-hotspots']);
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', `/?${params.toString()}&overlay=unknown`);
|
||||||
|
const state = parseUrlState();
|
||||||
|
|
||||||
|
expect(state.overlays).toEqual(new Set(['noise', 'crime-hotspots']));
|
||||||
|
});
|
||||||
|
|
||||||
it('round-trips repeated school filters with dedicated URL params', () => {
|
it('round-trips repeated school filters with dedicated URL params', () => {
|
||||||
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
||||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
|
import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
|
||||||
import {
|
import {
|
||||||
TRANSPORT_MODES,
|
MAX_TRAVEL_MINUTES,
|
||||||
type TransportMode,
|
parseServerMode,
|
||||||
|
resolveTransitVariant,
|
||||||
type TravelTimeEntry,
|
type TravelTimeEntry,
|
||||||
type TravelTimeInitial,
|
type TravelTimeInitial,
|
||||||
} from '../hooks/useTravelTime';
|
} from '../hooks/useTravelTime';
|
||||||
|
|
@ -48,6 +49,7 @@ import {
|
||||||
type PoiFilterName,
|
type PoiFilterName,
|
||||||
} from './poi-distance-filter';
|
} from './poi-distance-filter';
|
||||||
import { dedupeTravelTimeEntries } from './travel-params';
|
import { dedupeTravelTimeEntries } from './travel-params';
|
||||||
|
import { isOverlayId, type OverlayId } from './overlays';
|
||||||
|
|
||||||
const POI_NONE_PARAM = '__none';
|
const POI_NONE_PARAM = '__none';
|
||||||
|
|
||||||
|
|
@ -55,6 +57,7 @@ export interface UrlState {
|
||||||
viewState: ViewState;
|
viewState: ViewState;
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
poiCategories: Set<string>;
|
poiCategories: Set<string>;
|
||||||
|
overlays: Set<OverlayId>;
|
||||||
tab: 'properties' | 'area';
|
tab: 'properties' | 'area';
|
||||||
travelTime?: TravelTimeInitial;
|
travelTime?: TravelTimeInitial;
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
|
|
@ -209,6 +212,7 @@ export function parseUrlState(): UrlState {
|
||||||
viewState: INITIAL_VIEW_STATE,
|
viewState: INITIAL_VIEW_STATE,
|
||||||
filters: parseFilters(params),
|
filters: parseFilters(params),
|
||||||
poiCategories: new Set(),
|
poiCategories: new Set(),
|
||||||
|
overlays: new Set(),
|
||||||
tab: 'area',
|
tab: 'area',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -244,6 +248,11 @@ export function parseUrlState(): UrlState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overlayParams = params.getAll('overlay');
|
||||||
|
if (overlayParams.length > 0) {
|
||||||
|
result.overlays = new Set(overlayParams.filter(isOverlayId));
|
||||||
|
}
|
||||||
|
|
||||||
// Tab: full name
|
// Tab: full name
|
||||||
const tab = params.get('tab');
|
const tab = params.get('tab');
|
||||||
if (tab === 'properties' || tab === 'area') {
|
if (tab === 'properties' || tab === 'area') {
|
||||||
|
|
@ -257,15 +266,19 @@ export function parseUrlState(): UrlState {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Travel time: repeated `tt` params
|
// Travel time: repeated `tt` params
|
||||||
// Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
|
// Format: serverMode:slug:label[:b][:min:max]
|
||||||
|
// serverMode is one of: car | bicycle | walking | transit | transit-no-bus
|
||||||
|
// | transit-no-change | transit-no-change-no-bus. transit-one-change[-no-bus]
|
||||||
|
// variants are server-side only and will cause the entry to be dropped here
|
||||||
|
// (parseServerMode returns null) so we don't silently broaden the user's filter.
|
||||||
const ttParams = params.getAll('tt');
|
const ttParams = params.getAll('tt');
|
||||||
if (ttParams.length > 0) {
|
if (ttParams.length > 0) {
|
||||||
const entries: TravelTimeEntry[] = [];
|
const entries: TravelTimeEntry[] = [];
|
||||||
for (const tt of ttParams) {
|
for (const tt of ttParams) {
|
||||||
const parts = tt.split(':');
|
const parts = tt.split(':');
|
||||||
if (parts.length < 3) continue;
|
if (parts.length < 3) continue;
|
||||||
const mode = parts[0] as TransportMode;
|
const parsedMode = parseServerMode(parts[0]);
|
||||||
if (!TRANSPORT_MODES.includes(mode)) continue;
|
if (!parsedMode) continue;
|
||||||
const slug = parts[1];
|
const slug = parts[1];
|
||||||
const label = decodeURIComponent(parts[2]);
|
const label = decodeURIComponent(parts[2]);
|
||||||
const useBest = parts.length >= 4 && parts[3] === 'b';
|
const useBest = parts.length >= 4 && parts[3] === 'b';
|
||||||
|
|
@ -275,10 +288,21 @@ export function parseUrlState(): UrlState {
|
||||||
const min = Number(parts[3 + rangeOffset]);
|
const min = Number(parts[3 + rangeOffset]);
|
||||||
const max = Number(parts[4 + rangeOffset]);
|
const max = Number(parts[4 + rangeOffset]);
|
||||||
if (!isNaN(min) && !isNaN(max)) {
|
if (!isNaN(min) && !isNaN(max)) {
|
||||||
timeRange = [min, max];
|
// Clamp loaded max-time to the data ceiling. Older shared URLs
|
||||||
|
// may have max=120 from the previous slider range; no data exists
|
||||||
|
// above MAX_TRAVEL_MINUTES so the result is identical.
|
||||||
|
timeRange = [min, Math.min(max, MAX_TRAVEL_MINUTES)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entries.push({ mode, slug, label, timeRange, useBest });
|
entries.push({
|
||||||
|
mode: parsedMode.mode,
|
||||||
|
slug,
|
||||||
|
label,
|
||||||
|
timeRange,
|
||||||
|
useBest,
|
||||||
|
noChange: parsedMode.noChange,
|
||||||
|
noBuses: parsedMode.noBuses,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
result.travelTime = { entries: dedupeTravelTimeEntries(entries) };
|
result.travelTime = { entries: dedupeTravelTimeEntries(entries) };
|
||||||
|
|
@ -295,7 +319,8 @@ export function stateToParams(
|
||||||
selectedPOICategories: Set<string>,
|
selectedPOICategories: Set<string>,
|
||||||
rightPaneTab: 'properties' | 'area',
|
rightPaneTab: 'properties' | 'area',
|
||||||
travelTimeEntries?: TravelTimeEntry[],
|
travelTimeEntries?: TravelTimeEntry[],
|
||||||
share?: string
|
share?: string,
|
||||||
|
selectedOverlays?: Set<OverlayId>
|
||||||
): URLSearchParams {
|
): URLSearchParams {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
|
@ -378,11 +403,18 @@ export function stateToParams(
|
||||||
params.set('tab', 'properties');
|
params.set('tab', 'properties');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedOverlays) {
|
||||||
|
for (const overlay of selectedOverlays) {
|
||||||
|
params.append('overlay', overlay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Travel time: repeated `tt` params
|
// Travel time: repeated `tt` params
|
||||||
if (travelTimeEntries) {
|
if (travelTimeEntries) {
|
||||||
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
|
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
|
||||||
if (!entry.slug) continue;
|
if (!entry.slug) continue;
|
||||||
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
const serverMode = resolveTransitVariant(entry);
|
||||||
|
let val = `${serverMode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||||
if (entry.useBest) val += ':b';
|
if (entry.useBest) val += ':b';
|
||||||
if (entry.timeRange) {
|
if (entry.timeRange) {
|
||||||
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,31 @@ export interface ApiResponse {
|
||||||
features: HexagonData[];
|
features: HexagonData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GIAS register fields surfaced for state-funded school POIs. Every entry is
|
||||||
|
* optional because DfE does not populate every field for every establishment
|
||||||
|
* type (a nursery has no sixth form, an FE college reports no FSM, etc.). */
|
||||||
|
export interface SchoolMetadata {
|
||||||
|
phase?: string;
|
||||||
|
type?: string;
|
||||||
|
type_group?: string;
|
||||||
|
age_range?: string;
|
||||||
|
gender?: string;
|
||||||
|
religious_character?: string;
|
||||||
|
admissions_policy?: string;
|
||||||
|
nursery_provision?: string;
|
||||||
|
sixth_form?: string;
|
||||||
|
capacity?: number;
|
||||||
|
pupils?: number;
|
||||||
|
fsm_percent?: number;
|
||||||
|
trust?: string;
|
||||||
|
address?: string;
|
||||||
|
postcode?: string;
|
||||||
|
local_authority?: string;
|
||||||
|
website?: string;
|
||||||
|
telephone?: string;
|
||||||
|
head_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface POI {
|
export interface POI {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -105,6 +130,7 @@ export interface POI {
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
school?: SchoolMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface POIResponse {
|
export interface POIResponse {
|
||||||
|
|
@ -174,6 +200,12 @@ export interface RenovationEvent {
|
||||||
event: string;
|
event: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HistoricalPrice {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Property {
|
export interface Property {
|
||||||
address?: string;
|
address?: string;
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
|
|
@ -185,6 +217,8 @@ export interface Property {
|
||||||
property_sub_type?: string;
|
property_sub_type?: string;
|
||||||
price_qualifier?: string;
|
price_qualifier?: string;
|
||||||
former_council_house?: string;
|
former_council_house?: string;
|
||||||
|
within_conservation_area?: string;
|
||||||
|
listed_building?: string;
|
||||||
|
|
||||||
// Numeric fields
|
// Numeric fields
|
||||||
lat: number;
|
lat: number;
|
||||||
|
|
@ -192,9 +226,17 @@ export interface Property {
|
||||||
|
|
||||||
is_construction_date_approximate?: boolean;
|
is_construction_date_approximate?: boolean;
|
||||||
renovation_history?: RenovationEvent[];
|
renovation_history?: RenovationEvent[];
|
||||||
|
historical_prices?: HistoricalPrice[];
|
||||||
|
|
||||||
// All other numeric features (dynamic, including construction_age_band)
|
// All other numeric features (dynamic, including construction_age_band)
|
||||||
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
|
[key: string]:
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| RenovationEvent[]
|
||||||
|
| HistoricalPrice[]
|
||||||
|
| string[]
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared paginated list of `Property` records returned by both
|
/** Shared paginated list of `Property` records returned by both
|
||||||
|
|
@ -229,6 +271,17 @@ export interface PricePoint {
|
||||||
price: number;
|
price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CrimeYearPoint {
|
||||||
|
year: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrimeYearStats {
|
||||||
|
/** Crime type without the " (avg/yr)" suffix (e.g. "Burglary"). */
|
||||||
|
name: string;
|
||||||
|
points: CrimeYearPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface FilterExclusion {
|
export interface FilterExclusion {
|
||||||
name: string;
|
name: string;
|
||||||
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
||||||
|
|
@ -246,6 +299,8 @@ export interface HexagonStatsResponse {
|
||||||
numeric_features: NumericFeatureStats[];
|
numeric_features: NumericFeatureStats[];
|
||||||
enum_features: EnumFeatureStats[];
|
enum_features: EnumFeatureStats[];
|
||||||
price_history?: PricePoint[];
|
price_history?: PricePoint[];
|
||||||
|
/** Per-crime-type per-year counts averaged across the selection. */
|
||||||
|
crime_by_year?: CrimeYearStats[];
|
||||||
central_postcode?: string;
|
central_postcode?: string;
|
||||||
filter_exclusions?: FilterExclusion[];
|
filter_exclusions?: FilterExclusion[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
manual-data/fixed_broadband_coverage.zip
Normal file
BIN
manual-data/fixed_broadband_coverage.zip
Normal file
Binary file not shown.
51
pipeline/download/conservation_areas.py
Normal file
51
pipeline/download/conservation_areas.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Download Historic England conservation area polygons.
|
||||||
|
|
||||||
|
Source: Historic England Conservation Areas
|
||||||
|
License: Open Government Licence v3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pyogrio
|
||||||
|
|
||||||
|
URL = (
|
||||||
|
"https://opendata-historicengland.hub.arcgis.com/api/download/v1/items/"
|
||||||
|
"446bc9bf8b5b440386d0c504caa3dac5/geoPackage?layers=0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download Historic England conservation area polygons"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output GeoPackage file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = args.output.with_name(f"{args.output.stem}.tmp{args.output.suffix}")
|
||||||
|
|
||||||
|
print("Downloading Historic England conservation areas...")
|
||||||
|
with httpx.stream("GET", URL, follow_redirects=True, timeout=300) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
with tmp_path.open("wb") as fh:
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
fh.write(chunk)
|
||||||
|
|
||||||
|
info = pyogrio.read_info(tmp_path)
|
||||||
|
features = info.get("features", 0)
|
||||||
|
geometry_type = info.get("geometry_type")
|
||||||
|
if features <= 0:
|
||||||
|
raise ValueError("Downloaded conservation areas file contains no features")
|
||||||
|
if "Polygon" not in str(geometry_type):
|
||||||
|
raise ValueError(f"Expected polygon geometry, got {geometry_type!r}")
|
||||||
|
|
||||||
|
tmp_path.replace(args.output)
|
||||||
|
size_mb = args.output.stat().st_size / (1024 * 1024)
|
||||||
|
print(f"Saved {features} conservation areas to {args.output} ({size_mb:.1f} MB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
349
pipeline/download/gias.py
Normal file
349
pipeline/download/gias.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""Download the Get Information About Schools (GIAS) full establishments extract.
|
||||||
|
|
||||||
|
GIAS is the DfE register of all educational establishments in England, updated
|
||||||
|
daily. The CSV is generated on-demand via a four-step interaction with the
|
||||||
|
public Downloads page (there is no static URL):
|
||||||
|
|
||||||
|
1. GET /Downloads — extract anti-forgery token, the `all.edubase.data` tag,
|
||||||
|
and the FileGeneratedDate that the server expects for that tag today.
|
||||||
|
2. POST /Downloads/Collate — submit the form to start file generation. The
|
||||||
|
redirect URL contains a generation UUID.
|
||||||
|
3. Poll /Downloads/GenerateAjax/{id} until status:true.
|
||||||
|
4. GET the Azure blob URL with ?id={id} — returns a ZIP containing
|
||||||
|
`edubasealldataYYYYMMDD.csv`.
|
||||||
|
|
||||||
|
The CSV is cp1252-encoded with 135 columns. We keep the fields useful for a
|
||||||
|
schools map (identification, status, phase, age range, religious character,
|
||||||
|
admissions policy, headline figures, contact details) and project Easting/
|
||||||
|
Northing (EPSG:27700) to WGS84 lat/lng.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import polars as pl
|
||||||
|
from pyproj import Transformer
|
||||||
|
|
||||||
|
from pipeline.local_temp import local_tmp_dir
|
||||||
|
|
||||||
|
BASE_URL = "https://get-information-schools.service.gov.uk"
|
||||||
|
DOWNLOADS_URL = f"{BASE_URL}/Downloads"
|
||||||
|
COLLATE_URL = f"{BASE_URL}/Downloads/Collate"
|
||||||
|
AJAX_URL = f"{BASE_URL}/Downloads/GenerateAjax"
|
||||||
|
AZURE_FILE_URL = (
|
||||||
|
"https://ea-edubase-api-prod.azurewebsites.net/edubase/downloads/File.xhtml"
|
||||||
|
)
|
||||||
|
EXTRACT_TAG = "all.edubase.data"
|
||||||
|
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
POLL_INTERVAL_S = 2.0
|
||||||
|
POLL_TIMEOUT_S = 300.0
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(
|
||||||
|
r'name="__RequestVerificationToken"[^>]*value="([^"]+)"', re.IGNORECASE
|
||||||
|
)
|
||||||
|
_GEN_DATE_RE = re.compile(
|
||||||
|
r'Downloads_0__FileGeneratedDate"[^>]*value="([^"]+)"', re.IGNORECASE
|
||||||
|
)
|
||||||
|
_GEN_ID_RE = re.compile(
|
||||||
|
r"/Downloads/Generated/([0-9a-f-]{36})", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Columns to read from the CSV (the file has 135; we keep what is useful for a
|
||||||
|
# schools map and contact card). Names must match the CSV header verbatim.
|
||||||
|
_CSV_COLUMNS: list[str] = [
|
||||||
|
"URN",
|
||||||
|
"EstablishmentName",
|
||||||
|
"TypeOfEstablishment (name)",
|
||||||
|
"EstablishmentTypeGroup (name)",
|
||||||
|
"EstablishmentStatus (name)",
|
||||||
|
"PhaseOfEducation (name)",
|
||||||
|
"StatutoryLowAge",
|
||||||
|
"StatutoryHighAge",
|
||||||
|
"NurseryProvision (name)",
|
||||||
|
"OfficialSixthForm (name)",
|
||||||
|
"Gender (name)",
|
||||||
|
"ReligiousCharacter (name)",
|
||||||
|
"AdmissionsPolicy (name)",
|
||||||
|
"SchoolCapacity",
|
||||||
|
"NumberOfPupils",
|
||||||
|
"PercentageFSM",
|
||||||
|
"Trusts (name)",
|
||||||
|
"Street",
|
||||||
|
"Locality",
|
||||||
|
"Town",
|
||||||
|
"County (name)",
|
||||||
|
"Postcode",
|
||||||
|
"SchoolWebsite",
|
||||||
|
"TelephoneNum",
|
||||||
|
"HeadTitle (name)",
|
||||||
|
"HeadFirstName",
|
||||||
|
"HeadLastName",
|
||||||
|
"Easting",
|
||||||
|
"Northing",
|
||||||
|
"LA (name)",
|
||||||
|
]
|
||||||
|
|
||||||
|
_NULL_VALUES = ["", "NULL", "Not applicable", "Does not apply"]
|
||||||
|
|
||||||
|
_to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token(html: str) -> str:
|
||||||
|
match = _TOKEN_RE.search(html)
|
||||||
|
if match is None:
|
||||||
|
raise RuntimeError("Could not find __RequestVerificationToken on GIAS page")
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_file_generated_date(html: str) -> str:
|
||||||
|
match = _GEN_DATE_RE.search(html)
|
||||||
|
if match is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Could not find FileGeneratedDate for the establishments extract"
|
||||||
|
)
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_generation(client: httpx.Client) -> str:
|
||||||
|
"""Submit the Downloads form and return the generation UUID."""
|
||||||
|
initial = client.get(DOWNLOADS_URL)
|
||||||
|
initial.raise_for_status()
|
||||||
|
token = _extract_token(initial.text)
|
||||||
|
file_generated_date = _extract_file_generated_date(initial.text)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
COLLATE_URL,
|
||||||
|
data={
|
||||||
|
"__RequestVerificationToken": token,
|
||||||
|
"Downloads[0].Tag": EXTRACT_TAG,
|
||||||
|
"Downloads[0].FileGeneratedDate": file_generated_date,
|
||||||
|
"Downloads[0].Selected": "true",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
match = _GEN_ID_RE.search(str(response.url)) or _GEN_ID_RE.search(response.text)
|
||||||
|
if match is None:
|
||||||
|
raise RuntimeError("GIAS Collate did not yield a generation UUID")
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_generation(client: httpx.Client, generation_id: str) -> None:
|
||||||
|
deadline = time.monotonic() + POLL_TIMEOUT_S
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
response = client.get(
|
||||||
|
f"{AJAX_URL}/{generation_id}",
|
||||||
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
# The endpoint returns JSON whose payload is itself a JSON-encoded string,
|
||||||
|
# e.g. response.json() returns the string `{"status":true,...}` which we
|
||||||
|
# then need to decode a second time.
|
||||||
|
payload = json.loads(response.json())
|
||||||
|
if payload.get("status") is True:
|
||||||
|
return
|
||||||
|
time.sleep(POLL_INTERVAL_S)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"GIAS extract generation timed out after {POLL_TIMEOUT_S:.0f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_zip(client: httpx.Client, generation_id: str) -> bytes:
|
||||||
|
response = client.get(AZURE_FILE_URL, params={"id": generation_id})
|
||||||
|
response.raise_for_status()
|
||||||
|
if not response.content.startswith(b"PK"):
|
||||||
|
raise RuntimeError("GIAS Azure response was not a ZIP archive")
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_extract_zip() -> bytes:
|
||||||
|
"""Run the full GIAS download flow and return the raw ZIP bytes."""
|
||||||
|
headers = {"User-Agent": USER_AGENT}
|
||||||
|
with httpx.Client(headers=headers, timeout=httpx.Timeout(30.0, read=120.0)) as client:
|
||||||
|
generation_id = _start_generation(client)
|
||||||
|
_wait_for_generation(client, generation_id)
|
||||||
|
return _download_zip(client, generation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_csv_from_zip(zip_bytes: bytes) -> pl.DataFrame:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
|
||||||
|
csv_names = [name for name in archive.namelist() if name.lower().endswith(".csv")]
|
||||||
|
if not csv_names:
|
||||||
|
raise RuntimeError("GIAS ZIP did not contain a CSV file")
|
||||||
|
with archive.open(csv_names[0]) as raw:
|
||||||
|
data = raw.read()
|
||||||
|
|
||||||
|
text = data.decode("cp1252")
|
||||||
|
return pl.read_csv(
|
||||||
|
io.StringIO(text),
|
||||||
|
columns=_CSV_COLUMNS,
|
||||||
|
infer_schema_length=20000,
|
||||||
|
null_values=_NULL_VALUES,
|
||||||
|
truncate_ragged_lines=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_easting_northing(easting: pl.Series, northing: pl.Series) -> tuple[list[float | None], list[float | None]]:
|
||||||
|
e = easting.to_numpy()
|
||||||
|
n = northing.to_numpy()
|
||||||
|
lng, lat = _to_wgs84.transform(e, n)
|
||||||
|
lng_out: list[float | None] = []
|
||||||
|
lat_out: list[float | None] = []
|
||||||
|
for east_val, lat_val, lng_val in zip(e, lat, lng):
|
||||||
|
if east_val is None or float(east_val) == 0.0:
|
||||||
|
lng_out.append(None)
|
||||||
|
lat_out.append(None)
|
||||||
|
else:
|
||||||
|
lng_out.append(float(lng_val))
|
||||||
|
lat_out.append(float(lat_val))
|
||||||
|
return lat_out, lng_out
|
||||||
|
|
||||||
|
|
||||||
|
def _format_age_range(low: int | None, high: int | None) -> str | None:
|
||||||
|
if low is None and high is None:
|
||||||
|
return None
|
||||||
|
if low is None:
|
||||||
|
return f"up to {high}"
|
||||||
|
if high is None:
|
||||||
|
return f"{low}+"
|
||||||
|
return f"{low}–{high}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_address(street: str | None, locality: str | None, town: str | None) -> str | None:
|
||||||
|
parts = [part.strip() for part in (street, locality, town) if part]
|
||||||
|
parts = [part for part in parts if part]
|
||||||
|
return ", ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_head_name(title: str | None, first: str | None, last: str | None) -> str | None:
|
||||||
|
parts = [part.strip() for part in (title, first, last) if part]
|
||||||
|
parts = [part for part in parts if part]
|
||||||
|
return " ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def transform(zip_bytes: bytes) -> pl.DataFrame:
|
||||||
|
"""Convert the GIAS extract ZIP into a clean schools DataFrame."""
|
||||||
|
raw = _read_csv_from_zip(zip_bytes)
|
||||||
|
|
||||||
|
# Filter to currently-open establishments; the CSV also includes closed,
|
||||||
|
# proposed-to-open, and proposed-to-close rows we do not want on a map.
|
||||||
|
df = raw.filter(pl.col("EstablishmentStatus (name)") == "Open")
|
||||||
|
|
||||||
|
df = df.with_columns(
|
||||||
|
pl.col("URN").cast(pl.Int64),
|
||||||
|
pl.col("StatutoryLowAge").cast(pl.Int32, strict=False),
|
||||||
|
pl.col("StatutoryHighAge").cast(pl.Int32, strict=False),
|
||||||
|
pl.col("SchoolCapacity").cast(pl.Int32, strict=False),
|
||||||
|
pl.col("NumberOfPupils").cast(pl.Int32, strict=False),
|
||||||
|
pl.col("Easting").cast(pl.Float64, strict=False),
|
||||||
|
pl.col("Northing").cast(pl.Float64, strict=False),
|
||||||
|
pl.col("PercentageFSM")
|
||||||
|
.cast(pl.String)
|
||||||
|
.str.replace_all("%", "", literal=True)
|
||||||
|
.str.strip_chars()
|
||||||
|
.cast(pl.Float32, strict=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop rows without coordinates — a small number of historic/dummy entries
|
||||||
|
# have Easting=0 which would map to the Atlantic.
|
||||||
|
df = df.filter(
|
||||||
|
pl.col("Easting").is_not_null()
|
||||||
|
& pl.col("Northing").is_not_null()
|
||||||
|
& (pl.col("Easting") > 0)
|
||||||
|
& (pl.col("Northing") > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
lat, lng = _project_easting_northing(df["Easting"], df["Northing"])
|
||||||
|
|
||||||
|
age_range = [
|
||||||
|
_format_age_range(low, high)
|
||||||
|
for low, high in zip(df["StatutoryLowAge"].to_list(), df["StatutoryHighAge"].to_list())
|
||||||
|
]
|
||||||
|
address = [
|
||||||
|
_format_address(street, locality, town)
|
||||||
|
for street, locality, town in zip(
|
||||||
|
df["Street"].to_list(),
|
||||||
|
df["Locality"].to_list(),
|
||||||
|
df["Town"].to_list(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
head_name = [
|
||||||
|
_format_head_name(title, first, last)
|
||||||
|
for title, first, last in zip(
|
||||||
|
df["HeadTitle (name)"].to_list(),
|
||||||
|
df["HeadFirstName"].to_list(),
|
||||||
|
df["HeadLastName"].to_list(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
out = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"urn": df["URN"],
|
||||||
|
"name": df["EstablishmentName"],
|
||||||
|
"lat": pl.Series(lat, dtype=pl.Float64),
|
||||||
|
"lng": pl.Series(lng, dtype=pl.Float64),
|
||||||
|
"phase": df["PhaseOfEducation (name)"],
|
||||||
|
"type": df["TypeOfEstablishment (name)"],
|
||||||
|
"type_group": df["EstablishmentTypeGroup (name)"],
|
||||||
|
"age_range": pl.Series(age_range, dtype=pl.String),
|
||||||
|
"gender": df["Gender (name)"],
|
||||||
|
"religious_character": df["ReligiousCharacter (name)"],
|
||||||
|
"admissions_policy": df["AdmissionsPolicy (name)"],
|
||||||
|
"nursery_provision": df["NurseryProvision (name)"],
|
||||||
|
"sixth_form": df["OfficialSixthForm (name)"],
|
||||||
|
"capacity": df["SchoolCapacity"],
|
||||||
|
"pupils": df["NumberOfPupils"],
|
||||||
|
"fsm_percent": df["PercentageFSM"],
|
||||||
|
"trust": df["Trusts (name)"],
|
||||||
|
"address": pl.Series(address, dtype=pl.String),
|
||||||
|
"postcode": df["Postcode"],
|
||||||
|
"local_authority": df["LA (name)"],
|
||||||
|
"website": df["SchoolWebsite"],
|
||||||
|
"telephone": df["TelephoneNum"],
|
||||||
|
"head_name": pl.Series(head_name, dtype=pl.String),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop any remaining rows where projection failed (extremely rare).
|
||||||
|
return out.filter(pl.col("lat").is_not_null() & pl.col("lng").is_not_null())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download the GIAS full establishments extract → parquet"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cache_dir = local_tmp_dir() / "gias"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_path = cache_dir / "edubase.zip"
|
||||||
|
|
||||||
|
print("Fetching GIAS extract...")
|
||||||
|
zip_bytes = fetch_extract_zip()
|
||||||
|
cache_path.write_bytes(zip_bytes)
|
||||||
|
print(f"Downloaded {len(zip_bytes) / (1024 * 1024):.1f} MB to {cache_path}")
|
||||||
|
|
||||||
|
print("Transforming...")
|
||||||
|
df = transform(zip_bytes)
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
df.write_parquet(args.output, compression="zstd")
|
||||||
|
print(f"Wrote {args.output} ({len(df):,} open establishments)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
53
pipeline/download/listed_buildings.py
Normal file
53
pipeline/download/listed_buildings.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Download Historic England listed-building point data.
|
||||||
|
|
||||||
|
Source: Historic England National Heritage List for England (NHLE)
|
||||||
|
License: Open Government Licence v3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pyogrio
|
||||||
|
|
||||||
|
URL = (
|
||||||
|
"https://opendata-historicengland.hub.arcgis.com/api/download/v1/items/"
|
||||||
|
"767f279327a24845bf47dfe5eae9862b/geoPackage?layers=0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download Historic England NHLE listed-building points"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output GeoPackage file path"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = args.output.with_name(f"{args.output.stem}.tmp{args.output.suffix}")
|
||||||
|
|
||||||
|
print("Downloading Historic England listed-building points...")
|
||||||
|
with httpx.stream("GET", URL, follow_redirects=True, timeout=300) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
with tmp_path.open("wb") as fh:
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
fh.write(chunk)
|
||||||
|
|
||||||
|
info = pyogrio.read_info(tmp_path)
|
||||||
|
features = info.get("features", 0)
|
||||||
|
geometry_type = str(info.get("geometry_type") or "")
|
||||||
|
if features <= 0:
|
||||||
|
raise ValueError("Downloaded listed-buildings file contains no features")
|
||||||
|
if "Point" not in geometry_type:
|
||||||
|
raise ValueError(f"Expected point geometry, got {geometry_type!r}")
|
||||||
|
|
||||||
|
tmp_path.replace(args.output)
|
||||||
|
size_mb = args.output.stat().st_size / (1024 * 1024)
|
||||||
|
print(
|
||||||
|
f"Saved {features} listed-building points to {args.output} ({size_mb:.1f} MB)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
85
pipeline/download/lsoa_2011_to_2021.py
Normal file
85
pipeline/download/lsoa_2011_to_2021.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""Download the ONS LSOA 2011 → LSOA 2021 lookup.
|
||||||
|
|
||||||
|
Source: ONS Open Geography Portal (LSOA11_LSOA21_LAD22_EW_LU_v5)
|
||||||
|
License: Open Government Licence v3.0
|
||||||
|
|
||||||
|
The lookup tells us how 2011 LSOA boundaries map to 2021 ones. We use it to
|
||||||
|
remap older crime data (police.uk reported in 2011 codes pre-2022) into the
|
||||||
|
2021 codes the rest of the pipeline keys on, so the crime-over-time chart can
|
||||||
|
show the full history instead of only post-boundary-change years.
|
||||||
|
|
||||||
|
CHGIND values: U (unchanged), S (split into multiple 2021), M (multiple 2011
|
||||||
|
merged into one 2021), X (irregular reshape).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
BASE_URL = (
|
||||||
|
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
|
||||||
|
"LSOA11_LSOA21_LAD22_EW_LU_v5/FeatureServer/0/query"
|
||||||
|
)
|
||||||
|
PAGE_SIZE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def download(output_path: Path) -> None:
|
||||||
|
print("Downloading ONS LSOA 2011 → 2021 lookup...")
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"where": "1=1",
|
||||||
|
"outFields": "LSOA11CD,LSOA21CD,CHGIND",
|
||||||
|
"returnGeometry": "false",
|
||||||
|
"orderByFields": "LSOA11CD",
|
||||||
|
"f": "json",
|
||||||
|
"resultRecordCount": str(PAGE_SIZE),
|
||||||
|
"resultOffset": str(offset),
|
||||||
|
}
|
||||||
|
response = httpx.get(BASE_URL, params=params, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
features = data.get("features", [])
|
||||||
|
if not features:
|
||||||
|
break
|
||||||
|
for feat in features:
|
||||||
|
attrs = feat.get("attributes", {})
|
||||||
|
if attrs.get("LSOA11CD") and attrs.get("LSOA21CD"):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"lsoa11": attrs["LSOA11CD"],
|
||||||
|
"lsoa21": attrs["LSOA21CD"],
|
||||||
|
"chgind": attrs.get("CHGIND") or "U",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f" Fetched {len(features)} rows (offset={offset})")
|
||||||
|
if not data.get("exceededTransferLimit") and len(features) < PAGE_SIZE:
|
||||||
|
break
|
||||||
|
offset += len(features)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
raise RuntimeError("ONS lookup returned no rows")
|
||||||
|
|
||||||
|
df = pl.DataFrame(rows)
|
||||||
|
# England-only matches the rest of the pipeline.
|
||||||
|
df = df.filter(pl.col("lsoa11").str.starts_with("E"))
|
||||||
|
print(f"England LSOA mappings: {df.height}")
|
||||||
|
print(f" CHGIND breakdown: {df.group_by('chgind').len().sort('chgind').to_dicts()}")
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
df.write_parquet(output_path, compression="zstd")
|
||||||
|
print(f"Saved to {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Download ONS LSOA 2011 → 2021 lookup")
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
download(args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -148,6 +148,20 @@ def _looks_like_tiff(response: httpx.Response) -> bool:
|
||||||
return "tiff" in content_type or response.content[:4] in (b"II*\x00", b"MM\x00*")
|
return "tiff" in content_type or response.content[:4] in (b"II*\x00", b"MM\x00*")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_geotiff(path: Path) -> None:
|
||||||
|
"""Open and fully decode the raster to catch truncated/corrupt downloads.
|
||||||
|
|
||||||
|
The WCS endpoint occasionally returns a TIFF that opens cleanly (valid
|
||||||
|
header + IFD) but whose encoded pixel data is truncated. The corruption
|
||||||
|
only surfaces when rasterio actually decodes a strip/tile.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with rasterio.open(path) as src:
|
||||||
|
src.read(1)
|
||||||
|
except (rasterio.errors.RasterioIOError, rasterio.errors.RasterioError) as e:
|
||||||
|
raise NoGeoTiffError(f"Downloaded TIFF failed to decode: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
def _fetch_tile_bytes(
|
def _fetch_tile_bytes(
|
||||||
wcs_base: str,
|
wcs_base: str,
|
||||||
coverage_id: str,
|
coverage_id: str,
|
||||||
|
|
@ -216,7 +230,17 @@ def _download_tile(
|
||||||
content = _fetch_tile_bytes(
|
content = _fetch_tile_bytes(
|
||||||
wcs_base, coverage_id, min_e, min_n, max_e, max_n, wcs_version
|
wcs_base, coverage_id, min_e, min_n, max_e, max_n, wcs_version
|
||||||
)
|
)
|
||||||
tile_path.write_bytes(content)
|
# Write to a sibling temp file and rename atomically so partial
|
||||||
|
# writes (or truncated bodies that pass the magic-byte sniff but
|
||||||
|
# fail full decode) never poison the cache.
|
||||||
|
tmp_path = tile_path.with_suffix(tile_path.suffix + ".part")
|
||||||
|
tmp_path.write_bytes(content)
|
||||||
|
try:
|
||||||
|
_validate_geotiff(tmp_path)
|
||||||
|
except NoGeoTiffError:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
tmp_path.replace(tile_path)
|
||||||
return [tile_path], []
|
return [tile_path], []
|
||||||
except (
|
except (
|
||||||
NoGeoTiffError,
|
NoGeoTiffError,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,27 @@ import polars as pl
|
||||||
STREET_CRIME_CSV_RE = re.compile(r"^\d{4}-\d{2}-.+-street\.csv$")
|
STREET_CRIME_CSV_RE = re.compile(r"^\d{4}-\d{2}-.+-street\.csv$")
|
||||||
MONTH_RE = r"^\d{4}-\d{2}$"
|
MONTH_RE = r"^\d{4}-\d{2}$"
|
||||||
|
|
||||||
|
# Crime types that roll up into "Serious crime" / "Minor crime" aggregates.
|
||||||
|
# Must match the names used in pipeline/transform/merge.py for the sum_horizontal expressions.
|
||||||
|
SERIOUS_CRIME_TYPES = (
|
||||||
|
"Violence and sexual offences",
|
||||||
|
"Robbery",
|
||||||
|
"Burglary",
|
||||||
|
"Possession of weapons",
|
||||||
|
)
|
||||||
|
MINOR_CRIME_TYPES = (
|
||||||
|
"Anti-social behaviour",
|
||||||
|
"Criminal damage and arson",
|
||||||
|
"Shoplifting",
|
||||||
|
"Bicycle theft",
|
||||||
|
"Theft from the person",
|
||||||
|
"Other theft",
|
||||||
|
"Vehicle crime",
|
||||||
|
"Public order",
|
||||||
|
"Drugs",
|
||||||
|
"Other crime",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def find_street_crime_csvs(crime_dir: Path) -> tuple[list[Path], int]:
|
def find_street_crime_csvs(crime_dir: Path) -> tuple[list[Path], int]:
|
||||||
csvs = sorted(crime_dir.rglob("*.csv"))
|
csvs = sorted(crime_dir.rglob("*.csv"))
|
||||||
|
|
@ -14,7 +35,12 @@ def find_street_crime_csvs(crime_dir: Path) -> tuple[list[Path], int]:
|
||||||
return street_csvs, len(csvs) - len(street_csvs)
|
return street_csvs, len(csvs) - len(street_csvs)
|
||||||
|
|
||||||
|
|
||||||
def transform_crime(crime_dir: Path, output_path: Path) -> None:
|
def transform_crime(
|
||||||
|
crime_dir: Path,
|
||||||
|
output_path: Path,
|
||||||
|
by_year_output_path: Path | None = None,
|
||||||
|
lsoa_lookup_path: Path | None = None,
|
||||||
|
) -> None:
|
||||||
csvs, ignored_csv_count = find_street_crime_csvs(crime_dir)
|
csvs, ignored_csv_count = find_street_crime_csvs(crime_dir)
|
||||||
if not csvs:
|
if not csvs:
|
||||||
raise FileNotFoundError(f"No street crime CSV files found in {crime_dir}")
|
raise FileNotFoundError(f"No street crime CSV files found in {crime_dir}")
|
||||||
|
|
@ -38,6 +64,8 @@ def transform_crime(crime_dir: Path, output_path: Path) -> None:
|
||||||
},
|
},
|
||||||
).select("LSOA code", "Crime type", "Month")
|
).select("LSOA code", "Crime type", "Month")
|
||||||
|
|
||||||
|
df = _apply_lsoa_2011_to_2021(df, lsoa_lookup_path)
|
||||||
|
|
||||||
valid_month_expr = pl.col("Month").str.contains(MONTH_RE)
|
valid_month_expr = pl.col("Month").str.contains(MONTH_RE)
|
||||||
valid_months = (
|
valid_months = (
|
||||||
df.filter(valid_month_expr)
|
df.filter(valid_month_expr)
|
||||||
|
|
@ -57,6 +85,9 @@ def transform_crime(crime_dir: Path, output_path: Path) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Count monthly incidents, then annualise over every valid month in the dataset.
|
# Count monthly incidents, then annualise over every valid month in the dataset.
|
||||||
|
# `_weight` (≤1) comes from the LSOA 2011→2021 lookup: 2011 LSOAs that split
|
||||||
|
# into N 2021 LSOAs contribute 1/N of their count to each child, since we
|
||||||
|
# don't know which child a given incident actually belonged to.
|
||||||
yearly_counts = (
|
yearly_counts = (
|
||||||
df.filter(
|
df.filter(
|
||||||
valid_month_expr
|
valid_month_expr
|
||||||
|
|
@ -66,7 +97,7 @@ def transform_crime(crime_dir: Path, output_path: Path) -> None:
|
||||||
& (pl.col("Crime type") != "")
|
& (pl.col("Crime type") != "")
|
||||||
)
|
)
|
||||||
.group_by("LSOA code", "Month", "Crime type")
|
.group_by("LSOA code", "Month", "Crime type")
|
||||||
.agg(pl.len().alias("count"))
|
.agg((pl.col("_weight").first() * pl.len()).alias("count"))
|
||||||
.group_by("LSOA code", "Crime type")
|
.group_by("LSOA code", "Crime type")
|
||||||
.agg(
|
.agg(
|
||||||
(pl.col("count").sum() / pl.lit(valid_month_count) * 12)
|
(pl.col("count").sum() / pl.lit(valid_month_count) * 12)
|
||||||
|
|
@ -98,6 +129,118 @@ def transform_crime(crime_dir: Path, output_path: Path) -> None:
|
||||||
wide.write_parquet(output_path, compression="zstd")
|
wide.write_parquet(output_path, compression="zstd")
|
||||||
print(f"Saved to {output_path}")
|
print(f"Saved to {output_path}")
|
||||||
|
|
||||||
|
if by_year_output_path is not None:
|
||||||
|
_write_crime_by_year(df, valid_month_expr, by_year_output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_crime_by_year(
|
||||||
|
df: pl.LazyFrame, valid_month_expr: pl.Expr, by_year_output_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Emit per-LSOA per-type per-year crime counts as nested list[struct] columns.
|
||||||
|
|
||||||
|
Partial years are scaled to a 12-month-equivalent count so cross-year trends
|
||||||
|
aren't distorted by months missing from the source data.
|
||||||
|
"""
|
||||||
|
filtered = df.filter(
|
||||||
|
valid_month_expr
|
||||||
|
& pl.col("LSOA code").is_not_null()
|
||||||
|
& (pl.col("LSOA code") != "")
|
||||||
|
& pl.col("Crime type").is_not_null()
|
||||||
|
& (pl.col("Crime type") != "")
|
||||||
|
).with_columns(pl.col("Month").str.slice(0, 4).cast(pl.Int32).alias("year"))
|
||||||
|
|
||||||
|
# Months observed *anywhere* in the dataset for each year (annualisation denominator).
|
||||||
|
# Using crime-type-specific months would over-scale years where a rare type appears
|
||||||
|
# in only some months.
|
||||||
|
months_per_year = filtered.group_by("year").agg(
|
||||||
|
pl.col("Month").n_unique().alias("months_in_year")
|
||||||
|
)
|
||||||
|
|
||||||
|
yearly_per_type = (
|
||||||
|
filtered.group_by("LSOA code", "Crime type", "year", "Month")
|
||||||
|
.agg((pl.col("_weight").first() * pl.len()).alias("count"))
|
||||||
|
.group_by("LSOA code", "Crime type", "year")
|
||||||
|
.agg(pl.col("count").sum().alias("count"))
|
||||||
|
.join(months_per_year, on="year")
|
||||||
|
.with_columns(
|
||||||
|
(pl.col("count").cast(pl.Float32) * 12.0 / pl.col("months_in_year"))
|
||||||
|
.round(1)
|
||||||
|
.alias("count")
|
||||||
|
)
|
||||||
|
.select("LSOA code", "Crime type", "year", "count")
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
|
||||||
|
if yearly_per_type.is_empty():
|
||||||
|
raise ValueError("No valid crime rows for by-year output")
|
||||||
|
|
||||||
|
serious_rollup = _rollup_long(yearly_per_type, SERIOUS_CRIME_TYPES, "Serious crime")
|
||||||
|
minor_rollup = _rollup_long(yearly_per_type, MINOR_CRIME_TYPES, "Minor crime")
|
||||||
|
combined = pl.concat([yearly_per_type, serious_rollup, minor_rollup])
|
||||||
|
|
||||||
|
by_lsoa_type = (
|
||||||
|
combined.sort("year")
|
||||||
|
.group_by("LSOA code", "Crime type")
|
||||||
|
.agg(pl.struct("year", "count").alias("series"))
|
||||||
|
)
|
||||||
|
|
||||||
|
wide_by_year = by_lsoa_type.pivot(
|
||||||
|
on="Crime type", index="LSOA code", values="series"
|
||||||
|
)
|
||||||
|
|
||||||
|
type_cols = [c for c in wide_by_year.columns if c != "LSOA code"]
|
||||||
|
wide_by_year = wide_by_year.rename({col: f"{col} (by year)" for col in type_cols})
|
||||||
|
|
||||||
|
print(f"By-year output shape: {wide_by_year.shape}")
|
||||||
|
print(f"By-year columns: {wide_by_year.columns}")
|
||||||
|
|
||||||
|
wide_by_year.write_parquet(by_year_output_path, compression="zstd")
|
||||||
|
print(f"Saved by-year output to {by_year_output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _rollup_long(
|
||||||
|
yearly_per_type: pl.DataFrame, types: tuple[str, ...], rollup_name: str
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""Sum per-year counts across a set of crime types into a single rollup type."""
|
||||||
|
return (
|
||||||
|
yearly_per_type.filter(pl.col("Crime type").is_in(list(types)))
|
||||||
|
.group_by("LSOA code", "year")
|
||||||
|
.agg(pl.col("count").sum().round(1).alias("count"))
|
||||||
|
.with_columns(pl.lit(rollup_name).alias("Crime type"))
|
||||||
|
.select("LSOA code", "Crime type", "year", "count")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_lsoa_2011_to_2021(
|
||||||
|
df: pl.LazyFrame, lsoa_lookup_path: Path | None
|
||||||
|
) -> pl.LazyFrame:
|
||||||
|
"""Remap pre-2022 LSOA 2011 codes to LSOA 2021 codes.
|
||||||
|
|
||||||
|
Police.uk reports older years using LSOA 2011 codes; the rest of the pipeline
|
||||||
|
keys on LSOA 2021. Without remapping, those years silently fail to join and
|
||||||
|
the crime-over-time chart only shows post-2022 data.
|
||||||
|
|
||||||
|
For 1:1 mappings the LSOA code is rewritten in place. For 1→N splits (one
|
||||||
|
2011 LSOA becoming several 2021 ones), each child gets an even share via
|
||||||
|
`_weight = 1/N` since the source CSVs don't tell us which child a given
|
||||||
|
incident actually fell into.
|
||||||
|
"""
|
||||||
|
if lsoa_lookup_path is None:
|
||||||
|
return df.with_columns(pl.lit(1.0).alias("_weight"))
|
||||||
|
|
||||||
|
lookup = pl.scan_parquet(lsoa_lookup_path).select("lsoa11", "lsoa21")
|
||||||
|
weighted = lookup.with_columns(
|
||||||
|
(1.0 / pl.col("lsoa21").count().over("lsoa11")).alias("_weight")
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
df.join(weighted, left_on="LSOA code", right_on="lsoa11", how="left")
|
||||||
|
.with_columns(
|
||||||
|
pl.coalesce("lsoa21", "LSOA code").alias("LSOA code"),
|
||||||
|
pl.col("_weight").fill_null(1.0),
|
||||||
|
)
|
||||||
|
.drop("lsoa21")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|
@ -109,8 +252,22 @@ def main() -> None:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", type=Path, required=True, help="Output parquet file path"
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-by-year",
|
||||||
|
type=Path,
|
||||||
|
required=False,
|
||||||
|
help="Optional output parquet for per-LSOA per-year per-type counts (nested list[struct])",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lsoa-lookup",
|
||||||
|
type=Path,
|
||||||
|
required=False,
|
||||||
|
help="Optional parquet with columns (lsoa11, lsoa21) for remapping pre-2022 codes",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
transform_crime(args.input, args.output)
|
transform_crime(
|
||||||
|
args.input, args.output, args.output_by_year, args.lsoa_lookup
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
159
pipeline/transform/crime_hotspot_tiles.py
Normal file
159
pipeline/transform/crime_hotspot_tiles.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""Build PMTiles point tiles for the crime heatmap overlay.
|
||||||
|
|
||||||
|
The output intentionally keeps point features rather than H3/grid aggregates so
|
||||||
|
MapLibre can render a true client-side heatmap. Police.uk coordinates are
|
||||||
|
published anonymous map points, not exact offence locations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from pipeline.local_temp import local_tmp_dir
|
||||||
|
from pipeline.transform.crime import find_street_crime_csvs
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_months(crime_dir: Path, month_count: int) -> list[str]:
|
||||||
|
csvs, _ignored = find_street_crime_csvs(crime_dir)
|
||||||
|
months = sorted({path.parent.name for path in csvs})
|
||||||
|
if not months:
|
||||||
|
raise FileNotFoundError(f"No street crime CSVs found in {crime_dir}")
|
||||||
|
return months[-month_count:]
|
||||||
|
|
||||||
|
|
||||||
|
def _street_csvs_for_months(crime_dir: Path, months: set[str]) -> list[Path]:
|
||||||
|
csvs, _ignored = find_street_crime_csvs(crime_dir)
|
||||||
|
selected = [path for path in csvs if path.parent.name in months]
|
||||||
|
if not selected:
|
||||||
|
raise FileNotFoundError(f"No street crime CSVs found for {sorted(months)}")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _require_tippecanoe() -> str:
|
||||||
|
executable = shutil.which("tippecanoe")
|
||||||
|
if executable is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"tippecanoe is required to build crime hotspot PMTiles. "
|
||||||
|
"Install tippecanoe and rerun this target."
|
||||||
|
)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
def _write_geojsonseq(csvs: list[Path], output_path: Path) -> int:
|
||||||
|
df = (
|
||||||
|
pl.scan_csv(
|
||||||
|
csvs,
|
||||||
|
schema_overrides={
|
||||||
|
"Longitude": pl.Float64,
|
||||||
|
"Latitude": pl.Float64,
|
||||||
|
"Month": pl.Utf8,
|
||||||
|
"Crime type": pl.Utf8,
|
||||||
|
},
|
||||||
|
ignore_errors=True,
|
||||||
|
)
|
||||||
|
.select(
|
||||||
|
pl.col("Longitude").alias("lon"),
|
||||||
|
pl.col("Latitude").alias("lat"),
|
||||||
|
pl.col("Month").alias("month"),
|
||||||
|
pl.col("Crime type").alias("crime_type"),
|
||||||
|
)
|
||||||
|
.drop_nulls(["lon", "lat"])
|
||||||
|
.filter(pl.col("lon").is_between(-9.5, 5.0))
|
||||||
|
.filter(pl.col("lat").is_between(49.0, 57.0))
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
|
||||||
|
with output_path.open("w") as file:
|
||||||
|
for row in df.iter_rows(named=True):
|
||||||
|
feature = {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [row["lon"], row["lat"]],
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"count": 1,
|
||||||
|
"weight": 1,
|
||||||
|
"month": row["month"],
|
||||||
|
"crime_type": row["crime_type"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
file.write(json.dumps(feature, separators=(",", ":")) + "\n")
|
||||||
|
|
||||||
|
return df.height
|
||||||
|
|
||||||
|
|
||||||
|
def build_crime_hotspot_tiles(
|
||||||
|
crime_dir: Path,
|
||||||
|
output_path: Path,
|
||||||
|
months: int,
|
||||||
|
min_zoom: int,
|
||||||
|
max_zoom: int,
|
||||||
|
) -> None:
|
||||||
|
tippecanoe = _require_tippecanoe()
|
||||||
|
selected_months = set(_latest_months(crime_dir, months))
|
||||||
|
csvs = _street_csvs_for_months(crime_dir, selected_months)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as tmp:
|
||||||
|
ndjson_path = Path(tmp) / "crime_hotspots.geojsonseq"
|
||||||
|
feature_count = _write_geojsonseq(csvs, ndjson_path)
|
||||||
|
print(
|
||||||
|
f"Writing {feature_count:,} approximate crime heatmap points "
|
||||||
|
f"from {min(selected_months)} to {max(selected_months)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
tippecanoe,
|
||||||
|
"--force",
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
"--layer",
|
||||||
|
"crime_hotspots",
|
||||||
|
"--minimum-zoom",
|
||||||
|
str(min_zoom),
|
||||||
|
"--maximum-zoom",
|
||||||
|
str(max_zoom),
|
||||||
|
"--drop-densest-as-needed",
|
||||||
|
"--extend-zooms-if-still-dropping",
|
||||||
|
str(ndjson_path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--input", type=Path, required=True, help="Crime CSV directory")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output .pmtiles path"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--months",
|
||||||
|
type=int,
|
||||||
|
default=12,
|
||||||
|
help="Latest complete months to include in the heatmap",
|
||||||
|
)
|
||||||
|
parser.add_argument("--min-zoom", type=int, default=12)
|
||||||
|
parser.add_argument("--max-zoom", type=int, default=16)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
build_crime_hotspot_tiles(
|
||||||
|
args.input,
|
||||||
|
args.output,
|
||||||
|
args.months,
|
||||||
|
args.min_zoom,
|
||||||
|
args.max_zoom,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -1,12 +1,27 @@
|
||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyogrio
|
||||||
|
from pyproj import Transformer
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
from shapely import from_wkb, points
|
||||||
|
from shapely.geometry.base import BaseGeometry
|
||||||
|
from shapely.strtree import STRtree
|
||||||
|
from thefuzz import fuzz
|
||||||
|
|
||||||
|
from pipeline.utils.fuzzy_join import normalize_address_key
|
||||||
from pipeline.utils.postcode_mapping import build_postcode_mapping
|
from pipeline.utils.postcode_mapping import build_postcode_mapping
|
||||||
|
|
||||||
MIN_FLOOR_AREA_M2 = 10
|
MIN_FLOOR_AREA_M2 = 10
|
||||||
|
CONSERVATION_AREA_FEATURE = "Within conservation area"
|
||||||
|
LISTED_BUILDING_FEATURE = "Listed building"
|
||||||
|
LISTED_BUILDING_MATCH_RADIUS_M = 250.0
|
||||||
|
LISTED_BUILDING_NEAREST_POSTCODES = 3
|
||||||
|
LISTED_BUILDING_MIN_MATCH_SCORE = 95
|
||||||
|
|
||||||
_IOD_PERCENTILE_COLUMNS = [
|
_IOD_PERCENTILE_COLUMNS = [
|
||||||
"Education, Skills and Training Score",
|
"Education, Skills and Training Score",
|
||||||
|
|
@ -24,6 +39,8 @@ _AREA_COLUMNS = [
|
||||||
"lon",
|
"lon",
|
||||||
# Runtime provenance for deciding whether missing coordinates are skippable.
|
# Runtime provenance for deciding whether missing coordinates are skippable.
|
||||||
"ctry25cd",
|
"ctry25cd",
|
||||||
|
# Keyed lookup for postcode-level side tables (e.g. crime time series).
|
||||||
|
"lsoa21",
|
||||||
# Deprivation
|
# Deprivation
|
||||||
"Income Score",
|
"Income Score",
|
||||||
"Employment Score",
|
"Employment Score",
|
||||||
|
|
@ -63,6 +80,7 @@ _AREA_COLUMNS = [
|
||||||
# Environment
|
# Environment
|
||||||
"Noise (dB)",
|
"Noise (dB)",
|
||||||
"Max available download speed (Mbps)",
|
"Max available download speed (Mbps)",
|
||||||
|
CONSERVATION_AREA_FEATURE,
|
||||||
# Schools
|
# Schools
|
||||||
"Good+ primary schools within 5km",
|
"Good+ primary schools within 5km",
|
||||||
"Good+ secondary schools within 5km",
|
"Good+ secondary schools within 5km",
|
||||||
|
|
@ -97,6 +115,20 @@ _RENT_SOURCE_UNAVAILABLE_LADS = {
|
||||||
"E06000053": "Isles of Scilly",
|
"E06000053": "Isles of Scilly",
|
||||||
"E09000001": "City of London",
|
"E09000001": "City of London",
|
||||||
}
|
}
|
||||||
|
_NUMBER_RE = re.compile(r"\d+")
|
||||||
|
_LISTED_NAME_STOP_WORDS = {
|
||||||
|
"A",
|
||||||
|
"AN",
|
||||||
|
"AND",
|
||||||
|
"AT",
|
||||||
|
"BY",
|
||||||
|
"IN",
|
||||||
|
"OF",
|
||||||
|
"ON",
|
||||||
|
"THE",
|
||||||
|
"TO",
|
||||||
|
"WITH",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_dynamic_poi_metric_column(column: str) -> bool:
|
def _is_dynamic_poi_metric_column(column: str) -> bool:
|
||||||
|
|
@ -105,6 +137,389 @@ def _is_dynamic_poi_metric_column(column: str) -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _numbers_compatible(left: str, right: str) -> bool:
|
||||||
|
"""Require address/list-entry numbers to agree when either side has numbers."""
|
||||||
|
left_nums = set(_NUMBER_RE.findall(left))
|
||||||
|
right_nums = set(_NUMBER_RE.findall(right))
|
||||||
|
smaller, larger = (
|
||||||
|
(left_nums, right_nums)
|
||||||
|
if len(left_nums) <= len(right_nums)
|
||||||
|
else (right_nums, left_nums)
|
||||||
|
)
|
||||||
|
if not smaller and larger:
|
||||||
|
return False
|
||||||
|
return smaller.issubset(larger)
|
||||||
|
|
||||||
|
|
||||||
|
def _listed_candidate_schema() -> dict[str, pl.DataType]:
|
||||||
|
return {
|
||||||
|
"postcode": pl.Utf8,
|
||||||
|
"_listed_match_name": pl.Utf8,
|
||||||
|
"_listed_grade": pl.Utf8,
|
||||||
|
"_listed_entry": pl.Int64,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_listed_candidates() -> pl.DataFrame:
|
||||||
|
return pl.DataFrame(schema=_listed_candidate_schema())
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_listed_property_flags() -> pl.DataFrame:
|
||||||
|
return pl.DataFrame(
|
||||||
|
schema={
|
||||||
|
"postcode": pl.Utf8,
|
||||||
|
"pp_address": pl.Utf8,
|
||||||
|
LISTED_BUILDING_FEATURE: pl.Utf8,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_matchable_listed_name(name_key: str | None) -> bool:
|
||||||
|
if not name_key:
|
||||||
|
return False
|
||||||
|
if _NUMBER_RE.search(name_key):
|
||||||
|
return True
|
||||||
|
substantive_tokens = [
|
||||||
|
token
|
||||||
|
for token in name_key.split()
|
||||||
|
if token not in _LISTED_NAME_STOP_WORDS and len(token) >= 3
|
||||||
|
]
|
||||||
|
return len(substantive_tokens) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def _load_listed_building_points(listed_buildings_path: Path) -> pl.DataFrame:
|
||||||
|
"""Load Historic England NHLE listed-building point attributes."""
|
||||||
|
columns = ["ListEntry", "Name", "Grade", "Easting", "Northing"]
|
||||||
|
info = pyogrio.read_info(listed_buildings_path)
|
||||||
|
geometry_type = str(info.get("geometry_type") or "")
|
||||||
|
if "Point" not in geometry_type:
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected listed-building point data, got geometry {geometry_type!r}"
|
||||||
|
)
|
||||||
|
_, table = pyogrio.read_arrow(
|
||||||
|
listed_buildings_path,
|
||||||
|
columns=columns,
|
||||||
|
read_geometry=False,
|
||||||
|
)
|
||||||
|
df = pl.from_arrow(table)
|
||||||
|
missing = sorted(set(columns) - set(df.columns))
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"{listed_buildings_path} is missing listed-building columns: {missing}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
df.select(
|
||||||
|
pl.col("ListEntry").cast(pl.Int64),
|
||||||
|
pl.col("Name").cast(pl.Utf8),
|
||||||
|
pl.col("Grade").cast(pl.Utf8),
|
||||||
|
pl.col("Easting").cast(pl.Float64),
|
||||||
|
pl.col("Northing").cast(pl.Float64),
|
||||||
|
)
|
||||||
|
.drop_nulls(["Name", "Easting", "Northing"])
|
||||||
|
.with_columns(normalize_address_key(pl.col("Name")).alias("_listed_match_name"))
|
||||||
|
.filter(pl.col("_listed_match_name").is_not_null())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _postcode_listed_building_candidates(
|
||||||
|
listed_points: pl.DataFrame,
|
||||||
|
active_postcodes: pl.DataFrame,
|
||||||
|
*,
|
||||||
|
nearest_postcodes: int = LISTED_BUILDING_NEAREST_POSTCODES,
|
||||||
|
max_distance_m: float = LISTED_BUILDING_MATCH_RADIUS_M,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""Assign each listed-building point to nearby active postcode candidates."""
|
||||||
|
if listed_points.is_empty() or active_postcodes.is_empty():
|
||||||
|
return _empty_listed_candidates()
|
||||||
|
|
||||||
|
required_postcode_cols = {"postcode", "east1m", "north1m"}
|
||||||
|
missing = sorted(required_postcode_cols - set(active_postcodes.columns))
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Active postcode data missing required columns: {missing}")
|
||||||
|
|
||||||
|
required_listed_cols = {
|
||||||
|
"_listed_match_name",
|
||||||
|
"Grade",
|
||||||
|
"ListEntry",
|
||||||
|
"Easting",
|
||||||
|
"Northing",
|
||||||
|
}
|
||||||
|
missing = sorted(required_listed_cols - set(listed_points.columns))
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Listed-building data missing required columns: {missing}")
|
||||||
|
|
||||||
|
postcodes = active_postcodes.drop_nulls(["postcode", "east1m", "north1m"])
|
||||||
|
postcodes = postcodes.filter(
|
||||||
|
pl.col("east1m").is_finite() & pl.col("north1m").is_finite()
|
||||||
|
)
|
||||||
|
listed = listed_points.drop_nulls(["_listed_match_name", "Easting", "Northing"])
|
||||||
|
listed = listed.filter(
|
||||||
|
pl.col("Easting").is_finite() & pl.col("Northing").is_finite()
|
||||||
|
)
|
||||||
|
if postcodes.is_empty() or listed.is_empty():
|
||||||
|
return _empty_listed_candidates()
|
||||||
|
|
||||||
|
postcode_coords = np.column_stack(
|
||||||
|
[postcodes["east1m"].to_numpy(), postcodes["north1m"].to_numpy()]
|
||||||
|
)
|
||||||
|
listed_coords = np.column_stack(
|
||||||
|
[listed["Easting"].to_numpy(), listed["Northing"].to_numpy()]
|
||||||
|
)
|
||||||
|
k = max(1, min(nearest_postcodes, postcodes.height))
|
||||||
|
distances, indices = cKDTree(postcode_coords).query(
|
||||||
|
listed_coords,
|
||||||
|
k=k,
|
||||||
|
distance_upper_bound=max_distance_m,
|
||||||
|
)
|
||||||
|
if k == 1:
|
||||||
|
distances = distances[:, np.newaxis]
|
||||||
|
indices = indices[:, np.newaxis]
|
||||||
|
|
||||||
|
postcode_values = postcodes["postcode"].to_list()
|
||||||
|
listed_names = listed["_listed_match_name"].to_list()
|
||||||
|
listed_grades = listed["Grade"].to_list()
|
||||||
|
listed_entries = listed["ListEntry"].to_list()
|
||||||
|
|
||||||
|
rows: list[tuple[str, str, str | None, int | None]] = []
|
||||||
|
for listed_idx in range(listed.height):
|
||||||
|
name_key = listed_names[listed_idx]
|
||||||
|
if not _is_matchable_listed_name(name_key):
|
||||||
|
continue
|
||||||
|
seen_postcodes: set[str] = set()
|
||||||
|
for distance, postcode_idx in zip(distances[listed_idx], indices[listed_idx]):
|
||||||
|
if not np.isfinite(distance) or postcode_idx >= postcodes.height:
|
||||||
|
continue
|
||||||
|
postcode = postcode_values[int(postcode_idx)]
|
||||||
|
if postcode in seen_postcodes:
|
||||||
|
continue
|
||||||
|
seen_postcodes.add(postcode)
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
postcode,
|
||||||
|
name_key,
|
||||||
|
listed_grades[listed_idx],
|
||||||
|
listed_entries[listed_idx],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return _empty_listed_candidates()
|
||||||
|
|
||||||
|
return (
|
||||||
|
pl.DataFrame(
|
||||||
|
rows,
|
||||||
|
schema=[
|
||||||
|
"postcode",
|
||||||
|
"_listed_match_name",
|
||||||
|
"_listed_grade",
|
||||||
|
"_listed_entry",
|
||||||
|
],
|
||||||
|
orient="row",
|
||||||
|
)
|
||||||
|
.cast(_listed_candidate_schema())
|
||||||
|
.unique(["postcode", "_listed_match_name", "_listed_entry"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_listed_building_flags(
|
||||||
|
properties: pl.LazyFrame,
|
||||||
|
listed_candidates: pl.DataFrame,
|
||||||
|
*,
|
||||||
|
min_score: int = LISTED_BUILDING_MIN_MATCH_SCORE,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
"""Return property keys that conservatively match an NHLE listed entry."""
|
||||||
|
if listed_candidates.is_empty():
|
||||||
|
return _empty_listed_property_flags()
|
||||||
|
|
||||||
|
candidate_postcodes = listed_candidates.select("postcode").unique()
|
||||||
|
property_candidates = (
|
||||||
|
properties.select("postcode", "pp_address", "epc_address")
|
||||||
|
.join(candidate_postcodes.lazy(), on="postcode", how="semi")
|
||||||
|
.with_columns(
|
||||||
|
normalize_address_key(pl.col("pp_address")).alias("_pp_match_address"),
|
||||||
|
normalize_address_key(pl.col("epc_address")).alias("_epc_match_address"),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
pl.col("pp_address").is_not_null()
|
||||||
|
& (
|
||||||
|
pl.col("_pp_match_address").is_not_null()
|
||||||
|
| pl.col("_epc_match_address").is_not_null()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
if property_candidates.is_empty():
|
||||||
|
return _empty_listed_property_flags()
|
||||||
|
|
||||||
|
listed_by_postcode: dict[str, list[str]] = {}
|
||||||
|
for postcode, name in listed_candidates.select(
|
||||||
|
"postcode", "_listed_match_name"
|
||||||
|
).iter_rows():
|
||||||
|
if postcode and name:
|
||||||
|
listed_by_postcode.setdefault(postcode, []).append(name)
|
||||||
|
|
||||||
|
matches: list[tuple[str, str, str]] = []
|
||||||
|
for row in property_candidates.iter_rows(named=True):
|
||||||
|
postcode = row["postcode"]
|
||||||
|
listed_names = listed_by_postcode.get(postcode)
|
||||||
|
if not listed_names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
address_keys = []
|
||||||
|
for col in ("_pp_match_address", "_epc_match_address"):
|
||||||
|
value = row.get(col)
|
||||||
|
if value and value not in address_keys:
|
||||||
|
address_keys.append(value)
|
||||||
|
|
||||||
|
matched = False
|
||||||
|
for address_key in address_keys:
|
||||||
|
for listed_name in listed_names:
|
||||||
|
if not _numbers_compatible(address_key, listed_name):
|
||||||
|
continue
|
||||||
|
if fuzz.token_set_ratio(address_key, listed_name) >= min_score:
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if matched:
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched:
|
||||||
|
matches.append((postcode, row["pp_address"], "Yes"))
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return _empty_listed_property_flags()
|
||||||
|
|
||||||
|
return (
|
||||||
|
pl.DataFrame(
|
||||||
|
matches,
|
||||||
|
schema=["postcode", "pp_address", LISTED_BUILDING_FEATURE],
|
||||||
|
orient="row",
|
||||||
|
)
|
||||||
|
.cast(
|
||||||
|
{
|
||||||
|
"postcode": pl.Utf8,
|
||||||
|
"pp_address": pl.Utf8,
|
||||||
|
LISTED_BUILDING_FEATURE: pl.Utf8,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.unique(["postcode", "pp_address"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _listed_building_flags(
|
||||||
|
properties: pl.LazyFrame,
|
||||||
|
active_postcodes: pl.DataFrame,
|
||||||
|
listed_buildings_path: Path,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
print(f"Loading listed-building points from {listed_buildings_path}...")
|
||||||
|
listed_points = _load_listed_building_points(listed_buildings_path)
|
||||||
|
print(f"Loaded {listed_points.height} listed-building point records")
|
||||||
|
listed_candidates = _postcode_listed_building_candidates(
|
||||||
|
listed_points, active_postcodes
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Matching listed-building names to property addresses across "
|
||||||
|
f"{listed_candidates['postcode'].n_unique()} nearby postcodes..."
|
||||||
|
)
|
||||||
|
flags = _matched_listed_building_flags(properties, listed_candidates)
|
||||||
|
print(f"Matched {flags.height} property addresses to listed-building entries")
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_crs(crs: object | None) -> str:
|
||||||
|
return str(crs) if crs else "EPSG:4326"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_conservation_area_geometries(
|
||||||
|
conservation_areas_path: Path,
|
||||||
|
) -> tuple[list[BaseGeometry], str]:
|
||||||
|
metadata, table = pyogrio.read_arrow(conservation_areas_path, columns=[])
|
||||||
|
geometry_name = metadata.get("geometry_name") or table.column_names[-1]
|
||||||
|
geometries = []
|
||||||
|
for geom in from_wkb(table[geometry_name].combine_chunks().to_pylist()):
|
||||||
|
if geom is not None and not geom.is_empty:
|
||||||
|
geometries.append(geom)
|
||||||
|
if not geometries:
|
||||||
|
raise ValueError(
|
||||||
|
f"{conservation_areas_path} does not contain any usable polygon geometries"
|
||||||
|
)
|
||||||
|
return geometries, _normalise_crs(metadata.get("crs"))
|
||||||
|
|
||||||
|
|
||||||
|
def _postcode_conservation_area_flags(
|
||||||
|
postcodes: pl.DataFrame,
|
||||||
|
conservation_geometries: list[BaseGeometry],
|
||||||
|
conservation_crs: object | None,
|
||||||
|
batch_size: int = 100_000,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
required = {"postcode", "lat", "lon"}
|
||||||
|
missing = sorted(required - set(postcodes.columns))
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Postcode data missing required columns: {missing}")
|
||||||
|
|
||||||
|
all_postcodes = postcodes.select("postcode").drop_nulls().unique()
|
||||||
|
valid_points = postcodes.select("postcode", "lat", "lon").drop_nulls()
|
||||||
|
if valid_points.is_empty():
|
||||||
|
return all_postcodes.with_columns(pl.lit("No").alias(CONSERVATION_AREA_FEATURE))
|
||||||
|
|
||||||
|
lat = valid_points["lat"].to_numpy()
|
||||||
|
lon = valid_points["lon"].to_numpy()
|
||||||
|
finite = np.isfinite(lat) & np.isfinite(lon)
|
||||||
|
valid_points = valid_points.filter(pl.Series(finite))
|
||||||
|
if valid_points.is_empty():
|
||||||
|
return all_postcodes.with_columns(pl.lit("No").alias(CONSERVATION_AREA_FEATURE))
|
||||||
|
|
||||||
|
lat = valid_points["lat"].to_numpy()
|
||||||
|
lon = valid_points["lon"].to_numpy()
|
||||||
|
transformer = Transformer.from_crs(
|
||||||
|
"EPSG:4326", _normalise_crs(conservation_crs), always_xy=True
|
||||||
|
)
|
||||||
|
x, y = transformer.transform(lon, lat)
|
||||||
|
|
||||||
|
tree = STRtree(conservation_geometries)
|
||||||
|
inside = np.zeros(valid_points.height, dtype=bool)
|
||||||
|
for start in range(0, valid_points.height, batch_size):
|
||||||
|
end = min(start + batch_size, valid_points.height)
|
||||||
|
point_batch = points(x[start:end], y[start:end])
|
||||||
|
matches = tree.query(point_batch, predicate="intersects")
|
||||||
|
if matches.size > 0:
|
||||||
|
inside[start + matches[0]] = True
|
||||||
|
|
||||||
|
matched = (
|
||||||
|
valid_points.select("postcode")
|
||||||
|
.with_columns(pl.Series("_within_conservation_area", inside))
|
||||||
|
.group_by("postcode")
|
||||||
|
.agg(pl.col("_within_conservation_area").max())
|
||||||
|
.with_columns(
|
||||||
|
pl.when(pl.col("_within_conservation_area"))
|
||||||
|
.then(pl.lit("Yes"))
|
||||||
|
.otherwise(pl.lit("No"))
|
||||||
|
.alias(CONSERVATION_AREA_FEATURE)
|
||||||
|
)
|
||||||
|
.select("postcode", CONSERVATION_AREA_FEATURE)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
all_postcodes.join(matched, on="postcode", how="left")
|
||||||
|
.with_columns(pl.col(CONSERVATION_AREA_FEATURE).fill_null("No"))
|
||||||
|
.select("postcode", CONSERVATION_AREA_FEATURE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _conservation_area_by_postcode(
|
||||||
|
postcodes: pl.LazyFrame,
|
||||||
|
conservation_areas_path: Path,
|
||||||
|
) -> pl.LazyFrame:
|
||||||
|
print(f"Loading conservation area polygons from {conservation_areas_path}...")
|
||||||
|
geometries, crs = _load_conservation_area_geometries(conservation_areas_path)
|
||||||
|
postcode_points = postcodes.select("postcode", "lat", "lon").collect(
|
||||||
|
engine="streaming"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Computing conservation area membership for "
|
||||||
|
f"{postcode_points.height} active English postcodes..."
|
||||||
|
)
|
||||||
|
return _postcode_conservation_area_flags(postcode_points, geometries, crs).lazy()
|
||||||
|
|
||||||
|
|
||||||
def _less_deprived_percentile_expr(column: str) -> pl.Expr:
|
def _less_deprived_percentile_expr(column: str) -> pl.Expr:
|
||||||
"""Convert an IoD deprivation score to a 0-100 less-deprived percentile."""
|
"""Convert an IoD deprivation score to a 0-100 less-deprived percentile."""
|
||||||
non_null_count = pl.col(column).count()
|
non_null_count = pl.col(column).count()
|
||||||
|
|
@ -234,11 +649,13 @@ def _build(
|
||||||
noise_path: Path,
|
noise_path: Path,
|
||||||
school_proximity_path: Path,
|
school_proximity_path: Path,
|
||||||
broadband_path: Path,
|
broadband_path: Path,
|
||||||
|
conservation_areas_path: Path,
|
||||||
rental_prices_path: Path,
|
rental_prices_path: Path,
|
||||||
lsoa_population_path: Path,
|
lsoa_population_path: Path,
|
||||||
median_age_path: Path,
|
median_age_path: Path,
|
||||||
election_results_path: Path,
|
election_results_path: Path,
|
||||||
tree_density_postcodes_path: Path | None = None,
|
tree_density_postcodes_path: Path | None = None,
|
||||||
|
listed_buildings_path: Path | None = None,
|
||||||
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
||||||
"""Build postcode and properties dataframes from epc_pp + auxiliary data.
|
"""Build postcode and properties dataframes from epc_pp + auxiliary data.
|
||||||
|
|
||||||
|
|
@ -273,6 +690,29 @@ def _build(
|
||||||
).unique(["postcode"])
|
).unique(["postcode"])
|
||||||
wide = wide.join(postcode_country, on="postcode", how="left")
|
wide = wide.join(postcode_country, on="postcode", how="left")
|
||||||
|
|
||||||
|
if listed_buildings_path is not None:
|
||||||
|
active_postcodes_for_listed = (
|
||||||
|
arcgis_raw.filter(pl.col("ctry25cd") == "E92000001")
|
||||||
|
.filter(pl.col("doterm").is_null())
|
||||||
|
.select(
|
||||||
|
pl.col("pcds").alias("postcode"),
|
||||||
|
"east1m",
|
||||||
|
"north1m",
|
||||||
|
)
|
||||||
|
.collect(engine="streaming")
|
||||||
|
)
|
||||||
|
listed_flags = _listed_building_flags(
|
||||||
|
wide.select("postcode", "pp_address", "epc_address"),
|
||||||
|
active_postcodes_for_listed,
|
||||||
|
listed_buildings_path,
|
||||||
|
)
|
||||||
|
wide = wide.join(listed_flags.lazy(), on=["postcode", "pp_address"], how="left")
|
||||||
|
else:
|
||||||
|
wide = wide.with_columns(
|
||||||
|
pl.lit(None, dtype=pl.Utf8).alias(LISTED_BUILDING_FEATURE)
|
||||||
|
)
|
||||||
|
wide = wide.with_columns(pl.col(LISTED_BUILDING_FEATURE).fill_null("No"))
|
||||||
|
|
||||||
arcgis = (
|
arcgis = (
|
||||||
arcgis_raw.filter(pl.col("ctry25cd") == "E92000001") # England only
|
arcgis_raw.filter(pl.col("ctry25cd") == "E92000001") # England only
|
||||||
.filter(pl.col("doterm").is_null()) # Active postcodes only
|
.filter(pl.col("doterm").is_null()) # Active postcodes only
|
||||||
|
|
@ -382,6 +822,13 @@ def _build(
|
||||||
school_proximity = pl.scan_parquet(school_proximity_path)
|
school_proximity = pl.scan_parquet(school_proximity_path)
|
||||||
wide = wide.join(school_proximity, on="postcode", how="left")
|
wide = wide.join(school_proximity, on="postcode", how="left")
|
||||||
|
|
||||||
|
conservation_areas = _conservation_area_by_postcode(
|
||||||
|
arcgis.select("postcode", "lat", "lon"), conservation_areas_path
|
||||||
|
)
|
||||||
|
wide = wide.join(conservation_areas, on="postcode", how="left").with_columns(
|
||||||
|
pl.col(CONSERVATION_AREA_FEATURE).fill_null("No")
|
||||||
|
)
|
||||||
|
|
||||||
if tree_density_postcodes_path is not None:
|
if tree_density_postcodes_path is not None:
|
||||||
tree_density = _tree_density_by_postcode(tree_density_postcodes_path)
|
tree_density = _tree_density_by_postcode(tree_density_postcodes_path)
|
||||||
wide = wide.join(tree_density, on="postcode", how="left")
|
wide = wide.join(tree_density, on="postcode", how="left")
|
||||||
|
|
@ -476,7 +923,6 @@ def _build(
|
||||||
"Income Deprivation Affecting Older People (IDAOPI) Score (rate)",
|
"Income Deprivation Affecting Older People (IDAOPI) Score (rate)",
|
||||||
"Income Deprivation Affecting Children Index (IDACI) Score (rate)",
|
"Income Deprivation Affecting Children Index (IDACI) Score (rate)",
|
||||||
"Barriers to Housing and Services Score",
|
"Barriers to Housing and Services Score",
|
||||||
"lsoa21",
|
|
||||||
"oa21",
|
"oa21",
|
||||||
"pcon",
|
"pcon",
|
||||||
"epc_property_type",
|
"epc_property_type",
|
||||||
|
|
@ -598,6 +1044,18 @@ def main():
|
||||||
required=True,
|
required=True,
|
||||||
help="Broadband performance by output area parquet file",
|
help="Broadband performance by output area parquet file",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--conservation-areas",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="Historic England conservation areas GeoPackage",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--listed-buildings",
|
||||||
|
type=Path,
|
||||||
|
required=False,
|
||||||
|
help="Historic England NHLE listed-building points GeoPackage",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--rental-prices",
|
"--rental-prices",
|
||||||
type=Path,
|
type=Path,
|
||||||
|
|
@ -652,11 +1110,13 @@ def main():
|
||||||
noise_path=args.noise,
|
noise_path=args.noise,
|
||||||
school_proximity_path=args.school_proximity,
|
school_proximity_path=args.school_proximity,
|
||||||
broadband_path=args.broadband,
|
broadband_path=args.broadband,
|
||||||
|
conservation_areas_path=args.conservation_areas,
|
||||||
rental_prices_path=args.rental_prices,
|
rental_prices_path=args.rental_prices,
|
||||||
lsoa_population_path=args.lsoa_population,
|
lsoa_population_path=args.lsoa_population,
|
||||||
median_age_path=args.median_age,
|
median_age_path=args.median_age,
|
||||||
election_results_path=args.election_results,
|
election_results_path=args.election_results,
|
||||||
tree_density_postcodes_path=args.tree_density_postcodes,
|
tree_density_postcodes_path=args.tree_density_postcodes,
|
||||||
|
listed_buildings_path=args.listed_buildings,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"\nPostcode columns: {postcode_df.columns}")
|
print(f"\nPostcode columns: {postcode_df.columns}")
|
||||||
|
|
|
||||||
398
pipeline/transform/noise_overlay_tiles.py
Normal file
398
pipeline/transform/noise_overlay_tiles.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""Build PMTiles raster tiles for the high-resolution Defra noise overlay.
|
||||||
|
|
||||||
|
This keeps the native 10m strategic-noise rasters as the source of truth and
|
||||||
|
renders transparent PNG XYZ tiles into MBTiles before converting to PMTiles.
|
||||||
|
The dashboard serves the resulting archive through /api/overlays/noise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rasterio
|
||||||
|
from PIL import Image
|
||||||
|
from rasterio.enums import Resampling
|
||||||
|
from rasterio.transform import from_bounds
|
||||||
|
from rasterio.warp import reproject, transform_bounds
|
||||||
|
from shapely import STRtree, box
|
||||||
|
|
||||||
|
from pipeline.download.noise import (
|
||||||
|
BNG_MAX_E,
|
||||||
|
BNG_MAX_N,
|
||||||
|
BNG_MIN_E,
|
||||||
|
BNG_MIN_N,
|
||||||
|
NOISE_SOURCES,
|
||||||
|
download_raster,
|
||||||
|
)
|
||||||
|
from pipeline.download.tiles import ensure_pmtiles_cli
|
||||||
|
from pipeline.local_temp import local_tmp_dir
|
||||||
|
|
||||||
|
WEB_MERCATOR_CRS = "EPSG:3857"
|
||||||
|
WEB_MERCATOR_EXTENT = 20_037_508.342789244
|
||||||
|
DEFAULT_SOURCE_NAMES = ("road", "rail", "airport")
|
||||||
|
NOISE_COLOR_STOPS = np.array([45.0, 55.0, 65.0, 75.0], dtype=np.float32)
|
||||||
|
NOISE_COLORS = np.array(
|
||||||
|
[
|
||||||
|
[254, 240, 138],
|
||||||
|
[251, 146, 60],
|
||||||
|
[220, 38, 38],
|
||||||
|
[127, 29, 29],
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RasterInfo:
|
||||||
|
path: Path
|
||||||
|
bounds_mercator: tuple[float, float, float, float]
|
||||||
|
|
||||||
|
|
||||||
|
def _source_specs(source_names: tuple[str, ...]):
|
||||||
|
requested = {name.lower() for name in source_names}
|
||||||
|
if "all" in requested:
|
||||||
|
requested = set(DEFAULT_SOURCE_NAMES)
|
||||||
|
|
||||||
|
by_name = {label.lower(): spec for label, *spec in NOISE_SOURCES}
|
||||||
|
unknown = sorted(requested - set(by_name))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unknown noise source(s): {', '.join(unknown)}")
|
||||||
|
|
||||||
|
return [
|
||||||
|
(name.title(), *by_name[name])
|
||||||
|
for name in DEFAULT_SOURCE_NAMES
|
||||||
|
if name in requested
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _download_source_rasters(
|
||||||
|
raster_dir: Path,
|
||||||
|
source_names: tuple[str, ...],
|
||||||
|
) -> list[Path]:
|
||||||
|
paths: list[Path] = []
|
||||||
|
raster_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for (
|
||||||
|
label,
|
||||||
|
_col_name,
|
||||||
|
wcs_base,
|
||||||
|
coverage_id,
|
||||||
|
wcs_version,
|
||||||
|
allow_missing_tiles,
|
||||||
|
) in _source_specs(source_names):
|
||||||
|
tile_dir = raster_dir / label.lower()
|
||||||
|
tile_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
paths.extend(
|
||||||
|
download_raster(
|
||||||
|
tile_dir,
|
||||||
|
wcs_base,
|
||||||
|
coverage_id,
|
||||||
|
label,
|
||||||
|
wcs_version,
|
||||||
|
allow_missing_tiles,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _raster_infos(raster_paths: list[Path]) -> list[RasterInfo]:
|
||||||
|
infos: list[RasterInfo] = []
|
||||||
|
for path in raster_paths:
|
||||||
|
with rasterio.open(path) as dataset:
|
||||||
|
if dataset.crs is None:
|
||||||
|
raise ValueError(f"Raster has no CRS: {path}")
|
||||||
|
bounds = transform_bounds(
|
||||||
|
dataset.crs,
|
||||||
|
WEB_MERCATOR_CRS,
|
||||||
|
*dataset.bounds,
|
||||||
|
densify_pts=21,
|
||||||
|
)
|
||||||
|
infos.append(RasterInfo(path=path, bounds_mercator=bounds))
|
||||||
|
return infos
|
||||||
|
|
||||||
|
|
||||||
|
def _england_bounds_wgs84() -> tuple[float, float, float, float]:
|
||||||
|
return transform_bounds(
|
||||||
|
"EPSG:27700",
|
||||||
|
"EPSG:4326",
|
||||||
|
BNG_MIN_E,
|
||||||
|
BNG_MIN_N,
|
||||||
|
BNG_MAX_E,
|
||||||
|
BNG_MAX_N,
|
||||||
|
densify_pts=21,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lonlat_to_tile(lon: float, lat: float, zoom: int) -> tuple[int, int]:
|
||||||
|
lat = max(min(lat, 85.05112878), -85.05112878)
|
||||||
|
n = 1 << zoom
|
||||||
|
x = int(math.floor((lon + 180.0) / 360.0 * n))
|
||||||
|
y = int(
|
||||||
|
math.floor((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n)
|
||||||
|
)
|
||||||
|
return min(max(x, 0), n - 1), min(max(y, 0), n - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _tile_bounds_mercator(
|
||||||
|
zoom: int, x: int, y: int
|
||||||
|
) -> tuple[float, float, float, float]:
|
||||||
|
n = 1 << zoom
|
||||||
|
tile_size_m = WEB_MERCATOR_EXTENT * 2 / n
|
||||||
|
left = -WEB_MERCATOR_EXTENT + x * tile_size_m
|
||||||
|
right = left + tile_size_m
|
||||||
|
top = WEB_MERCATOR_EXTENT - y * tile_size_m
|
||||||
|
bottom = top - tile_size_m
|
||||||
|
return left, bottom, right, top
|
||||||
|
|
||||||
|
|
||||||
|
def _read_noise_tile(
|
||||||
|
candidates: list[RasterInfo],
|
||||||
|
bounds_mercator: tuple[float, float, float, float],
|
||||||
|
tile_size: int,
|
||||||
|
) -> np.ndarray:
|
||||||
|
left, bottom, right, top = bounds_mercator
|
||||||
|
merged = np.full((tile_size, tile_size), np.nan, dtype=np.float32)
|
||||||
|
|
||||||
|
for info in candidates:
|
||||||
|
with rasterio.open(info.path) as source:
|
||||||
|
tile = np.full((tile_size, tile_size), np.nan, dtype=np.float32)
|
||||||
|
reproject(
|
||||||
|
source=rasterio.band(source, 1),
|
||||||
|
destination=tile,
|
||||||
|
src_transform=source.transform,
|
||||||
|
src_crs=source.crs,
|
||||||
|
src_nodata=source.nodata if source.nodata is not None else 0,
|
||||||
|
dst_transform=from_bounds(
|
||||||
|
left, bottom, right, top, tile_size, tile_size
|
||||||
|
),
|
||||||
|
dst_crs=WEB_MERCATOR_CRS,
|
||||||
|
dst_nodata=np.nan,
|
||||||
|
resampling=Resampling.bilinear,
|
||||||
|
)
|
||||||
|
|
||||||
|
tile[~np.isfinite(tile) | (tile <= 0)] = np.nan
|
||||||
|
merged = np.fmax(merged, tile)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_noise_png(noise_db: np.ndarray) -> bytes | None:
|
||||||
|
valid = np.isfinite(noise_db) & (noise_db >= NOISE_COLOR_STOPS[0])
|
||||||
|
if not valid.any():
|
||||||
|
return None
|
||||||
|
|
||||||
|
clipped = np.clip(noise_db, NOISE_COLOR_STOPS[0], NOISE_COLOR_STOPS[-1])
|
||||||
|
rgba = np.zeros((*noise_db.shape, 4), dtype=np.uint8)
|
||||||
|
valid_values = clipped[valid]
|
||||||
|
|
||||||
|
for channel in range(3):
|
||||||
|
channel_values = np.interp(
|
||||||
|
valid_values,
|
||||||
|
NOISE_COLOR_STOPS,
|
||||||
|
NOISE_COLORS[:, channel],
|
||||||
|
).astype(np.uint8)
|
||||||
|
rgba[..., channel][valid] = channel_values
|
||||||
|
|
||||||
|
alpha = np.interp(
|
||||||
|
valid_values,
|
||||||
|
[NOISE_COLOR_STOPS[0], NOISE_COLOR_STOPS[-1]],
|
||||||
|
[70, 190],
|
||||||
|
).astype(np.uint8)
|
||||||
|
rgba[..., 3][valid] = alpha
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
Image.fromarray(rgba, mode="RGBA").save(output, format="PNG", optimize=True)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _tile_ranges(
|
||||||
|
bounds_wgs84: tuple[float, float, float, float],
|
||||||
|
zoom: int,
|
||||||
|
) -> tuple[range, range]:
|
||||||
|
west, south, east, north = bounds_wgs84
|
||||||
|
min_x, min_y = _lonlat_to_tile(west, north, zoom)
|
||||||
|
max_x, max_y = _lonlat_to_tile(east, south, zoom)
|
||||||
|
return range(min_x, max_x + 1), range(min_y, max_y + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mbtiles(
|
||||||
|
raster_infos: list[RasterInfo],
|
||||||
|
mbtiles_path: Path,
|
||||||
|
min_zoom: int,
|
||||||
|
max_zoom: int,
|
||||||
|
tile_size: int,
|
||||||
|
) -> int:
|
||||||
|
if mbtiles_path.exists():
|
||||||
|
mbtiles_path.unlink()
|
||||||
|
|
||||||
|
bounds_wgs84 = _england_bounds_wgs84()
|
||||||
|
geometries = [box(*info.bounds_mercator) for info in raster_infos]
|
||||||
|
tree = STRtree(geometries)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(mbtiles_path)
|
||||||
|
conn.execute("CREATE TABLE metadata (name TEXT, value TEXT)")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, "
|
||||||
|
"tile_row INTEGER, tile_data BLOB)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row)"
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO metadata (name, value) VALUES (?, ?)",
|
||||||
|
[
|
||||||
|
("name", "Defra Lden noise overlay"),
|
||||||
|
("type", "overlay"),
|
||||||
|
("version", "1"),
|
||||||
|
("description", "Defra Round 4 10m strategic noise Lden overlay"),
|
||||||
|
("format", "png"),
|
||||||
|
(
|
||||||
|
"attribution",
|
||||||
|
"Contains public sector information licensed under the OGL v3.0",
|
||||||
|
),
|
||||||
|
("bounds", ",".join(f"{value:.6f}" for value in bounds_wgs84)),
|
||||||
|
("minzoom", str(min_zoom)),
|
||||||
|
("maxzoom", str(max_zoom)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
total_tiles = 0
|
||||||
|
try:
|
||||||
|
for zoom in range(min_zoom, max_zoom + 1):
|
||||||
|
x_range, y_range = _tile_ranges(bounds_wgs84, zoom)
|
||||||
|
zoom_tiles = 0
|
||||||
|
for x in x_range:
|
||||||
|
for y in y_range:
|
||||||
|
bounds_mercator = _tile_bounds_mercator(zoom, x, y)
|
||||||
|
candidate_indexes = tree.query(box(*bounds_mercator))
|
||||||
|
if len(candidate_indexes) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
raster_infos[int(index)] for index in candidate_indexes
|
||||||
|
]
|
||||||
|
tile = _read_noise_tile(candidates, bounds_mercator, tile_size)
|
||||||
|
tile_png = _encode_noise_png(tile)
|
||||||
|
if tile_png is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tms_y = (1 << zoom) - 1 - y
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tiles VALUES (?, ?, ?, ?)",
|
||||||
|
(zoom, x, tms_y, tile_png),
|
||||||
|
)
|
||||||
|
zoom_tiles += 1
|
||||||
|
total_tiles += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"Zoom {zoom}: wrote {zoom_tiles:,} PNG tiles")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return total_tiles
|
||||||
|
|
||||||
|
|
||||||
|
def build_noise_overlay_tiles(
|
||||||
|
output_path: Path,
|
||||||
|
raster_dir: Path,
|
||||||
|
source_names: tuple[str, ...],
|
||||||
|
input_rasters: tuple[Path, ...],
|
||||||
|
pmtiles_bin: Path,
|
||||||
|
pmtiles_version: str,
|
||||||
|
min_zoom: int,
|
||||||
|
max_zoom: int,
|
||||||
|
tile_size: int,
|
||||||
|
) -> None:
|
||||||
|
if min_zoom > max_zoom:
|
||||||
|
raise ValueError("--min-zoom must be <= --max-zoom")
|
||||||
|
|
||||||
|
raster_paths = list(input_rasters) or _download_source_rasters(
|
||||||
|
raster_dir, source_names
|
||||||
|
)
|
||||||
|
if not raster_paths:
|
||||||
|
raise FileNotFoundError("No noise raster GeoTIFFs available")
|
||||||
|
|
||||||
|
print(f"Preparing {len(raster_paths):,} noise raster tile(s)")
|
||||||
|
raster_infos = _raster_infos(raster_paths)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ensure_pmtiles_cli(pmtiles_bin, pmtiles_version)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as tmp:
|
||||||
|
mbtiles_path = Path(tmp) / "noise_lden_10m.mbtiles"
|
||||||
|
tile_count = _create_mbtiles(
|
||||||
|
raster_infos, mbtiles_path, min_zoom, max_zoom, tile_size
|
||||||
|
)
|
||||||
|
if tile_count == 0:
|
||||||
|
raise RuntimeError("Noise overlay generation produced no tiles")
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
str(pmtiles_bin),
|
||||||
|
"convert",
|
||||||
|
str(mbtiles_path),
|
||||||
|
str(output_path),
|
||||||
|
"--force",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
size_mb = output_path.stat().st_size / (1024 * 1024)
|
||||||
|
print(f"Wrote {output_path} ({size_mb:.1f} MB)")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--raster-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path("property-data/noise_overlay_rasters"),
|
||||||
|
help="Cache directory for downloaded Defra WCS GeoTIFF tiles",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
action="append",
|
||||||
|
dest="sources",
|
||||||
|
choices=("all", *DEFAULT_SOURCE_NAMES),
|
||||||
|
help="Noise source to include; repeatable. Defaults to all.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--input-raster",
|
||||||
|
action="append",
|
||||||
|
dest="input_rasters",
|
||||||
|
type=Path,
|
||||||
|
help="Existing GeoTIFF to render instead of downloading WCS rasters",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pmtiles-bin", type=Path, default=Path("property-data/pmtiles")
|
||||||
|
)
|
||||||
|
parser.add_argument("--pmtiles-version", default="1.22.3")
|
||||||
|
parser.add_argument("--min-zoom", type=int, default=13)
|
||||||
|
parser.add_argument("--max-zoom", type=int, default=14)
|
||||||
|
parser.add_argument("--tile-size", type=int, default=256)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
build_noise_overlay_tiles(
|
||||||
|
output_path=args.output,
|
||||||
|
raster_dir=args.raster_dir,
|
||||||
|
source_names=tuple(args.sources or ("all",)),
|
||||||
|
input_rasters=tuple(args.input_rasters or ()),
|
||||||
|
pmtiles_bin=args.pmtiles_bin,
|
||||||
|
pmtiles_version=args.pmtiles_version,
|
||||||
|
min_zoom=args.min_zoom,
|
||||||
|
max_zoom=args.max_zoom,
|
||||||
|
tile_size=args.tile_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -39,6 +39,8 @@ def main():
|
||||||
pl.col("Ofsted phase").is_in(["Primary", "Secondary"])
|
pl.col("Ofsted phase").is_in(["Primary", "Secondary"])
|
||||||
& pl.col("Latest OEIF overall effectiveness").is_in(["1", "2"])
|
& pl.col("Latest OEIF overall effectiveness").is_in(["1", "2"])
|
||||||
)
|
)
|
||||||
|
if ofsted.is_empty():
|
||||||
|
raise ValueError("No good+ primary/secondary Ofsted schools found")
|
||||||
|
|
||||||
print(f"Good+ schools: {len(ofsted):,}")
|
print(f"Good+ schools: {len(ofsted):,}")
|
||||||
print(
|
print(
|
||||||
|
|
@ -74,6 +76,8 @@ def main():
|
||||||
)
|
)
|
||||||
|
|
||||||
schools = ofsted.join(arcgis, on="postcode", how="inner")
|
schools = ofsted.join(arcgis, on="postcode", how="inner")
|
||||||
|
if schools.is_empty():
|
||||||
|
raise ValueError("No Ofsted schools matched ArcGIS postcode coordinates")
|
||||||
print(f"Schools with coordinates: {len(schools):,}")
|
print(f"Schools with coordinates: {len(schools):,}")
|
||||||
|
|
||||||
# Load all postcodes for proximity counting
|
# Load all postcodes for proximity counting
|
||||||
|
|
@ -88,6 +92,7 @@ def main():
|
||||||
|
|
||||||
result = counts_5km.join(counts_2km, on="postcode")
|
result = counts_5km.join(counts_2km, on="postcode")
|
||||||
|
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
result.write_parquet(args.output)
|
result.write_parquet(args.output)
|
||||||
size_mb = args.output.stat().st_size / (1024 * 1024)
|
size_mb = args.output.stat().st_size / (1024 * 1024)
|
||||||
print(f"Wrote {args.output} ({size_mb:.1f} MB)")
|
print(f"Wrote {args.output} ({size_mb:.1f} MB)")
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,69 @@ def test_transform_crime_annualises_over_all_valid_months(tmp_path):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_crime_writes_by_year_output(tmp_path):
|
||||||
|
crime_dir = tmp_path / "crime"
|
||||||
|
jan23 = crime_dir / "2023-01"
|
||||||
|
jan24 = crime_dir / "2024-01"
|
||||||
|
feb24 = crime_dir / "2024-02"
|
||||||
|
for d in (jan23, jan24, feb24):
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
|
||||||
|
header = "Crime ID,Month,Reported by,Falls within,Longitude,Latitude,Location,LSOA code,LSOA name,Crime type,Last outcome category,Context"
|
||||||
|
(jan23 / "2023-01-test-force-street.csv").write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
header,
|
||||||
|
"1,2023-01,F,F,-0.1,51.5,X,E01000001,L,Burglary,U,",
|
||||||
|
"2,2023-01,F,F,-0.1,51.5,X,E01000001,L,Robbery,U,",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
(jan24 / "2024-01-test-force-street.csv").write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
header,
|
||||||
|
"3,2024-01,F,F,-0.1,51.5,X,E01000001,L,Burglary,U,",
|
||||||
|
"4,2024-01,F,F,-0.1,51.5,X,E01000001,L,Burglary,U,",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
(feb24 / "2024-02-test-force-street.csv").write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
header,
|
||||||
|
"5,2024-02,F,F,-0.1,51.5,X,E01000001,L,Anti-social behaviour,U,",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = tmp_path / "crime.parquet"
|
||||||
|
by_year_output = tmp_path / "crime_by_year.parquet"
|
||||||
|
transform_crime(crime_dir, output, by_year_output)
|
||||||
|
|
||||||
|
by_year = pl.read_parquet(by_year_output)
|
||||||
|
assert by_year.height == 1
|
||||||
|
cols = set(by_year.columns)
|
||||||
|
assert "Burglary (by year)" in cols
|
||||||
|
assert "Serious crime (by year)" in cols
|
||||||
|
assert "Minor crime (by year)" in cols
|
||||||
|
|
||||||
|
row = by_year.row(0, named=True)
|
||||||
|
burglary = sorted(row["Burglary (by year)"], key=lambda r: r["year"])
|
||||||
|
# 2023: 1 burglary in 1 month → 12/yr; 2024: 2 in 2 months → 12/yr
|
||||||
|
assert burglary == [
|
||||||
|
{"year": 2023, "count": 12.0},
|
||||||
|
{"year": 2024, "count": 12.0},
|
||||||
|
]
|
||||||
|
# Serious crime in 2023 = Burglary(12) + Robbery(12) = 24
|
||||||
|
serious = {p["year"]: p["count"] for p in row["Serious crime (by year)"]}
|
||||||
|
assert serious[2023] == 24.0
|
||||||
|
assert serious[2024] == 12.0
|
||||||
|
|
||||||
|
|
||||||
def test_transform_crime_fails_without_valid_months(tmp_path):
|
def test_transform_crime_fails_without_valid_months(tmp_path):
|
||||||
crime_dir = tmp_path / "crime"
|
crime_dir = tmp_path / "crime"
|
||||||
month_dir = crime_dir / "2024-01"
|
month_dir = crime_dir / "2024-01"
|
||||||
|
|
@ -117,3 +180,49 @@ def test_transform_crime_fails_without_valid_months(tmp_path):
|
||||||
assert "No valid crime months" in str(exc)
|
assert "No valid crime months" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("Expected ValueError")
|
raise AssertionError("Expected ValueError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_crime_applies_lsoa_2011_to_2021_lookup(tmp_path):
|
||||||
|
crime_dir = tmp_path / "crime"
|
||||||
|
month_dir = crime_dir / "2024-01"
|
||||||
|
month_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
header = "Crime ID,Month,Reported by,Falls within,Longitude,Latitude,Location,LSOA code,LSOA name,Crime type,Last outcome category,Context"
|
||||||
|
# E01000001 was split into two 2021 LSOAs; E01000099 is unchanged.
|
||||||
|
(month_dir / "2024-01-test-force-street.csv").write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
header,
|
||||||
|
"1,2024-01,F,F,-0.1,51.5,X,E01000001,L,Burglary,U,",
|
||||||
|
"2,2024-01,F,F,-0.1,51.5,X,E01000001,L,Burglary,U,",
|
||||||
|
"3,2024-01,F,F,-0.1,51.5,X,E01000099,L,Burglary,U,",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
lookup_path = tmp_path / "lookup.parquet"
|
||||||
|
pl.DataFrame(
|
||||||
|
{
|
||||||
|
"lsoa11": ["E01000001", "E01000001", "E01000099"],
|
||||||
|
"lsoa21": ["E01000050", "E01000051", "E01000099"],
|
||||||
|
}
|
||||||
|
).write_parquet(lookup_path)
|
||||||
|
|
||||||
|
output = tmp_path / "crime.parquet"
|
||||||
|
by_year_output = tmp_path / "by_year.parquet"
|
||||||
|
transform_crime(crime_dir, output, by_year_output, lookup_path)
|
||||||
|
|
||||||
|
# Split LSOA: 2 burglaries split evenly → 1/yr each child, annualised to 12/yr each.
|
||||||
|
avg = pl.read_parquet(output).sort("LSOA code").to_dicts()
|
||||||
|
assert avg == [
|
||||||
|
{"LSOA code": "E01000050", "Burglary (avg/yr)": 12.0},
|
||||||
|
{"LSOA code": "E01000051", "Burglary (avg/yr)": 12.0},
|
||||||
|
{"LSOA code": "E01000099", "Burglary (avg/yr)": 12.0},
|
||||||
|
]
|
||||||
|
|
||||||
|
by_year = pl.read_parquet(by_year_output).sort("LSOA code").to_dicts()
|
||||||
|
burglaries = {row["LSOA code"]: row["Burglary (by year)"] for row in by_year}
|
||||||
|
assert burglaries["E01000050"] == [{"year": 2024, "count": 12.0}]
|
||||||
|
assert burglaries["E01000051"] == [{"year": 2024, "count": 12.0}]
|
||||||
|
assert burglaries["E01000099"] == [{"year": 2024, "count": 12.0}]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import pytest
|
import pytest
|
||||||
|
from shapely import box
|
||||||
|
|
||||||
from pipeline.transform.merge import (
|
from pipeline.transform.merge import (
|
||||||
_AREA_COLUMNS,
|
_AREA_COLUMNS,
|
||||||
|
CONSERVATION_AREA_FEATURE,
|
||||||
|
LISTED_BUILDING_FEATURE,
|
||||||
TREE_DENSITY_FEATURE,
|
TREE_DENSITY_FEATURE,
|
||||||
_is_dynamic_poi_metric_column,
|
_is_dynamic_poi_metric_column,
|
||||||
_less_deprived_percentile_expr,
|
_less_deprived_percentile_expr,
|
||||||
|
_matched_listed_building_flags,
|
||||||
|
_postcode_conservation_area_flags,
|
||||||
|
_postcode_listed_building_candidates,
|
||||||
_tree_density_by_postcode,
|
_tree_density_by_postcode,
|
||||||
_validate_lad_source_coverage,
|
_validate_lad_source_coverage,
|
||||||
_validate_property_postcodes,
|
_validate_property_postcodes,
|
||||||
|
|
@ -48,6 +54,106 @@ def test_country_code_is_kept_in_postcode_area_columns() -> None:
|
||||||
assert "ctry25cd" in _AREA_COLUMNS
|
assert "ctry25cd" in _AREA_COLUMNS
|
||||||
|
|
||||||
|
|
||||||
|
def test_conservation_area_feature_is_area_level() -> None:
|
||||||
|
assert CONSERVATION_AREA_FEATURE in _AREA_COLUMNS
|
||||||
|
|
||||||
|
|
||||||
|
def test_listed_building_feature_is_property_level() -> None:
|
||||||
|
assert LISTED_BUILDING_FEATURE not in _AREA_COLUMNS
|
||||||
|
|
||||||
|
|
||||||
|
def test_postcode_conservation_area_flags_marks_point_membership() -> None:
|
||||||
|
postcodes = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"postcode": ["AA1 1AA", "BB1 1BB", "CC1 1CC"],
|
||||||
|
"lat": [0.5, 2.0, None],
|
||||||
|
"lon": [0.5, 2.0, 0.5],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _postcode_conservation_area_flags(
|
||||||
|
postcodes, [box(0, 0, 1, 1)], "EPSG:4326", batch_size=2
|
||||||
|
).sort("postcode")
|
||||||
|
|
||||||
|
assert result.to_dicts() == [
|
||||||
|
{"postcode": "AA1 1AA", CONSERVATION_AREA_FEATURE: "Yes"},
|
||||||
|
{"postcode": "BB1 1BB", CONSERVATION_AREA_FEATURE: "No"},
|
||||||
|
{"postcode": "CC1 1CC", CONSERVATION_AREA_FEATURE: "No"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_postcode_listed_building_candidates_uses_nearby_postcodes() -> None:
|
||||||
|
listed_points = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"ListEntry": [1234, 5678],
|
||||||
|
"Name": ["1 and 2 High Street", "Distant Hall"],
|
||||||
|
"Grade": ["II", "I"],
|
||||||
|
"Easting": [100.0, 1000.0],
|
||||||
|
"Northing": [100.0, 1000.0],
|
||||||
|
}
|
||||||
|
).with_columns(
|
||||||
|
pl.col("Name")
|
||||||
|
.str.to_uppercase()
|
||||||
|
.str.replace_all(r"[^0-9A-Z]+", " ")
|
||||||
|
.str.replace_all(r"\s+", " ")
|
||||||
|
.str.strip_chars()
|
||||||
|
.alias("_listed_match_name")
|
||||||
|
)
|
||||||
|
active_postcodes = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"postcode": ["AA1 1AA", "BB1 1BB"],
|
||||||
|
"east1m": [105.0, 5000.0],
|
||||||
|
"north1m": [105.0, 5000.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _postcode_listed_building_candidates(
|
||||||
|
listed_points,
|
||||||
|
active_postcodes,
|
||||||
|
nearest_postcodes=1,
|
||||||
|
max_distance_m=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.select("postcode", "_listed_match_name").to_dicts() == [
|
||||||
|
{"postcode": "AA1 1AA", "_listed_match_name": "1 AND 2 HIGH STREET"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matched_listed_building_flags_requires_address_match() -> None:
|
||||||
|
properties = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"postcode": ["AA1 1AA", "AA1 1AA", "BB1 1BB"],
|
||||||
|
"pp_address": ["1 HIGH STREET", "99 HIGH STREET", "THE OLD RECTORY"],
|
||||||
|
"epc_address": ["1, High Street", "99, High Street", "Old Rectory"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
listed_candidates = pl.DataFrame(
|
||||||
|
{
|
||||||
|
"postcode": ["AA1 1AA", "BB1 1BB"],
|
||||||
|
"_listed_match_name": ["1 AND 2 HIGH STREET", "OLD RECTORY"],
|
||||||
|
"_listed_grade": ["II", "II*"],
|
||||||
|
"_listed_entry": [1234, 5678],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _matched_listed_building_flags(
|
||||||
|
properties.lazy(), listed_candidates, min_score=95
|
||||||
|
).sort("postcode", "pp_address")
|
||||||
|
|
||||||
|
assert result.to_dicts() == [
|
||||||
|
{
|
||||||
|
"postcode": "AA1 1AA",
|
||||||
|
"pp_address": "1 HIGH STREET",
|
||||||
|
LISTED_BUILDING_FEATURE: "Yes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"postcode": "BB1 1BB",
|
||||||
|
"pp_address": "THE OLD RECTORY",
|
||||||
|
LISTED_BUILDING_FEATURE: "Yes",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_validate_property_postcodes_rejects_blank_rows() -> None:
|
def test_validate_property_postcodes_rejects_blank_rows() -> None:
|
||||||
df = pl.DataFrame(
|
df = pl.DataFrame(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,19 @@ DROP_CATEGORIES = {
|
||||||
"public_transport/platform",
|
"public_transport/platform",
|
||||||
"public_transport/station",
|
"public_transport/station",
|
||||||
"public_transport/stop_position",
|
"public_transport/stop_position",
|
||||||
|
# Education amenities — schools come from GIAS instead. OSM coverage for
|
||||||
|
# tertiary education, tutoring, and childcare is too noisy/incomplete to be
|
||||||
|
# useful on a property-search map.
|
||||||
|
"amenity/school",
|
||||||
|
"amenity/prep_school",
|
||||||
|
"amenity/language_school",
|
||||||
|
"amenity/music_school",
|
||||||
|
"amenity/university",
|
||||||
|
"amenity/college",
|
||||||
|
"building/university",
|
||||||
|
"amenity/kindergarten",
|
||||||
|
"amenity/childcare",
|
||||||
|
"office/tutoring",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -943,23 +956,10 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [
|
||||||
"tourism/chalet",
|
"tourism/chalet",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
# Note: schools come from the GIAS register (see transform_gias_schools).
|
||||||
"Education",
|
# Niche/tertiary education amenities that GIAS does not cover are dropped
|
||||||
"School",
|
# rather than mixed in with state-funded schools.
|
||||||
"🏫",
|
|
||||||
[
|
|
||||||
"amenity/school",
|
|
||||||
"amenity/prep_school",
|
|
||||||
"amenity/language_school",
|
|
||||||
"amenity/music_school",
|
|
||||||
"amenity/university",
|
|
||||||
"amenity/college",
|
|
||||||
"building/university",
|
|
||||||
"amenity/kindergarten",
|
|
||||||
"amenity/childcare",
|
|
||||||
"office/tutoring",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"Local Businesses",
|
"Local Businesses",
|
||||||
"Hotel",
|
"Hotel",
|
||||||
|
|
@ -1316,11 +1316,45 @@ def transform_grocery_retail_points(
|
||||||
).select("id", "name", "category", "icon_category", "group", "lat", "lng", "emoji")
|
).select("id", "name", "category", "icon_category", "group", "lat", "lng", "emoji")
|
||||||
|
|
||||||
|
|
||||||
|
def transform_gias_schools(gias_path: Path) -> pl.LazyFrame:
|
||||||
|
"""Convert the GIAS register parquet into POI rows with school metadata."""
|
||||||
|
return pl.scan_parquet(gias_path).select(
|
||||||
|
pl.concat_str([pl.lit("gias-"), pl.col("urn").cast(pl.String)]).alias("id"),
|
||||||
|
pl.col("name"),
|
||||||
|
pl.lit("School").alias("category"),
|
||||||
|
pl.lit("School").alias("icon_category"),
|
||||||
|
pl.lit("Education").alias("group"),
|
||||||
|
pl.col("lat").cast(pl.Float64),
|
||||||
|
pl.col("lng").cast(pl.Float64),
|
||||||
|
pl.lit("🏫").alias("emoji"),
|
||||||
|
pl.col("phase").alias("school_phase"),
|
||||||
|
pl.col("type").alias("school_type"),
|
||||||
|
pl.col("type_group").alias("school_type_group"),
|
||||||
|
pl.col("age_range").alias("school_age_range"),
|
||||||
|
pl.col("gender").alias("school_gender"),
|
||||||
|
pl.col("religious_character").alias("school_religious_character"),
|
||||||
|
pl.col("admissions_policy").alias("school_admissions_policy"),
|
||||||
|
pl.col("nursery_provision").alias("school_nursery_provision"),
|
||||||
|
pl.col("sixth_form").alias("school_sixth_form"),
|
||||||
|
pl.col("capacity").cast(pl.Int32, strict=False).alias("school_capacity"),
|
||||||
|
pl.col("pupils").cast(pl.Int32, strict=False).alias("school_pupils"),
|
||||||
|
pl.col("fsm_percent").cast(pl.Float32, strict=False).alias("school_fsm_percent"),
|
||||||
|
pl.col("trust").alias("school_trust"),
|
||||||
|
pl.col("address").alias("school_address"),
|
||||||
|
pl.col("postcode").alias("school_postcode"),
|
||||||
|
pl.col("local_authority").alias("school_local_authority"),
|
||||||
|
pl.col("website").alias("school_website"),
|
||||||
|
pl.col("telephone").cast(pl.String, strict=False).alias("school_telephone"),
|
||||||
|
pl.col("head_name").alias("school_head_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def transform(
|
def transform(
|
||||||
input_path: Path,
|
input_path: Path,
|
||||||
naptan_path: Path | None = None,
|
naptan_path: Path,
|
||||||
boundary_path: Path | None = None,
|
boundary_path: Path,
|
||||||
grocery_retail_points_path: Path | None = None,
|
grocery_retail_points_path: Path,
|
||||||
|
gias_path: Path,
|
||||||
) -> pl.LazyFrame:
|
) -> pl.LazyFrame:
|
||||||
lf = pl.scan_parquet(input_path)
|
lf = pl.scan_parquet(input_path)
|
||||||
|
|
||||||
|
|
@ -1372,7 +1406,6 @@ def transform(
|
||||||
)
|
)
|
||||||
|
|
||||||
naptan_df = pl.scan_parquet(naptan_path).collect()
|
naptan_df = pl.scan_parquet(naptan_path).collect()
|
||||||
if boundary_path is not None:
|
|
||||||
mask = in_england_mask(
|
mask = in_england_mask(
|
||||||
boundary_path,
|
boundary_path,
|
||||||
naptan_df["lat"].to_numpy(),
|
naptan_df["lat"].to_numpy(),
|
||||||
|
|
@ -1385,11 +1418,9 @@ def transform(
|
||||||
pl.col("category").alias("icon_category"),
|
pl.col("category").alias("icon_category"),
|
||||||
)
|
)
|
||||||
|
|
||||||
frames = [lf, naptan]
|
|
||||||
if grocery_retail_points_path is not None:
|
|
||||||
grocery_df = pl.read_parquet(grocery_retail_points_path)
|
grocery_df = pl.read_parquet(grocery_retail_points_path)
|
||||||
grocery_pois = transform_grocery_retail_points(grocery_df, boundary_path)
|
grocery_pois = transform_grocery_retail_points(grocery_df, boundary_path)
|
||||||
frames.append(grocery_pois.lazy())
|
frames = [lf, naptan, grocery_pois.lazy(), transform_gias_schools(gias_path)]
|
||||||
|
|
||||||
return pl.concat(frames, how="diagonal_relaxed")
|
return pl.concat(frames, how="diagonal_relaxed")
|
||||||
|
|
||||||
|
|
@ -1413,8 +1444,15 @@ def main():
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--grocery-retail-points",
|
"--grocery-retail-points",
|
||||||
type=Path,
|
type=Path,
|
||||||
|
required=True,
|
||||||
help="GEOLYTIX Grocery Retail Points parquet",
|
help="GEOLYTIX Grocery Retail Points parquet",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gias",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="GIAS schools register parquet (replaces OSM schools)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
|
"--output", type=Path, required=True, help="Output filtered POIs parquet file"
|
||||||
)
|
)
|
||||||
|
|
@ -1425,6 +1463,7 @@ def main():
|
||||||
args.naptan,
|
args.naptan,
|
||||||
args.boundary,
|
args.boundary,
|
||||||
args.grocery_retail_points,
|
args.grocery_retail_points,
|
||||||
|
args.gias,
|
||||||
).collect(engine="streaming")
|
).collect(engine="streaming")
|
||||||
|
|
||||||
df.write_parquet(args.output)
|
df.write_parquet(args.output)
|
||||||
|
|
|
||||||
269
pipeline/transform/tree_overlay_tiles.py
Normal file
269
pipeline/transform/tree_overlay_tiles.py
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
"""Build PMTiles polygon tiles for the Trees Outside Woodland overlay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyogrio
|
||||||
|
import shapely
|
||||||
|
from pyproj import Transformer
|
||||||
|
|
||||||
|
from pipeline.local_temp import local_tmp_dir
|
||||||
|
from pipeline.transform.tree_density import (
|
||||||
|
DEFAULT_TOW_TYPES,
|
||||||
|
_layers,
|
||||||
|
_tow_dataset_path,
|
||||||
|
_where_for_tow_types,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_tippecanoe() -> str:
|
||||||
|
executable = shutil.which("tippecanoe")
|
||||||
|
if executable is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"tippecanoe is required to build tree overlay PMTiles. "
|
||||||
|
"Install tippecanoe and rerun this target."
|
||||||
|
)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
def _column_or_none(batch, names: list[str], column: str):
|
||||||
|
if column not in names:
|
||||||
|
return None
|
||||||
|
return batch.column(names.index(column)).to_numpy(zero_copy_only=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _number_or_none(value) -> float | int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if np.isfinite(value):
|
||||||
|
if float(value).is_integer():
|
||||||
|
return int(value)
|
||||||
|
return round(float(value), 2)
|
||||||
|
except TypeError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_tree_geojsonseq(
|
||||||
|
dataset_path: str,
|
||||||
|
output_path: Path,
|
||||||
|
tow_types: tuple[str, ...],
|
||||||
|
batch_size: int,
|
||||||
|
layer_names: tuple[str, ...] | None,
|
||||||
|
max_features_per_layer: int | None,
|
||||||
|
) -> int:
|
||||||
|
to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True)
|
||||||
|
where = _where_for_tow_types(tow_types)
|
||||||
|
layers = _layers(dataset_path, layer_names)
|
||||||
|
print(f"Processing {len(layers)} TOW layer(s): {', '.join(layers)}")
|
||||||
|
if where:
|
||||||
|
print(f"TOW type filter: {where}")
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
"TOW_ID",
|
||||||
|
"Woodland_Type",
|
||||||
|
"TOW_Area_M",
|
||||||
|
"MEANHT",
|
||||||
|
"MINHT",
|
||||||
|
"MAXHT",
|
||||||
|
"LiDAR_Survey_Year",
|
||||||
|
]
|
||||||
|
feature_count = 0
|
||||||
|
|
||||||
|
with output_path.open("w") as file:
|
||||||
|
for layer in layers:
|
||||||
|
info = pyogrio.read_info(dataset_path, layer=layer)
|
||||||
|
print(f"\nLayer {layer}: {info.get('features', 0):,} features")
|
||||||
|
layer_features_seen = 0
|
||||||
|
|
||||||
|
with pyogrio.open_arrow(
|
||||||
|
dataset_path,
|
||||||
|
layer=layer,
|
||||||
|
columns=columns,
|
||||||
|
where=where,
|
||||||
|
batch_size=batch_size,
|
||||||
|
use_pyarrow=True,
|
||||||
|
) as (_meta, reader):
|
||||||
|
for batch in reader:
|
||||||
|
if max_features_per_layer is not None:
|
||||||
|
remaining = max_features_per_layer - layer_features_seen
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
if batch.num_rows > remaining:
|
||||||
|
batch = batch.slice(0, remaining)
|
||||||
|
|
||||||
|
layer_features_seen += batch.num_rows
|
||||||
|
names = batch.schema.names
|
||||||
|
area = np.asarray(
|
||||||
|
batch.column(names.index("TOW_Area_M")).to_numpy(
|
||||||
|
zero_copy_only=False
|
||||||
|
),
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
geometry = np.asarray(
|
||||||
|
batch.column(names.index("SHAPE")).to_numpy(
|
||||||
|
zero_copy_only=False
|
||||||
|
),
|
||||||
|
dtype=object,
|
||||||
|
)
|
||||||
|
valid = np.isfinite(area) & (area > 0)
|
||||||
|
if not valid.any():
|
||||||
|
continue
|
||||||
|
|
||||||
|
tow_id = _column_or_none(batch, names, "TOW_ID")
|
||||||
|
woodland_type = _column_or_none(batch, names, "Woodland_Type")
|
||||||
|
mean_height = _column_or_none(batch, names, "MEANHT")
|
||||||
|
min_height = _column_or_none(batch, names, "MINHT")
|
||||||
|
max_height = _column_or_none(batch, names, "MAXHT")
|
||||||
|
lidar_year = _column_or_none(batch, names, "LiDAR_Survey_Year")
|
||||||
|
|
||||||
|
geometries = shapely.from_wkb(geometry[valid])
|
||||||
|
geometries = shapely.transform(
|
||||||
|
geometries,
|
||||||
|
to_wgs84.transform,
|
||||||
|
interleaved=False,
|
||||||
|
)
|
||||||
|
geometries_json = shapely.to_geojson(geometries)
|
||||||
|
valid_indexes = np.flatnonzero(valid)
|
||||||
|
|
||||||
|
for idx, geometry_json in zip(valid_indexes, geometries_json):
|
||||||
|
properties = {
|
||||||
|
"tow_id": str(tow_id[idx]) if tow_id is not None else "",
|
||||||
|
"woodland_type": (
|
||||||
|
str(woodland_type[idx])
|
||||||
|
if woodland_type is not None
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"area_sqm": _number_or_none(area[idx]),
|
||||||
|
"mean_height_m": (
|
||||||
|
_number_or_none(mean_height[idx])
|
||||||
|
if mean_height is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"min_height_m": (
|
||||||
|
_number_or_none(min_height[idx])
|
||||||
|
if min_height is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"max_height_m": (
|
||||||
|
_number_or_none(max_height[idx])
|
||||||
|
if max_height is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"lidar_year": (
|
||||||
|
_number_or_none(lidar_year[idx])
|
||||||
|
if lidar_year is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"source_layer": layer,
|
||||||
|
}
|
||||||
|
feature = {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": json.loads(geometry_json),
|
||||||
|
"properties": properties,
|
||||||
|
}
|
||||||
|
file.write(json.dumps(feature, separators=(",", ":")) + "\n")
|
||||||
|
feature_count += 1
|
||||||
|
|
||||||
|
return feature_count
|
||||||
|
|
||||||
|
|
||||||
|
def build_tree_overlay_tiles(
|
||||||
|
tow_zip: Path,
|
||||||
|
output_path: Path,
|
||||||
|
extract_dir: Path,
|
||||||
|
tow_types: tuple[str, ...],
|
||||||
|
batch_size: int,
|
||||||
|
layer_names: tuple[str, ...] | None,
|
||||||
|
max_features_per_layer: int | None,
|
||||||
|
min_zoom: int,
|
||||||
|
max_zoom: int,
|
||||||
|
force_extract: bool,
|
||||||
|
use_vsizip: bool,
|
||||||
|
) -> None:
|
||||||
|
tippecanoe = _require_tippecanoe()
|
||||||
|
dataset_path = _tow_dataset_path(tow_zip, extract_dir, force_extract, use_vsizip)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as tmp:
|
||||||
|
ndjson_path = Path(tmp) / "trees_outside_woodlands.geojsonseq"
|
||||||
|
feature_count = _write_tree_geojsonseq(
|
||||||
|
dataset_path,
|
||||||
|
ndjson_path,
|
||||||
|
tow_types,
|
||||||
|
batch_size,
|
||||||
|
layer_names,
|
||||||
|
max_features_per_layer,
|
||||||
|
)
|
||||||
|
print(f"Writing {feature_count:,} TOW polygon features")
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
tippecanoe,
|
||||||
|
"--force",
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
"--layer",
|
||||||
|
"trees_outside_woodlands",
|
||||||
|
"--minimum-zoom",
|
||||||
|
str(min_zoom),
|
||||||
|
"--maximum-zoom",
|
||||||
|
str(max_zoom),
|
||||||
|
"--drop-smallest-as-needed",
|
||||||
|
"--extend-zooms-if-still-dropping",
|
||||||
|
str(ndjson_path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--tow-zip", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--extract-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path("property-data/fr_tow_v1_all"),
|
||||||
|
help="Directory used to extract the FileGDB",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tow-type",
|
||||||
|
action="append",
|
||||||
|
dest="tow_types",
|
||||||
|
help="Woodland_Type to include; repeatable. Defaults to TOW outside-woodland classes.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--batch-size", type=int, default=50_000)
|
||||||
|
parser.add_argument("--layer", action="append", dest="layers")
|
||||||
|
parser.add_argument("--max-features-per-layer", type=int)
|
||||||
|
parser.add_argument("--min-zoom", type=int, default=15)
|
||||||
|
parser.add_argument("--max-zoom", type=int, default=17)
|
||||||
|
parser.add_argument("--force-extract", action="store_true")
|
||||||
|
parser.add_argument("--use-vsizip", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
build_tree_overlay_tiles(
|
||||||
|
tow_zip=args.tow_zip,
|
||||||
|
output_path=args.output,
|
||||||
|
extract_dir=args.extract_dir,
|
||||||
|
tow_types=tuple(args.tow_types or DEFAULT_TOW_TYPES),
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
layer_names=tuple(args.layers) if args.layers else None,
|
||||||
|
max_features_per_layer=args.max_features_per_layer,
|
||||||
|
min_zoom=args.min_zoom,
|
||||||
|
max_zoom=args.max_zoom,
|
||||||
|
force_extract=args.force_extract,
|
||||||
|
use_vsizip=args.use_vsizip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -21,8 +21,13 @@ set -euo pipefail
|
||||||
# --demo only compute Bank + TCR, transit only (quick test)
|
# --demo only compute Bank + TCR, transit only (quick test)
|
||||||
|
|
||||||
# --- Defaults ---
|
# --- Defaults ---
|
||||||
THREADS=6
|
THREADS=12
|
||||||
HEAP=40g
|
# The execution cgroup caps process memory at 48 GB (see /sys/fs/cgroup/memory.max);
|
||||||
|
# the nominal "64 GB" host total is not all addressable to one process. 28g heap +
|
||||||
|
# ~15g native overhead (DuckDB JNI, R5 mapdb, Kryo deserialize, RAPTOR scratch)
|
||||||
|
# leaves ~5g cgroup headroom — empirically the safe ceiling before SIGKILL.
|
||||||
|
# Under 32g also keeps CompressedOops on, halving R5's reference-heavy footprint.
|
||||||
|
HEAP=28g
|
||||||
NETWORK_DIR=property-data/r5-network
|
NETWORK_DIR=property-data/r5-network
|
||||||
OUTPUT_BASE=property-data/travel-times
|
OUTPUT_BASE=property-data/travel-times
|
||||||
R5_DIR=r5-java
|
R5_DIR=r5-java
|
||||||
|
|
@ -36,6 +41,7 @@ while [[ $# -gt 0 ]]; do
|
||||||
--network-dir) NETWORK_DIR="$2"; shift 2 ;;
|
--network-dir) NETWORK_DIR="$2"; shift 2 ;;
|
||||||
--output-dir) OUTPUT_BASE="$2"; shift 2 ;;
|
--output-dir) OUTPUT_BASE="$2"; shift 2 ;;
|
||||||
--demo) DEMO_FLAG="--demo"; shift ;;
|
--demo) DEMO_FLAG="--demo"; shift ;;
|
||||||
|
--demo-cars=*) DEMO_FLAG="--demo-cars ${1#--demo-cars=}"; shift ;;
|
||||||
*) echo "Unknown: $1"; exit 1 ;;
|
*) echo "Unknown: $1"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
@ -147,7 +153,14 @@ mkdir -p "$TMP_DIR"
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- Starting batch computation ---"
|
echo "--- Starting batch computation ---"
|
||||||
DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
|
DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
|
||||||
java -Xms"$HEAP" -Xmx"$HEAP" -Djava.io.tmpdir="$TMP_DIR" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
|
java -Xmx"$HEAP" \
|
||||||
|
-XX:+UseParallelGC -XX:ParallelGCThreads=12 \
|
||||||
|
-XX:+UseTransparentHugePages \
|
||||||
|
-XX:NewRatio=2 \
|
||||||
|
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath="$TMP_DIR/heapdump-$(date +%s).hprof" \
|
||||||
|
-XX:+ExitOnOutOfMemoryError \
|
||||||
|
-Xlog:gc*:file="$TMP_DIR/gc.log":time,uptime:filecount=5,filesize=20M \
|
||||||
|
-Djava.io.tmpdir="$TMP_DIR" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
|
||||||
--postcodes property-data/arcgis_data.parquet \
|
--postcodes property-data/arcgis_data.parquet \
|
||||||
--places property-data/places.parquet \
|
--places property-data/places.parquet \
|
||||||
--output-dir "$OUTPUT_BASE" \
|
--output-dir "$OUTPUT_BASE" \
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package propertymap;
|
||||||
|
|
||||||
import com.conveyal.r5.transit.TransportNetwork;
|
import com.conveyal.r5.transit.TransportNetwork;
|
||||||
import org.duckdb.DuckDBConnection;
|
import org.duckdb.DuckDBConnection;
|
||||||
|
import org.locationtech.jts.index.strtree.STRtree;
|
||||||
|
import propertymap.Router.PostcodeTile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.DirectoryStream;
|
import java.nio.file.DirectoryStream;
|
||||||
|
|
@ -10,9 +12,12 @@ import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
|
@ -32,15 +37,84 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||||
* with columns (pcds VARCHAR, travel_minutes SMALLINT). Transit mode additionally
|
* with columns (pcds VARCHAR, travel_minutes SMALLINT). Transit mode additionally
|
||||||
* includes a best_minutes SMALLINT column (5th percentile = best-case departure timing)
|
* includes a best_minutes SMALLINT column (5th percentile = best-case departure timing)
|
||||||
* and a journey VARCHAR column with JSON leg instructions.
|
* and a journey VARCHAR column with JSON leg instructions.
|
||||||
|
*
|
||||||
|
* Concurrency model:
|
||||||
|
* - One per-mode routing thread pool (transit capped lower because path recording
|
||||||
|
* allocates heavily per task; direct modes use the full --threads value).
|
||||||
|
* - A process-lifetime writer pool of {@link #WRITER_THREADS} threads, each holding
|
||||||
|
* its own DuckDB connection. Routing tasks enqueue {@link WriteJob}s onto a
|
||||||
|
* bounded {@link ArrayBlockingQueue}; if writes lag, the queue applies backpressure
|
||||||
|
* to routing.
|
||||||
|
* - Within a mode, the latch counts down only after the write completes — so progress
|
||||||
|
* and the inter-mode barrier reflect fully-persisted work.
|
||||||
*/
|
*/
|
||||||
public class App {
|
public class App {
|
||||||
|
|
||||||
private static final String[] MODES = {"bicycle", "transit", "walking", "car"};
|
private static final String[] MODES = {
|
||||||
private static final String[] DEMO_MODES = {"transit"};
|
"bicycle", "walking", "car",
|
||||||
|
"transit",
|
||||||
|
"transit-no-bus",
|
||||||
|
"transit-no-change",
|
||||||
|
"transit-no-change-no-bus",
|
||||||
|
"transit-one-change",
|
||||||
|
"transit-one-change-no-bus",
|
||||||
|
};
|
||||||
|
private static final String[] DEMO_MODES = {"transit", "car", "bicycle", "walking"};
|
||||||
private static final Set<String> DEMO_PLACES = Set.of(
|
private static final Set<String> DEMO_PLACES = Set.of(
|
||||||
"Bank tube station", "Tottenham Court Road tube station");
|
"Bank tube station", "Tottenham Court Road tube station");
|
||||||
|
/**
|
||||||
|
* Always-first origins (per-mode). The cache-warmest London core: these origins
|
||||||
|
* each touch ~100 unique global tiles, so running them up front builds the
|
||||||
|
* LinkageCache that every subsequent London-ish origin reuses.
|
||||||
|
* Order within this list is preserved in submission order.
|
||||||
|
*/
|
||||||
|
private static final List<String> PRIORITY_PLACES = List.of(
|
||||||
|
"Bank tube station",
|
||||||
|
"Tower Gateway DLR station",
|
||||||
|
"Tottenham Court Road tube station");
|
||||||
private static final int MAX_RETRIES = 2;
|
private static final int MAX_RETRIES = 2;
|
||||||
|
|
||||||
|
/** Writer pool size. Holds one DuckDB connection per thread. */
|
||||||
|
private static final int WRITER_THREADS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-mode worker concurrency caps. Memory, not CPU, is the binding constraint
|
||||||
|
* for long-radius modes: each origin allocates a per-origin FreeFormPointSet +
|
||||||
|
* LinkedPointSet sized to the filtered dest count, plus R5's transient routing
|
||||||
|
* state (StreetRouter cost arrays for the reachable street area). London car
|
||||||
|
* origins filter to ~1M postcodes within 150km, so each in-flight car task is
|
||||||
|
* ~500MB-1GB of state. 12 concurrent of those OOM the 28g heap.
|
||||||
|
*
|
||||||
|
* Tuned to the 28g heap ceiling:
|
||||||
|
* transit → 4 (tile-cached but path recording allocates heavily per task)
|
||||||
|
* car → 4 (~1M dests per origin, 150km radius)
|
||||||
|
* bicycle → 8 (~250k dests per origin, 60km radius)
|
||||||
|
* walking → full (~few k dests per origin, 12km radius)
|
||||||
|
*/
|
||||||
|
private static final int MAX_TRANSIT_THREADS = 4;
|
||||||
|
private static final int MAX_CAR_THREADS = 4;
|
||||||
|
private static final int MAX_BICYCLE_THREADS = 8;
|
||||||
|
|
||||||
|
/** Sentinel enqueued at shutdown to wake writer threads. */
|
||||||
|
private static final WriteJob POISON = new WriteJob(null, null, null, null, null, null, null, null, null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flattened result ready to be persisted. {@code bestTimes}/{@code journeys}
|
||||||
|
* are non-null only for transit. Writers decrement {@code completed} or
|
||||||
|
* {@code failed} and {@code latch} when done.
|
||||||
|
*/
|
||||||
|
record WriteJob(
|
||||||
|
Path outPath,
|
||||||
|
String[] codes,
|
||||||
|
short[] times,
|
||||||
|
short[] bestTimes,
|
||||||
|
String[] journeys,
|
||||||
|
String originName,
|
||||||
|
AtomicInteger completed,
|
||||||
|
AtomicInteger failed,
|
||||||
|
CountDownLatch latch
|
||||||
|
) {}
|
||||||
|
|
||||||
public static void main(String[] args) throws Exception {
|
public static void main(String[] args) throws Exception {
|
||||||
String postcodesPath = requiredArg(args, "--postcodes");
|
String postcodesPath = requiredArg(args, "--postcodes");
|
||||||
String placesPath = requiredArg(args, "--places");
|
String placesPath = requiredArg(args, "--places");
|
||||||
|
|
@ -61,6 +135,16 @@ public class App {
|
||||||
postcodesPath, outDir.resolve("postcodes_ref.parquet"));
|
postcodesPath, outDir.resolve("postcodes_ref.parquet"));
|
||||||
System.err.printf(" %,d postcodes%n", postcodes.lats().length);
|
System.err.printf(" %,d postcodes%n", postcodes.lats().length);
|
||||||
|
|
||||||
|
System.err.println("Building STRtree spatial index over postcodes...");
|
||||||
|
STRtree postcodeIndex = Router.buildPostcodeIndex(postcodes.lats(), postcodes.lons());
|
||||||
|
|
||||||
|
System.err.println("Building global transit tiles (shared FreeFormPointSets)...");
|
||||||
|
List<PostcodeTile> transitTiles = Router.buildGlobalTransitTiles(postcodes.lats(), postcodes.lons());
|
||||||
|
System.err.printf(" %,d tiles (max %d dests each)%n",
|
||||||
|
transitTiles.size(),
|
||||||
|
transitTiles.stream().mapToInt(t -> t.originalIndices().length).max().orElse(0));
|
||||||
|
Router.preloadTransitTileLinkages(network, transitTiles);
|
||||||
|
|
||||||
System.err.println("Loading places (deduplicated)...");
|
System.err.println("Loading places (deduplicated)...");
|
||||||
Parquet.Places places = Parquet.loadPlaces(placesPath, outDir.resolve("places_ref.parquet"));
|
Parquet.Places places = Parquet.loadPlaces(placesPath, outDir.resolve("places_ref.parquet"));
|
||||||
String[] originNames = places.names();
|
String[] originNames = places.names();
|
||||||
|
|
@ -80,7 +164,24 @@ public class App {
|
||||||
// In demo mode, filter to just Bank + TCR and transit only
|
// In demo mode, filter to just Bank + TCR and transit only
|
||||||
int[] originIndices;
|
int[] originIndices;
|
||||||
String[] modes;
|
String[] modes;
|
||||||
if (demo) {
|
int demoCars = Integer.parseInt(optionalArg(args, "--demo-cars", "0"));
|
||||||
|
if (demoCars > 0) {
|
||||||
|
// Leak-test path: run car mode only on the top-N England origins by
|
||||||
|
// workload (≈ postcode count within 150km). London-area origins go
|
||||||
|
// first, exercising the heaviest per-origin allocations.
|
||||||
|
Integer[] englandArr = englandIndices.toArray(new Integer[0]);
|
||||||
|
java.util.Arrays.sort(englandArr, (a, b) -> Integer.compare(
|
||||||
|
Router.estimateWorkload(postcodeIndex, originLats[b], originLons[b], 150),
|
||||||
|
Router.estimateWorkload(postcodeIndex, originLats[a], originLons[a], 150)));
|
||||||
|
originIndices = java.util.Arrays.stream(englandArr).limit(demoCars).mapToInt(Integer::intValue).toArray();
|
||||||
|
modes = new String[]{"car"};
|
||||||
|
System.err.printf("DEMO-CARS MODE: %d origins (car only, LPT-ordered)%n", originIndices.length);
|
||||||
|
for (int i = 0; i < Math.min(5, originIndices.length); i++) {
|
||||||
|
System.err.printf(" - %s (workload ~%d)%n", originNames[originIndices[i]],
|
||||||
|
Router.estimateWorkload(postcodeIndex, originLats[originIndices[i]], originLons[originIndices[i]], 150));
|
||||||
|
}
|
||||||
|
if (originIndices.length > 5) System.err.printf(" ... and %d more%n", originIndices.length - 5);
|
||||||
|
} else if (demo) {
|
||||||
List<Integer> demoIdx = new ArrayList<>();
|
List<Integer> demoIdx = new ArrayList<>();
|
||||||
for (int i = 0; i < nOrigins; i++) {
|
for (int i = 0; i < nOrigins; i++) {
|
||||||
if (DEMO_PLACES.contains(originNames[i])) demoIdx.add(i);
|
if (DEMO_PLACES.contains(originNames[i])) demoIdx.add(i);
|
||||||
|
|
@ -96,25 +197,96 @@ public class App {
|
||||||
modes = MODES;
|
modes = MODES;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One thread pool shared across all modes
|
|
||||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
|
||||||
// One DuckDB connection per thread, reused across all writes
|
|
||||||
ThreadLocal<DuckDBConnection> threadConn = ThreadLocal.withInitial(() -> {
|
|
||||||
try { return Parquet.connect(); }
|
|
||||||
catch (Exception e) { throw new RuntimeException(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (enablePaths) System.err.println("Path recording ENABLED (transit only, ~20x slower)");
|
if (enablePaths) System.err.println("Path recording ENABLED (transit only, ~20x slower)");
|
||||||
|
|
||||||
|
// Writer pool (lives across all modes). Bounded queue applies backpressure
|
||||||
|
// to routing workers when writes lag behind.
|
||||||
|
BlockingQueue<WriteJob> writeQueue = new ArrayBlockingQueue<>(Math.max(threads * 2, 16));
|
||||||
|
ExecutorService writerPool = Executors.newFixedThreadPool(WRITER_THREADS, r -> {
|
||||||
|
Thread t = new Thread(r, "parquet-writer");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
for (int i = 0; i < WRITER_THREADS; i++) {
|
||||||
|
writerPool.submit(() -> writerLoop(writeQueue));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean skipCompleted = !demo && demoCars == 0;
|
||||||
try {
|
try {
|
||||||
for (String mode : modes) {
|
for (String mode : modes) {
|
||||||
processMode(network, postcodes.codes(), postcodes.lats(), postcodes.lons(),
|
int modeThreads = threadsForMode(mode, threads);
|
||||||
originNames, originLats, originLons, outDir, mode, today, pool, threadConn, enablePaths,
|
processMode(network, postcodeIndex, transitTiles,
|
||||||
originIndices, !demo);
|
postcodes.codes(), postcodes.lats(), postcodes.lons(),
|
||||||
|
originNames, originLats, originLons, outDir, mode, today,
|
||||||
|
modeThreads, writeQueue, enablePaths, originIndices, skipCompleted);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pool.shutdown();
|
// Wake every writer with a poison pill, then shut down.
|
||||||
pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
|
for (int i = 0; i < WRITER_THREADS; i++) {
|
||||||
|
writeQueue.put(POISON);
|
||||||
|
}
|
||||||
|
writerPool.shutdown();
|
||||||
|
writerPool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writer thread main loop: pull jobs, write parquet, count down latch.
|
||||||
|
* Each writer owns one long-lived DuckDB connection.
|
||||||
|
*/
|
||||||
|
private static void writerLoop(BlockingQueue<WriteJob> queue) {
|
||||||
|
// Recycle the DuckDB connection every CONN_RECYCLE_EVERY writes. Long-lived
|
||||||
|
// in-memory DuckDB connections accumulate buffer pages / catalog state that
|
||||||
|
// doesn't get fully released by DROP TABLE alone; close + reopen forces it.
|
||||||
|
// Set to 10 (vs 50) after leak testing showed +60MB/origin growth at 50 —
|
||||||
|
// tighter recycling keeps the per-connection working set bounded.
|
||||||
|
final int CONN_RECYCLE_EVERY = 10;
|
||||||
|
DuckDBConnection conn;
|
||||||
|
try {
|
||||||
|
conn = Parquet.connect();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Writer failed to open DuckDB connection: " + e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int writesSinceRecycle = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
WriteJob job;
|
||||||
|
try {
|
||||||
|
job = queue.take();
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job == POISON) return;
|
||||||
|
try {
|
||||||
|
if (job.bestTimes() != null) {
|
||||||
|
Parquet.writeTransitTravelTimes(conn, job.outPath(),
|
||||||
|
job.codes(), job.times(), job.bestTimes(), job.journeys());
|
||||||
|
} else {
|
||||||
|
Parquet.writeTravelTimes(conn, job.outPath(),
|
||||||
|
job.codes(), job.times());
|
||||||
|
}
|
||||||
|
job.completed().incrementAndGet();
|
||||||
|
} catch (Exception e) {
|
||||||
|
job.failed().incrementAndGet();
|
||||||
|
System.err.printf("%n [WRITE FAIL] %s: %s%n", job.originName(), e.getMessage());
|
||||||
|
} finally {
|
||||||
|
job.latch().countDown();
|
||||||
|
}
|
||||||
|
if (++writesSinceRecycle >= CONN_RECYCLE_EVERY) {
|
||||||
|
try { conn.close(); } catch (Exception ignore) {}
|
||||||
|
try {
|
||||||
|
conn = Parquet.connect();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Writer failed to reopen DuckDB connection: " + e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writesSinceRecycle = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { conn.close(); } catch (Exception ignore) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,14 +295,14 @@ public class App {
|
||||||
* @param skipCompleted if true, skip origins that already have output files.
|
* @param skipCompleted if true, skip origins that already have output files.
|
||||||
*/
|
*/
|
||||||
private static void processMode(
|
private static void processMode(
|
||||||
TransportNetwork network,
|
TransportNetwork network, STRtree postcodeIndex, List<PostcodeTile> transitTiles,
|
||||||
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
|
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
|
||||||
String[] originNames, double[] originLats, double[] originLons,
|
String[] originNames, double[] originLats, double[] originLons,
|
||||||
Path outDir, String mode, LocalDate date,
|
Path outDir, String mode, LocalDate date,
|
||||||
ExecutorService pool, ThreadLocal<DuckDBConnection> threadConn,
|
int modeThreads, BlockingQueue<WriteJob> writeQueue,
|
||||||
boolean enablePaths, int[] originIndices, boolean skipCompleted) throws Exception {
|
boolean enablePaths, int[] originIndices, boolean skipCompleted) throws Exception {
|
||||||
|
|
||||||
System.err.printf("%n=== %s ===%n", mode.toUpperCase());
|
System.err.printf("%n=== %s (workers=%d) ===%n", mode.toUpperCase(), modeThreads);
|
||||||
System.err.printf(" Radius: %.0f km%n", Router.maxRadiusKm(mode));
|
System.err.printf(" Radius: %.0f km%n", Router.maxRadiusKm(mode));
|
||||||
Path modeDir = outDir.resolve(mode);
|
Path modeDir = outDir.resolve(mode);
|
||||||
Files.createDirectories(modeDir);
|
Files.createDirectories(modeDir);
|
||||||
|
|
@ -155,6 +327,22 @@ public class App {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ordering policy:
|
||||||
|
// 1. PRIORITY_PLACES first, in the literal order they're listed (Bank, Tower
|
||||||
|
// Gateway DLR, TCR). These dense London origins are the best LinkageCache
|
||||||
|
// warmers — every later origin in the SE benefits.
|
||||||
|
// 2. Then LPT (longest-processing-time-first): dense urban origins do far
|
||||||
|
// more work than rural ones. Submitting them first prevents a long tail
|
||||||
|
// where a few London origins finish after everything else drains.
|
||||||
|
double modeRadius = Router.maxRadiusKm(mode);
|
||||||
|
remaining.sort(Comparator.<Integer, Integer>comparing(
|
||||||
|
idx -> {
|
||||||
|
int prio = PRIORITY_PLACES.indexOf(originNames[idx]);
|
||||||
|
return prio < 0 ? Integer.MAX_VALUE : prio;
|
||||||
|
})
|
||||||
|
.thenComparing(Comparator.comparingInt((Integer idx) ->
|
||||||
|
Router.estimateWorkload(postcodeIndex, originLats[idx], originLons[idx], modeRadius)).reversed()));
|
||||||
|
|
||||||
long startMs = System.currentTimeMillis();
|
long startMs = System.currentTimeMillis();
|
||||||
int total = remaining.size();
|
int total = remaining.size();
|
||||||
AtomicInteger completed = new AtomicInteger(0);
|
AtomicInteger completed = new AtomicInteger(0);
|
||||||
|
|
@ -177,26 +365,39 @@ public class App {
|
||||||
}, 2, 2, TimeUnit.SECONDS);
|
}, 2, 2, TimeUnit.SECONDS);
|
||||||
|
|
||||||
CountDownLatch latch = new CountDownLatch(remaining.size());
|
CountDownLatch latch = new CountDownLatch(remaining.size());
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(modeThreads, r -> {
|
||||||
|
Thread t = new Thread(r, "r5-" + mode);
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
for (int idx : remaining) {
|
for (int idx : remaining) {
|
||||||
|
final int originIdx = idx;
|
||||||
pool.submit(() -> {
|
pool.submit(() -> {
|
||||||
try {
|
try {
|
||||||
processOrigin(network, postcodes, postcodeLats, postcodeLons,
|
processOrigin(network, postcodeIndex, transitTiles,
|
||||||
originLats[idx], originLons[idx],
|
postcodes, postcodeLats, postcodeLons,
|
||||||
modeDir, mode, date, idx, originNames[idx], threadConn.get(), enablePaths);
|
originLats[originIdx], originLons[originIdx],
|
||||||
completed.incrementAndGet();
|
modeDir, mode, date, originIdx, originNames[originIdx],
|
||||||
|
writeQueue, enablePaths, completed, failed, latch);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
// processOrigin only throws before a WriteJob is enqueued.
|
||||||
|
// The caller owns failure accounting and latch countdown.
|
||||||
failed.incrementAndGet();
|
failed.incrementAndGet();
|
||||||
System.err.printf("%n [FAIL] origin %s: %s%n", originNames[idx], e.getMessage());
|
System.err.printf("%n [FAIL] origin %s: %s%n", originNames[originIdx], e.getMessage());
|
||||||
} finally {
|
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
latch.await();
|
latch.await();
|
||||||
|
} finally {
|
||||||
|
pool.shutdown();
|
||||||
|
pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
|
||||||
reporter.shutdown();
|
reporter.shutdown();
|
||||||
reporter.awaitTermination(5, TimeUnit.SECONDS);
|
reporter.awaitTermination(5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
double elapsedH = (System.currentTimeMillis() - startMs) / 3_600_000.0;
|
double elapsedH = (System.currentTimeMillis() - startMs) / 3_600_000.0;
|
||||||
int n = completed.get();
|
int n = completed.get();
|
||||||
|
|
@ -211,13 +412,17 @@ public class App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute and write travel times for a single origin, with retry on failure. */
|
/**
|
||||||
|
* Route from a single origin (with retry), flatten the sparse result, and enqueue
|
||||||
|
* for the writer pool. The latch is decremented by the writer after enqueue.
|
||||||
|
*/
|
||||||
private static void processOrigin(
|
private static void processOrigin(
|
||||||
TransportNetwork network,
|
TransportNetwork network, STRtree postcodeIndex, List<PostcodeTile> transitTiles,
|
||||||
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
|
String[] postcodes, double[] postcodeLats, double[] postcodeLons,
|
||||||
double originLat, double originLon,
|
double originLat, double originLon,
|
||||||
Path modeDir, String mode, LocalDate date, int index, String name,
|
Path modeDir, String mode, LocalDate date, int index, String name,
|
||||||
DuckDBConnection conn, boolean enablePaths) throws Exception {
|
BlockingQueue<WriteJob> writeQueue, boolean enablePaths,
|
||||||
|
AtomicInteger completed, AtomicInteger failed, CountDownLatch latch) throws Exception {
|
||||||
|
|
||||||
Path outPath = modeDir.resolve(originFilename(index, name));
|
Path outPath = modeDir.resolve(originFilename(index, name));
|
||||||
Exception lastError = null;
|
Exception lastError = null;
|
||||||
|
|
@ -225,10 +430,10 @@ public class App {
|
||||||
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
try {
|
try {
|
||||||
Router.FilteredResult result = Router.computeForOrigin(
|
Router.FilteredResult result = Router.computeForOrigin(
|
||||||
network, postcodeLats, postcodeLons,
|
network, postcodeIndex, transitTiles, postcodeLats, postcodeLons,
|
||||||
originLat, originLon, mode, date, enablePaths);
|
originLat, originLon, mode, date, enablePaths);
|
||||||
|
|
||||||
// Write only reachable postcodes (sparse output)
|
// Flatten to only reachable postcodes (sparse output)
|
||||||
int reachable = 0;
|
int reachable = 0;
|
||||||
for (short t : result.times()) if (t >= 0) reachable++;
|
for (short t : result.times()) if (t >= 0) reachable++;
|
||||||
|
|
||||||
|
|
@ -247,11 +452,10 @@ public class App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestTimes != null) {
|
// Hand off to the writer pool. Blocking put() applies backpressure so
|
||||||
Parquet.writeTransitTravelTimes(conn, outPath, codes, times, bestTimes, journeys);
|
// routing slows down naturally if disk/compression can't keep up.
|
||||||
} else {
|
writeQueue.put(new WriteJob(outPath, codes, times, bestTimes, journeys,
|
||||||
Parquet.writeTravelTimes(conn, outPath, codes, times);
|
name, completed, failed, latch));
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
lastError = e;
|
lastError = e;
|
||||||
|
|
@ -264,6 +468,8 @@ public class App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// All retries exhausted; no WriteJob was enqueued, so the caller
|
||||||
|
// owns failure accounting and latch countdown.
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,6 +509,19 @@ public class App {
|
||||||
return slugs;
|
return slugs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap routing concurrency per mode based on memory footprint per in-flight task.
|
||||||
|
* See the MAX_*_THREADS constant block for the reasoning behind each cap.
|
||||||
|
*/
|
||||||
|
private static int threadsForMode(String mode, int defaultThreads) {
|
||||||
|
if (Router.isTransitMode(mode)) return Math.min(defaultThreads, MAX_TRANSIT_THREADS);
|
||||||
|
return switch (mode) {
|
||||||
|
case "car" -> Math.min(defaultThreads, MAX_CAR_THREADS);
|
||||||
|
case "bicycle" -> Math.min(defaultThreads, MAX_BICYCLE_THREADS);
|
||||||
|
default -> defaultThreads; // walking
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static String requiredArg(String[] args, String name) {
|
private static String requiredArg(String[] args, String name) {
|
||||||
for (int i = 0; i < args.length - 1; i++) {
|
for (int i = 0; i < args.length - 1; i++) {
|
||||||
if (args[i].equals(name)) return args[i + 1];
|
if (args[i].equals(name)) return args[i + 1];
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,11 @@ public class Parquet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try (Statement stmt = conn.createStatement()) {
|
try (Statement stmt = conn.createStatement()) {
|
||||||
stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)");
|
stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD, COMPRESSION_LEVEL 1)");
|
||||||
|
// Drop the populated table NOW so DuckDB releases its in-memory storage
|
||||||
|
// for the next write. Without this, the previous origin's rows linger
|
||||||
|
// until the next call's DROP IF EXISTS — accumulating across writers.
|
||||||
|
stmt.execute("DROP TABLE t");
|
||||||
}
|
}
|
||||||
Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +153,8 @@ public class Parquet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try (Statement stmt = conn.createStatement()) {
|
try (Statement stmt = conn.createStatement()) {
|
||||||
stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)");
|
stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD, COMPRESSION_LEVEL 1)");
|
||||||
|
stmt.execute("DROP TABLE t");
|
||||||
}
|
}
|
||||||
Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
}
|
}
|
||||||
|
|
@ -161,6 +166,6 @@ public class Parquet {
|
||||||
|
|
||||||
private static void copyToParquet(Statement stmt, String query, Path outPath) throws Exception {
|
private static void copyToParquet(Statement stmt, String query, Path outPath) throws Exception {
|
||||||
stmt.execute("COPY (" + query + ") TO '" + escapePath(outPath.toAbsolutePath().toString())
|
stmt.execute("COPY (" + query + ") TO '" + escapePath(outPath.toAbsolutePath().toString())
|
||||||
+ "' (FORMAT PARQUET, COMPRESSION ZSTD)");
|
+ "' (FORMAT PARQUET, COMPRESSION ZSTD, COMPRESSION_LEVEL 1)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package propertymap;
|
||||||
|
|
||||||
import com.conveyal.r5.OneOriginResult;
|
import com.conveyal.r5.OneOriginResult;
|
||||||
import com.conveyal.r5.analyst.FreeFormPointSet;
|
import com.conveyal.r5.analyst.FreeFormPointSet;
|
||||||
|
import com.conveyal.r5.analyst.LinkageCache;
|
||||||
import com.conveyal.r5.analyst.PointSet;
|
import com.conveyal.r5.analyst.PointSet;
|
||||||
import com.conveyal.r5.analyst.StreetTimesAndModes;
|
import com.conveyal.r5.analyst.StreetTimesAndModes;
|
||||||
import com.conveyal.r5.analyst.TravelTimeComputer;
|
import com.conveyal.r5.analyst.TravelTimeComputer;
|
||||||
|
|
@ -12,11 +13,14 @@ import com.conveyal.r5.analyst.cluster.TravelTimeResult;
|
||||||
import com.conveyal.r5.api.util.LegMode;
|
import com.conveyal.r5.api.util.LegMode;
|
||||||
import com.conveyal.r5.api.util.TransitModes;
|
import com.conveyal.r5.api.util.TransitModes;
|
||||||
import com.conveyal.r5.kryo.KryoNetworkSerializer;
|
import com.conveyal.r5.kryo.KryoNetworkSerializer;
|
||||||
|
import com.conveyal.r5.profile.StreetMode;
|
||||||
import com.conveyal.r5.transit.TransitLayer;
|
import com.conveyal.r5.transit.TransitLayer;
|
||||||
import com.conveyal.r5.transit.TransportNetwork;
|
import com.conveyal.r5.transit.TransportNetwork;
|
||||||
import com.conveyal.r5.transit.path.RouteSequence;
|
import com.conveyal.r5.transit.path.RouteSequence;
|
||||||
import com.google.common.collect.Multimap;
|
import com.google.common.collect.Multimap;
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
import org.locationtech.jts.geom.Coordinate;
|
||||||
|
import org.locationtech.jts.geom.Envelope;
|
||||||
|
import org.locationtech.jts.index.strtree.STRtree;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
@ -31,12 +35,29 @@ public class Router {
|
||||||
|
|
||||||
private static final int ZOOM = 9; // R5 enforces range 9-12
|
private static final int ZOOM = 9; // R5 enforces range 9-12
|
||||||
private static final int MAX_GRID_CELLS = 4_900_000; // under R5's 5M limit
|
private static final int MAX_GRID_CELLS = 4_900_000; // under R5's 5M limit
|
||||||
private static final int DEPARTURE_FROM_TIME = 7 * 3600 + 30 * 60; // 07:30
|
// 30-minute peak window: RAPTOR cost is linear in (toTime-fromTime)/60.
|
||||||
private static final int DEPARTURE_TO_TIME = 8 * 3600 + 30 * 60; // 08:30
|
// best_minutes (5th percentile) is the best of these 30 minute-shifted departures.
|
||||||
|
private static final int DEPARTURE_FROM_TIME = 7 * 3600 + 45 * 60; // 07:45
|
||||||
|
private static final int DEPARTURE_TO_TIME = 8 * 3600 + 15 * 60; // 08:15
|
||||||
private static final int MAX_TRIP_DURATION_MINUTES = 90;
|
private static final int MAX_TRIP_DURATION_MINUTES = 90;
|
||||||
|
// Transit-only: cap walk access/egress at 20 min to shrink the egress
|
||||||
|
// street subgraph PerTargetPropagater walks per stop.
|
||||||
|
private static final int TRANSIT_MAX_WALK_TIME_MIN = 20;
|
||||||
|
|
||||||
|
// Hard R5 limit when path recording is enabled (PathResult internals).
|
||||||
|
// Larger is better here: each chunk forces R5 to rebuild the egress cost
|
||||||
|
// table (~334k stop linkages), so fewer chunks per origin = fewer rebuilds.
|
||||||
private static final int PATH_MAX_DESTINATIONS = 5000;
|
private static final int PATH_MAX_DESTINATIONS = 5000;
|
||||||
|
|
||||||
|
// Per-chunk destination cap for non-transit direct modes (car/bicycle/walking).
|
||||||
|
// London car origins filter to ~1M postcodes within 150km. Without a cap, each
|
||||||
|
// chunk's per-task LinkedPointSet + FreeFormPointSet allocate ~50-100 MB and
|
||||||
|
// R5's StreetRouter scratch state stacks across concurrent workers, OOMing the
|
||||||
|
// heap. 150k caps per-chunk transient memory at ~5-10 MB; chunk count for
|
||||||
|
// London goes from 1 to ~7, adding ~10-20% wall-clock per origin via repeated
|
||||||
|
// Dijkstra. Walking has so few dests this is a no-op.
|
||||||
|
private static final int DIRECT_MAX_DESTINATIONS = 150_000;
|
||||||
|
|
||||||
// Percentile indices in R5 result arrays (order must match task.percentiles in buildTask)
|
// Percentile indices in R5 result arrays (order must match task.percentiles in buildTask)
|
||||||
private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only)
|
private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only)
|
||||||
private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0)
|
private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0)
|
||||||
|
|
@ -44,22 +65,69 @@ public class Router {
|
||||||
/** Result of computing travel times for a single origin with spatial pre-filtering. */
|
/** Result of computing travel times for a single origin with spatial pre-filtering. */
|
||||||
record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes, String[] journeys) {}
|
record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes, String[] journeys) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global transit tile: a destination subset bundled with the FreeFormPointSet
|
||||||
|
* R5 routes against. Reused across origins so R5's LinkageCache (and the
|
||||||
|
* expensive EgressCostTable) is built once per tile, not once per origin × chunk.
|
||||||
|
*/
|
||||||
|
record PostcodeTile(
|
||||||
|
FreeFormPointSet pointSet,
|
||||||
|
WebMercatorExtents extents,
|
||||||
|
int[] originalIndices,
|
||||||
|
double minLat, double maxLat, double minLon, double maxLon
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** True for any transit variant (transit, transit-no-bus, transit-no-change, …). */
|
||||||
|
static boolean isTransitMode(String mode) {
|
||||||
|
return mode.startsWith("transit");
|
||||||
|
}
|
||||||
|
|
||||||
/** Max plausible travel radius in km for {@link #MAX_TRIP_DURATION_MINUTES}-minute trips. */
|
/** Max plausible travel radius in km for {@link #MAX_TRIP_DURATION_MINUTES}-minute trips. */
|
||||||
static double maxRadiusKm(String mode) {
|
static double maxRadiusKm(String mode) {
|
||||||
|
if (isTransitMode(mode)) return 150;
|
||||||
return switch (mode) {
|
return switch (mode) {
|
||||||
case "car" -> 150;
|
case "car" -> 150;
|
||||||
case "transit" -> 150;
|
|
||||||
case "bicycle" -> 60;
|
case "bicycle" -> 60;
|
||||||
case "walking" -> 12;
|
case "walking" -> 12;
|
||||||
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
|
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transit variant configuration. {@code maxRides} is the number of transit legs:
|
||||||
|
* 1 = walk-transit-walk (no change), 2 = one change, 3 = two changes.
|
||||||
|
* {@code excludeBus} drops {@link TransitModes#BUS} from the allowed mode set.
|
||||||
|
*/
|
||||||
|
private record TransitConfig(int maxRides, boolean excludeBus) {}
|
||||||
|
|
||||||
|
private static TransitConfig transitConfigFor(String mode) {
|
||||||
|
return switch (mode) {
|
||||||
|
case "transit" -> new TransitConfig(3, false);
|
||||||
|
case "transit-no-bus" -> new TransitConfig(3, true);
|
||||||
|
case "transit-no-change" -> new TransitConfig(1, false);
|
||||||
|
case "transit-no-change-no-bus" -> new TransitConfig(1, true);
|
||||||
|
case "transit-one-change" -> new TransitConfig(2, false);
|
||||||
|
case "transit-one-change-no-bus" -> new TransitConfig(2, true);
|
||||||
|
default -> throw new IllegalArgumentException("Unknown transit mode: " + mode);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load or build the transport network with Kryo caching.
|
* Load or build the transport network with Kryo caching.
|
||||||
* The returned network is read-only after buildDistanceTables — safe for concurrent use.
|
* The returned network is read-only after buildDistanceTables — safe for concurrent use.
|
||||||
|
*
|
||||||
|
* The evictable LinkageCache is left small (32 entries) because non-transit modes
|
||||||
|
* create one huge per-origin LinkedPointSet each (~1M dests for car @ 150km radius).
|
||||||
|
* Caching 1024 such entries OOMs the heap. Transit tile linkages instead go into
|
||||||
|
* the unevictable {@code linkageMap} via {@link #preloadTransitTileLinkages} after
|
||||||
|
* tiles are built — that map has no count limit and is checked first on lookup.
|
||||||
*/
|
*/
|
||||||
static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception {
|
static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception {
|
||||||
|
// Must be set BEFORE the TransportNetwork is deserialized, since its LinkageCache
|
||||||
|
// is constructed (and sized) during that deserialization. 32 fits the working
|
||||||
|
// set of non-transit per-origin linkages without exhausting heap.
|
||||||
|
LinkageCache.LINKAGE_CACHE_SIZE = 32;
|
||||||
|
|
||||||
System.err.println("Loading transport network...");
|
System.err.println("Loading transport network...");
|
||||||
File cacheFile = new File(cacheDir, "network.dat");
|
File cacheFile = new File(cacheDir, "network.dat");
|
||||||
TransportNetwork network;
|
TransportNetwork network;
|
||||||
|
|
@ -111,20 +179,121 @@ public class Router {
|
||||||
System.err.printf(" Active transit services on %s: %,d%n", date, activeServices.cardinality());
|
System.err.printf(" Active transit services on %s: %,d%n", date, activeServices.cardinality());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate routing workload for an origin: count of postcodes within mode radius.
|
||||||
|
* Cheap STRtree bbox query; used as the LPT sort key for scheduling.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
static int estimateWorkload(STRtree postcodeIndex, double originLat, double originLon, double maxRadiusKm) {
|
||||||
|
double degLat = maxRadiusKm / 111.0;
|
||||||
|
double degLon = maxRadiusKm / (111.0 * Math.cos(Math.toRadians(originLat)));
|
||||||
|
Envelope env = new Envelope(
|
||||||
|
originLon - degLon, originLon + degLon,
|
||||||
|
originLat - degLat, originLat + degLat);
|
||||||
|
return postcodeIndex.query(env).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an STRtree spatial index over postcode points. Forces an initial query
|
||||||
|
* to trigger lazy build, so the returned tree is safe for concurrent queries.
|
||||||
|
*/
|
||||||
|
static STRtree buildPostcodeIndex(double[] lats, double[] lons) {
|
||||||
|
STRtree tree = new STRtree();
|
||||||
|
for (int i = 0; i < lats.length; i++) {
|
||||||
|
tree.insert(new Envelope(lons[i], lons[i], lats[i], lats[i]), Integer.valueOf(i));
|
||||||
|
}
|
||||||
|
// Force build (otherwise the first concurrent query races on lazy init)
|
||||||
|
tree.query(new Envelope(0, 0, 0, 0));
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WALK linkages for every transit tile and store them as unevictable on
|
||||||
|
* the network's LinkageCache. Subsequent transit routing calls get cache hits
|
||||||
|
* regardless of how many per-origin car/bike/walk linkages cycle through the
|
||||||
|
* evictable LRU. Logs progress because this is multi-minute work.
|
||||||
|
*/
|
||||||
|
static void preloadTransitTileLinkages(TransportNetwork network, List<PostcodeTile> tiles) {
|
||||||
|
System.err.printf("Pre-building WALK linkages for %,d transit tiles (unevictable)...%n", tiles.size());
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
int n = tiles.size();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
network.linkageCache.buildUnevictableLinkage(
|
||||||
|
tiles.get(i).pointSet(), network.streetLayer, StreetMode.WALK);
|
||||||
|
if ((i + 1) % 25 == 0 || i + 1 == n) {
|
||||||
|
double secs = (System.currentTimeMillis() - t0) / 1000.0;
|
||||||
|
System.err.printf(" %,d/%,d tile linkages built (%.1fs elapsed)%n", i + 1, n, secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build global transit tiles ONCE from all postcodes. Each tile holds a
|
||||||
|
* FreeFormPointSet that is reused across every transit origin, so R5's
|
||||||
|
* LinkageCache hits and the EgressCostTable is built once per tile rather
|
||||||
|
* than once per (origin × chunk). This is the dominant transit speed-up.
|
||||||
|
*
|
||||||
|
* Tiles are sized to the R5 path-result hard limit (PATH_MAX_DESTINATIONS=5000)
|
||||||
|
* so the same tiles serve both path-recording and non-path transit requests.
|
||||||
|
*/
|
||||||
|
static List<PostcodeTile> buildGlobalTransitTiles(double[] lats, double[] lons) {
|
||||||
|
int n = lats.length;
|
||||||
|
int[] sorted = sortIndicesByLat(lats);
|
||||||
|
|
||||||
|
// Global lon span sets gridWidth — all tiles share the same horizontal extent
|
||||||
|
// bound, so each tile is a horizontal band of postcodes.
|
||||||
|
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
|
||||||
|
for (double lon : lons) {
|
||||||
|
minLon = Math.min(minLon, lon);
|
||||||
|
maxLon = Math.max(maxLon, lon);
|
||||||
|
}
|
||||||
|
int totalPixels = 256 << ZOOM;
|
||||||
|
int gridWidth = lonToPixel(maxLon, totalPixels) - lonToPixel(minLon, totalPixels) + 1;
|
||||||
|
int maxHeight = MAX_GRID_CELLS / gridWidth;
|
||||||
|
|
||||||
|
List<PostcodeTile> tiles = new ArrayList<>();
|
||||||
|
int start = 0;
|
||||||
|
while (start < n) {
|
||||||
|
int end = start + 1;
|
||||||
|
int topPixel = latToPixel(lats[sorted[start]], totalPixels);
|
||||||
|
|
||||||
|
while (end < n) {
|
||||||
|
if (end - start >= PATH_MAX_DESTINATIONS) break;
|
||||||
|
int bottomPixel = latToPixel(lats[sorted[end]], totalPixels);
|
||||||
|
if (Math.abs(bottomPixel - topPixel) + 1 > maxHeight) break;
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
|
||||||
|
tiles.add(buildTile(lats, lons, sorted, start, end));
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter destinations by distance, build chunks, compute travel times for one origin.
|
* Filter destinations by distance, build chunks, compute travel times for one origin.
|
||||||
* Returns only the filtered subset indices and their travel times.
|
* Returns only the filtered subset indices and their travel times.
|
||||||
|
*
|
||||||
|
* Transit uses {@code globalTiles} (shared FreeFormPointSets → linkage cache hits);
|
||||||
|
* other modes use per-origin filter+chunk (no expensive linkage to amortize, and
|
||||||
|
* tiling would force routing to many irrelevant destinations).
|
||||||
*/
|
*/
|
||||||
static FilteredResult computeForOrigin(
|
static FilteredResult computeForOrigin(
|
||||||
TransportNetwork network,
|
TransportNetwork network,
|
||||||
|
STRtree postcodeIndex,
|
||||||
|
List<PostcodeTile> globalTiles,
|
||||||
double[] allLats, double[] allLons,
|
double[] allLats, double[] allLons,
|
||||||
double originLat, double originLon,
|
double originLat, double originLon,
|
||||||
String mode, LocalDate date, boolean enablePaths) {
|
String mode, LocalDate date, boolean enablePaths) {
|
||||||
|
|
||||||
|
if (isTransitMode(mode)) {
|
||||||
|
return computeTransit(network, globalTiles, originLat, originLon, mode, date, enablePaths);
|
||||||
|
}
|
||||||
|
|
||||||
double maxRadius = maxRadiusKm(mode);
|
double maxRadius = maxRadiusKm(mode);
|
||||||
|
|
||||||
// 1. Filter destinations by bounding box
|
// 1. Filter destinations by bounding box (STRtree query)
|
||||||
int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius);
|
int[] filtered = filterByDistance(postcodeIndex, originLat, originLon, maxRadius);
|
||||||
if (filtered.length == 0) {
|
if (filtered.length == 0) {
|
||||||
return new FilteredResult(new int[0], new short[0], null, null);
|
return new FilteredResult(new int[0], new short[0], null, null);
|
||||||
}
|
}
|
||||||
|
|
@ -137,56 +306,109 @@ public class Router {
|
||||||
fLons[i] = allLons[filtered[i]];
|
fLons[i] = allLons[filtered[i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Build chunks — smaller when path recording is active (R5 PathResult limit: 5000)
|
// 3. Build per-origin chunks. Cap at DIRECT_MAX_DESTINATIONS so car at 150km
|
||||||
boolean isTransit = mode.equals("transit");
|
// radius (~1M dests for London) gets split into ~7 manageable chunks
|
||||||
boolean recordPaths = isTransit && enablePaths;
|
// instead of one giant LinkedPointSet allocation.
|
||||||
int maxDestsPerChunk = recordPaths ? PATH_MAX_DESTINATIONS : Integer.MAX_VALUE;
|
List<DestinationChunk> chunks = buildDestinationChunks(fLats, fLons, DIRECT_MAX_DESTINATIONS);
|
||||||
List<DestinationChunk> chunks = buildDestinationChunks(fLats, fLons, maxDestsPerChunk);
|
|
||||||
|
|
||||||
// 4. Compute travel times (and optionally paths)
|
// 4. Compute travel times
|
||||||
String[] journeys = recordPaths ? new String[fLats.length] : null;
|
short[][] allTimes = computeTravelTimesDirect(network, chunks, originLat, originLon, mode, fLats.length, date);
|
||||||
short[][] allTimes = computeTravelTimes(
|
return new FilteredResult(filtered, allTimes[0], null, null);
|
||||||
network, chunks, originLat, originLon, mode, fLats.length, date,
|
}
|
||||||
recordPaths, journeys);
|
|
||||||
|
|
||||||
// Transit requests [5th, 50th] percentiles; others request [50th] only
|
/**
|
||||||
short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0];
|
* Transit routing path: route from origin to every global tile whose bbox intersects
|
||||||
short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null;
|
* the origin's max-radius bbox. Reuses tile FreeFormPointSets for R5 LinkageCache hits.
|
||||||
return new FilteredResult(filtered, medianTimes, bestTimes, journeys);
|
* {@code mode} selects the transit variant (rides cap, bus exclusion).
|
||||||
|
*/
|
||||||
|
private static FilteredResult computeTransit(
|
||||||
|
TransportNetwork network, List<PostcodeTile> globalTiles,
|
||||||
|
double originLat, double originLon, String mode, LocalDate date, boolean enablePaths) {
|
||||||
|
|
||||||
|
double maxRadius = maxRadiusKm(mode);
|
||||||
|
double degLat = maxRadius / 111.0;
|
||||||
|
double degLon = maxRadius / (111.0 * Math.cos(Math.toRadians(originLat)));
|
||||||
|
double oMinLat = originLat - degLat, oMaxLat = originLat + degLat;
|
||||||
|
double oMinLon = originLon - degLon, oMaxLon = originLon + degLon;
|
||||||
|
|
||||||
|
List<PostcodeTile> selected = new ArrayList<>();
|
||||||
|
int totalDests = 0;
|
||||||
|
for (PostcodeTile tile : globalTiles) {
|
||||||
|
if (tile.maxLat() < oMinLat || tile.minLat() > oMaxLat) continue;
|
||||||
|
if (tile.maxLon() < oMinLon || tile.minLon() > oMaxLon) continue;
|
||||||
|
selected.add(tile);
|
||||||
|
totalDests += tile.originalIndices().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty()) {
|
||||||
|
return new FilteredResult(new int[0], new short[0], new short[0],
|
||||||
|
enablePaths ? new String[0] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] outIndices = new int[totalDests];
|
||||||
|
short[] medianTimes = new short[totalDests];
|
||||||
|
short[] bestTimes = new short[totalDests];
|
||||||
|
Arrays.fill(medianTimes, (short) -1);
|
||||||
|
Arrays.fill(bestTimes, (short) -1);
|
||||||
|
String[] journeys = enablePaths ? new String[totalDests] : null;
|
||||||
|
|
||||||
|
int offset = 0;
|
||||||
|
for (PostcodeTile tile : selected) {
|
||||||
|
int tileLen = tile.originalIndices().length;
|
||||||
|
System.arraycopy(tile.originalIndices(), 0, outIndices, offset, tileLen);
|
||||||
|
|
||||||
|
RegionalTask task = buildTaskForTile(tile, originLat, originLon, mode, date, enablePaths);
|
||||||
|
TravelTimeComputer computer = new TravelTimeComputer(task, network);
|
||||||
|
OneOriginResult result = computer.computeTravelTimes();
|
||||||
|
|
||||||
|
TravelTimeResult tt = result.travelTimes;
|
||||||
|
if (tt == null) {
|
||||||
|
throw new RuntimeException("R5 returned null travelTimes for tile with " + tileLen + " destinations");
|
||||||
|
}
|
||||||
|
int[][] values = tt.getValues();
|
||||||
|
if (values.length < 2) {
|
||||||
|
throw new RuntimeException("R5 returned " + values.length + " percentiles, expected 2");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < tileLen; i++) {
|
||||||
|
if (values[PERCENTILE_BEST][i] != Integer.MAX_VALUE) {
|
||||||
|
bestTimes[offset + i] = (short) values[PERCENTILE_BEST][i];
|
||||||
|
}
|
||||||
|
if (values[PERCENTILE_MEDIAN][i] != Integer.MAX_VALUE) {
|
||||||
|
medianTimes[offset + i] = (short) values[PERCENTILE_MEDIAN][i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enablePaths && result.paths != null) {
|
||||||
|
extractPathsIntoOffset(result.paths, tileLen, offset, network.transitLayer, journeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += tileLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FilteredResult(outIndices, medianTimes, bestTimes, journeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter destination indices to those within a bounding box of maxRadiusKm from origin.
|
* Filter destination indices to those within a bounding box of maxRadiusKm from origin.
|
||||||
* Uses degree-based approximation — slightly overestimates at corners, which is fine.
|
* Uses degree-based approximation — slightly overestimates at corners, which is fine.
|
||||||
|
* Backed by STRtree: O(log n + k) per query instead of O(n) scan.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
private static int[] filterByDistance(
|
private static int[] filterByDistance(
|
||||||
double[] lats, double[] lons,
|
STRtree postcodeIndex,
|
||||||
double originLat, double originLon,
|
double originLat, double originLon,
|
||||||
double maxRadiusKm) {
|
double maxRadiusKm) {
|
||||||
|
|
||||||
double degLat = maxRadiusKm / 111.0;
|
double degLat = maxRadiusKm / 111.0;
|
||||||
double degLon = maxRadiusKm / (111.0 * Math.cos(Math.toRadians(originLat)));
|
double degLon = maxRadiusKm / (111.0 * Math.cos(Math.toRadians(originLat)));
|
||||||
|
|
||||||
double minLat = originLat - degLat;
|
Envelope queryEnv = new Envelope(
|
||||||
double maxLat = originLat + degLat;
|
originLon - degLon, originLon + degLon,
|
||||||
double minLon = originLon - degLon;
|
originLat - degLat, originLat + degLat);
|
||||||
double maxLon = originLon + degLon;
|
|
||||||
|
|
||||||
// Two-pass: count then fill (avoids ArrayList/boxing overhead)
|
List<Integer> hits = postcodeIndex.query(queryEnv);
|
||||||
int count = 0;
|
int[] result = new int[hits.size()];
|
||||||
for (int i = 0; i < lats.length; i++) {
|
for (int i = 0; i < hits.size(); i++) result[i] = hits.get(i);
|
||||||
if (lats[i] >= minLat && lats[i] <= maxLat && lons[i] >= minLon && lons[i] <= maxLon) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int[] result = new int[count];
|
|
||||||
int j = 0;
|
|
||||||
for (int i = 0; i < lats.length; i++) {
|
|
||||||
if (lats[i] >= minLat && lats[i] <= maxLat && lons[i] >= minLon && lons[i] <= maxLon) {
|
|
||||||
result[j++] = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,10 +421,10 @@ public class Router {
|
||||||
double[] lats, double[] lons, int maxDestsPerChunk) {
|
double[] lats, double[] lons, int maxDestsPerChunk) {
|
||||||
int n = lats.length;
|
int n = lats.length;
|
||||||
|
|
||||||
// Sort indices by latitude for geographic chunking
|
// Sort indices by latitude for geographic chunking — primitive long sort to
|
||||||
Integer[] sorted = new Integer[n];
|
// avoid Integer[] autoboxing per origin (millions of Integer allocs at scale).
|
||||||
for (int i = 0; i < n; i++) sorted[i] = i;
|
// Pack: high 32 bits = lat as sortable int, low 32 bits = original index.
|
||||||
Arrays.sort(sorted, (a, b) -> Double.compare(lats[a], lats[b]));
|
int[] sorted = sortIndicesByLat(lats);
|
||||||
|
|
||||||
// Determine grid width (longitude span is the same for all chunks)
|
// Determine grid width (longitude span is the same for all chunks)
|
||||||
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
|
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
|
||||||
|
|
@ -236,22 +458,18 @@ public class Router {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute travel times from one origin to all destinations across all chunks.
|
* Compute travel times for a non-transit mode (single percentile, no paths).
|
||||||
* Returns one short[] per requested percentile (transit gets 2: best + median, others get 1: median).
|
* Result is indexed into the per-origin filtered subset (via chunk.originalIndices).
|
||||||
* When recordPaths is true, also extracts journey instructions into journeysOut.
|
|
||||||
*/
|
*/
|
||||||
private static short[][] computeTravelTimes(
|
private static short[][] computeTravelTimesDirect(
|
||||||
TransportNetwork network, List<DestinationChunk> chunks,
|
TransportNetwork network, List<DestinationChunk> chunks,
|
||||||
double originLat, double originLon, String mode, int nDest, LocalDate date,
|
double originLat, double originLon, String mode, int nDest, LocalDate date) {
|
||||||
boolean recordPaths, String[] journeysOut) {
|
|
||||||
|
|
||||||
boolean isTransit = mode.equals("transit");
|
short[][] allTimes = new short[1][nDest];
|
||||||
int nPercentiles = isTransit ? 2 : 1;
|
Arrays.fill(allTimes[0], (short) -1);
|
||||||
short[][] allTimes = new short[nPercentiles][nDest];
|
|
||||||
for (short[] arr : allTimes) Arrays.fill(arr, (short) -1);
|
|
||||||
|
|
||||||
for (DestinationChunk chunk : chunks) {
|
for (DestinationChunk chunk : chunks) {
|
||||||
RegionalTask task = buildTask(chunk, originLat, originLon, mode, date, recordPaths);
|
RegionalTask task = buildTask(chunk, originLat, originLon, mode, date, false);
|
||||||
TravelTimeComputer computer = new TravelTimeComputer(task, network);
|
TravelTimeComputer computer = new TravelTimeComputer(task, network);
|
||||||
OneOriginResult result = computer.computeTravelTimes();
|
OneOriginResult result = computer.computeTravelTimes();
|
||||||
|
|
||||||
|
|
@ -261,41 +479,29 @@ public class Router {
|
||||||
+ chunk.originalIndices.length + " destinations");
|
+ chunk.originalIndices.length + " destinations");
|
||||||
}
|
}
|
||||||
int[][] values = tt.getValues();
|
int[][] values = tt.getValues();
|
||||||
if (values.length < nPercentiles) {
|
if (values.length < 1) {
|
||||||
throw new RuntimeException("R5 returned " + values.length + " percentiles, expected "
|
throw new RuntimeException("R5 returned 0 percentiles, expected 1");
|
||||||
+ nPercentiles);
|
|
||||||
}
|
|
||||||
for (int p = 0; p < nPercentiles; p++) {
|
|
||||||
if (values[p].length < chunk.originalIndices.length) {
|
|
||||||
throw new RuntimeException("R5 returned " + values[p].length
|
|
||||||
+ " travel times for percentile " + p + ", expected "
|
|
||||||
+ chunk.originalIndices.length);
|
|
||||||
}
|
}
|
||||||
for (int i = 0; i < chunk.originalIndices.length; i++) {
|
for (int i = 0; i < chunk.originalIndices.length; i++) {
|
||||||
if (values[p][i] != Integer.MAX_VALUE) {
|
if (values[0][i] != Integer.MAX_VALUE) {
|
||||||
allTimes[p][chunk.originalIndices[i]] = (short) values[p][i];
|
allTimes[0][chunk.originalIndices[i]] = (short) values[0][i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract path data for transit
|
|
||||||
if (recordPaths && journeysOut != null && result.paths != null) {
|
|
||||||
extractPaths(result.paths, chunk.originalIndices, network.transitLayer, journeysOut);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return allTimes;
|
return allTimes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the most common journey pattern for each destination in a chunk.
|
* Extract the most common journey pattern for each destination in a tile,
|
||||||
* Produces a JSON array of legs: [{mode, from?, to?, minutes}, ...].
|
* writing into a combined output array at the given offset.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static void extractPaths(
|
private static void extractPathsIntoOffset(
|
||||||
PathResult paths, int[] originalIndices, TransitLayer transitLayer,
|
PathResult paths, int tileLen, int offset, TransitLayer transitLayer,
|
||||||
String[] journeysOut) {
|
String[] journeysOut) {
|
||||||
Multimap<RouteSequence, PathResult.Iteration>[] allPaths = paths.iterationsForPathTemplates;
|
Multimap<RouteSequence, PathResult.Iteration>[] allPaths = paths.iterationsForPathTemplates;
|
||||||
for (int i = 0; i < originalIndices.length && i < allPaths.length; i++) {
|
int n = Math.min(tileLen, allPaths.length);
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
Multimap<RouteSequence, PathResult.Iteration> destPaths = allPaths[i];
|
Multimap<RouteSequence, PathResult.Iteration> destPaths = allPaths[i];
|
||||||
if (destPaths == null || destPaths.isEmpty()) continue;
|
if (destPaths == null || destPaths.isEmpty()) continue;
|
||||||
|
|
||||||
|
|
@ -311,7 +517,7 @@ public class Router {
|
||||||
}
|
}
|
||||||
if (bestRoute == null) continue;
|
if (bestRoute == null) continue;
|
||||||
|
|
||||||
journeysOut[originalIndices[i]] = buildJourneyJson(bestRoute, transitLayer);
|
journeysOut[offset + i] = buildJourneyJson(bestRoute, transitLayer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,8 +568,29 @@ public class Router {
|
||||||
|
|
||||||
private record DestinationChunk(FreeFormPointSet pointSet, WebMercatorExtents extents, int[] originalIndices) {}
|
private record DestinationChunk(FreeFormPointSet pointSet, WebMercatorExtents extents, int[] originalIndices) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort destination indices by latitude using a primitive long-packed sort.
|
||||||
|
* Encodes lat as a fixed-point microdeg int (+offset to keep it non-negative
|
||||||
|
* for any plausible lat) so high 32 bits of the packed long give a monotonic
|
||||||
|
* sort key. Low 32 bits hold the original index, breaking ties deterministically.
|
||||||
|
*/
|
||||||
|
private static int[] sortIndicesByLat(double[] lats) {
|
||||||
|
int n = lats.length;
|
||||||
|
long[] packed = new long[n];
|
||||||
|
// Offset by 90° so any lat in [-90, 90] maps to a non-negative key
|
||||||
|
long offset = 900_000_000L;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
long latKey = (long) Math.round(lats[i] * 10_000_000L) + offset;
|
||||||
|
packed[i] = (latKey << 32) | (i & 0xFFFFFFFFL);
|
||||||
|
}
|
||||||
|
Arrays.sort(packed);
|
||||||
|
int[] sorted = new int[n];
|
||||||
|
for (int i = 0; i < n; i++) sorted[i] = (int) (packed[i] & 0xFFFFFFFFL);
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
private static DestinationChunk buildChunk(
|
private static DestinationChunk buildChunk(
|
||||||
double[] lats, double[] lons, Integer[] sorted, int start, int end) {
|
double[] lats, double[] lons, int[] sorted, int start, int end) {
|
||||||
int size = end - start;
|
int size = end - start;
|
||||||
int[] originalIndices = new int[size];
|
int[] originalIndices = new int[size];
|
||||||
Coordinate[] coords = new Coordinate[size];
|
Coordinate[] coords = new Coordinate[size];
|
||||||
|
|
@ -392,6 +619,69 @@ public class Router {
|
||||||
return new DestinationChunk(pointSet, extents, originalIndices);
|
return new DestinationChunk(pointSet, extents, originalIndices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Like {@link #buildChunk} but produces a {@link PostcodeTile} with bbox + global indices. */
|
||||||
|
private static PostcodeTile buildTile(
|
||||||
|
double[] lats, double[] lons, int[] sorted, int start, int end) {
|
||||||
|
int size = end - start;
|
||||||
|
int[] originalIndices = new int[size];
|
||||||
|
Coordinate[] coords = new Coordinate[size];
|
||||||
|
double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE;
|
||||||
|
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
|
||||||
|
|
||||||
|
for (int i = 0; i < size; i++) {
|
||||||
|
int idx = sorted[start + i];
|
||||||
|
originalIndices[i] = idx;
|
||||||
|
double lat = lats[idx], lon = lons[idx];
|
||||||
|
coords[i] = new Coordinate(lon, lat);
|
||||||
|
minLat = Math.min(minLat, lat);
|
||||||
|
maxLat = Math.max(maxLat, lat);
|
||||||
|
minLon = Math.min(minLon, lon);
|
||||||
|
maxLon = Math.max(maxLon, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
FreeFormPointSet pointSet = new FreeFormPointSet(coords);
|
||||||
|
int totalPixels = 256 << ZOOM;
|
||||||
|
int west = lonToPixel(minLon, totalPixels);
|
||||||
|
int north = latToPixel(maxLat, totalPixels);
|
||||||
|
int width = lonToPixel(maxLon, totalPixels) - west + 1;
|
||||||
|
int height = latToPixel(minLat, totalPixels) - north + 1;
|
||||||
|
WebMercatorExtents extents = new WebMercatorExtents(west, north, width, height, ZOOM);
|
||||||
|
|
||||||
|
return new PostcodeTile(pointSet, extents, originalIndices, minLat, maxLat, minLon, maxLon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a transit RegionalTask that targets one global tile, configured by {@code mode}. */
|
||||||
|
private static RegionalTask buildTaskForTile(
|
||||||
|
PostcodeTile tile, double originLat, double originLon, String mode, LocalDate date, boolean recordPaths) {
|
||||||
|
RegionalTask task = new RegionalTask();
|
||||||
|
task.fromLat = originLat;
|
||||||
|
task.fromLon = originLon;
|
||||||
|
task.date = date;
|
||||||
|
task.percentiles = new int[]{5, 50};
|
||||||
|
task.recordTimes = true;
|
||||||
|
task.destinationPointSets = new PointSet[]{tile.pointSet()};
|
||||||
|
task.zoom = tile.extents().zoom;
|
||||||
|
task.west = tile.extents().west;
|
||||||
|
task.north = tile.extents().north;
|
||||||
|
task.width = tile.extents().width;
|
||||||
|
task.height = tile.extents().height;
|
||||||
|
task.fromTime = DEPARTURE_FROM_TIME;
|
||||||
|
task.toTime = DEPARTURE_TO_TIME;
|
||||||
|
task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES;
|
||||||
|
// TfL GTFS uses frequency-based service patterns. With the default
|
||||||
|
// monteCarloDraws=220 R5 runs 8 iters/min (~240 iters per 30-min window).
|
||||||
|
// Set to 0 to use HALF_HEADWAY mode → 1 iter/min, deterministic, 8x cheaper.
|
||||||
|
task.monteCarloDraws = 0;
|
||||||
|
|
||||||
|
if (recordPaths) {
|
||||||
|
task.includePathResults = true;
|
||||||
|
task.nPathsPerTarget = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
configureMode(task, mode);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
private static RegionalTask buildTask(
|
private static RegionalTask buildTask(
|
||||||
DestinationChunk chunk, double originLat, double originLon, String mode, LocalDate date,
|
DestinationChunk chunk, double originLat, double originLon, String mode, LocalDate date,
|
||||||
boolean recordPaths) {
|
boolean recordPaths) {
|
||||||
|
|
@ -423,20 +713,25 @@ public class Router {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void configureMode(RegionalTask task, String mode) {
|
private static void configureMode(RegionalTask task, String mode) {
|
||||||
switch (mode) {
|
if (isTransitMode(mode)) {
|
||||||
case "car" -> setDirectMode(task, LegMode.CAR);
|
TransitConfig config = transitConfigFor(mode);
|
||||||
case "bicycle" -> setDirectMode(task, LegMode.BICYCLE);
|
task.maxRides = config.maxRides();
|
||||||
case "walking" -> setDirectMode(task, LegMode.WALK);
|
task.maxWalkTime = TRANSIT_MAX_WALK_TIME_MIN;
|
||||||
case "transit" -> {
|
|
||||||
task.maxRides = 4;
|
|
||||||
// R5 requires directModes ⊆ accessModes. BICYCLE egress is too expensive
|
// R5 requires directModes ⊆ accessModes. BICYCLE egress is too expensive
|
||||||
// (builds cost tables from 59k stops × N destinations), so keep WALK only
|
// (builds cost tables from 59k stops × N destinations), so keep WALK only
|
||||||
// for egress and match access/direct to avoid the R5 validation error.
|
// for egress and match access/direct to avoid the R5 validation error.
|
||||||
task.accessModes = EnumSet.of(LegMode.WALK);
|
task.accessModes = EnumSet.of(LegMode.WALK);
|
||||||
task.egressModes = EnumSet.of(LegMode.WALK);
|
task.egressModes = EnumSet.of(LegMode.WALK);
|
||||||
task.directModes = EnumSet.of(LegMode.WALK);
|
task.directModes = EnumSet.of(LegMode.WALK);
|
||||||
task.transitModes = EnumSet.allOf(TransitModes.class);
|
EnumSet<TransitModes> transitModes = EnumSet.allOf(TransitModes.class);
|
||||||
|
if (config.excludeBus()) transitModes.remove(TransitModes.BUS);
|
||||||
|
task.transitModes = transitModes;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
switch (mode) {
|
||||||
|
case "car" -> setDirectMode(task, LegMode.CAR);
|
||||||
|
case "bicycle" -> setDirectMode(task, LegMode.BICYCLE);
|
||||||
|
case "walking" -> setDirectMode(task, LegMode.WALK);
|
||||||
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
|
default -> throw new IllegalArgumentException("Unknown mode: " + mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
|
||||||
amenityCount2km: 'Number%20of%20amenities%20(Cafe)%20within%202km:2:8',
|
amenityCount2km: 'Number%20of%20amenities%20(Cafe)%20within%202km:2:8',
|
||||||
amenityCount5km: 'Number%20of%20amenities%20(Park)%20within%205km:1:20',
|
amenityCount5km: 'Number%20of%20amenities%20(Park)%20within%205km:1:20',
|
||||||
poi: 'supermarket',
|
poi: 'supermarket',
|
||||||
|
overlay: ['noise', 'crime-hotspots'],
|
||||||
tt: 'transit:kings-cross:Kings Cross:b:0:30',
|
tt: 'transit:kings-cross:Kings Cross:b:0:30',
|
||||||
share: 'abc123',
|
share: 'abc123',
|
||||||
pc: 'SW1A 1AA',
|
pc: 'SW1A 1AA',
|
||||||
|
|
@ -59,6 +60,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
|
||||||
assert.deepEqual(result.qs.getAll('amenityCount5km'), [
|
assert.deepEqual(result.qs.getAll('amenityCount5km'), [
|
||||||
'Number%20of%20amenities%20(Park)%20within%205km:1:20',
|
'Number%20of%20amenities%20(Park)%20within%205km:1:20',
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(result.qs.getAll('overlay'), ['noise', 'crime-hotspots']);
|
||||||
assert.equal(result.qs.get('share'), 'abc123');
|
assert.equal(result.qs.get('share'), 'abc123');
|
||||||
assert.equal(result.qs.get('pc'), 'SW1A 1AA');
|
assert.equal(result.qs.get('pc'), 'SW1A 1AA');
|
||||||
assert.equal(result.qs.get('lang'), 'fr');
|
assert.equal(result.qs.get('lang'), 'fr');
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const REPEATED_KEYS = [
|
||||||
'amenityCount2km',
|
'amenityCount2km',
|
||||||
'amenityCount5km',
|
'amenityCount5km',
|
||||||
'poi',
|
'poi',
|
||||||
|
'overlay',
|
||||||
'tt',
|
'tt',
|
||||||
] as const;
|
] as const;
|
||||||
const PASSTHROUGH_SINGLE_KEYS = ['share', 'pc'] as const;
|
const PASSTHROUGH_SINGLE_KEYS = ['share', 'pc'] as const;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod actual_listings;
|
mod actual_listings;
|
||||||
|
pub mod crime_by_year;
|
||||||
mod places;
|
mod places;
|
||||||
mod poi;
|
mod poi;
|
||||||
mod postcodes;
|
mod postcodes;
|
||||||
|
|
@ -35,11 +36,12 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use actual_listings::{ActualListing, ActualListingData};
|
pub use actual_listings::{ActualListing, ActualListingData};
|
||||||
|
pub use crime_by_year::CrimeByYearData;
|
||||||
pub use places::{normalize_search_text, PlaceData};
|
pub use places::{normalize_search_text, PlaceData};
|
||||||
pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData};
|
pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMetadata};
|
||||||
pub use postcodes::{OutcodeData, PostcodeData};
|
pub use postcodes::{OutcodeData, PostcodeData};
|
||||||
pub use property::{
|
pub use property::{
|
||||||
precompute_h3, FeatureStats, Histogram, PostcodePoiMetrics, PropertyData, QuantRef,
|
precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData,
|
||||||
RenovationEvent,
|
QuantRef, RenovationEvent,
|
||||||
};
|
};
|
||||||
pub use travel_time::{slugify, TravelTimeStore};
|
pub use travel_time::{slugify, TravelTimeStore};
|
||||||
|
|
|
||||||
187
server-rs/src/data/crime_by_year.rs
Normal file
187
server-rs/src/data/crime_by_year.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
//! Per-LSOA per-crime-type per-year crime counts, loaded from a side parquet
|
||||||
|
//! and used by the right pane to plot crime-over-time. Filtering is not
|
||||||
|
//! supported — this data is display-only.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use polars::lazy::frame::LazyFrame;
|
||||||
|
use polars::prelude::PlRefPath;
|
||||||
|
use polars::prelude::*;
|
||||||
|
use rustc_hash::FxHashMap;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use super::run_polars_io;
|
||||||
|
|
||||||
|
/// Suffix appended to the underlying crime-type column name in the parquet
|
||||||
|
/// (e.g. `"Burglary (by year)"`). Stripped to derive the display name.
|
||||||
|
pub const BY_YEAR_SUFFIX: &str = " (by year)";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct YearPoint {
|
||||||
|
pub year: i32,
|
||||||
|
pub count: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One per crime type: ordered list of (year, count) for a single LSOA.
|
||||||
|
pub struct LsoaCrimeSeries {
|
||||||
|
/// Index into `crime_types`.
|
||||||
|
pub type_idx: u16,
|
||||||
|
pub points: Vec<YearPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CrimeByYearData {
|
||||||
|
/// All crime type names in stable insertion order.
|
||||||
|
pub crime_types: Vec<String>,
|
||||||
|
/// All years available for each crime type, same order as `crime_types`.
|
||||||
|
pub years_by_type: Vec<Vec<i32>>,
|
||||||
|
/// LSOA code → all available per-type series for that LSOA.
|
||||||
|
pub series_by_lsoa: FxHashMap<String, Vec<LsoaCrimeSeries>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrimeByYearData {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
crime_types: Vec::new(),
|
||||||
|
years_by_type: Vec::new(),
|
||||||
|
series_by_lsoa: FxHashMap::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
run_polars_io(|| Self::load_inner(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_inner(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
info!("Loading crime-by-year from {}", path.display());
|
||||||
|
let pl_path = PlRefPath::try_from_path(path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to normalize crime-by-year parquet path {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let df = LazyFrame::scan_parquet(pl_path, Default::default())
|
||||||
|
.with_context(|| format!("Failed to scan crime-by-year parquet at {}", path.display()))?
|
||||||
|
.collect()
|
||||||
|
.with_context(|| {
|
||||||
|
format!("Failed to read crime-by-year parquet at {}", path.display())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let lsoa_col = df
|
||||||
|
.column("LSOA code")
|
||||||
|
.context("crime-by-year parquet missing 'LSOA code' column")?
|
||||||
|
.str()
|
||||||
|
.context("'LSOA code' column is not a string")?;
|
||||||
|
let lsoa_values: Vec<String> = lsoa_col
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row, value)| {
|
||||||
|
let value =
|
||||||
|
value.with_context(|| format!("crime-by-year row {row} has null LSOA code"))?;
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
bail!("crime-by-year row {row} has blank LSOA code");
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
})
|
||||||
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
// Discover crime-type columns (anything with the by-year suffix).
|
||||||
|
let crime_type_cols: Vec<(String, String)> = df
|
||||||
|
.get_column_names()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|name| {
|
||||||
|
let name = name.as_str();
|
||||||
|
name.strip_suffix(BY_YEAR_SUFFIX)
|
||||||
|
.map(|stripped| (stripped.to_string(), name.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if crime_type_cols.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"crime-by-year parquet at {} has no '* (by year)' columns",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let crime_types: Vec<String> = crime_type_cols.iter().map(|(t, _)| t.clone()).collect();
|
||||||
|
|
||||||
|
let mut series_by_lsoa: FxHashMap<String, Vec<LsoaCrimeSeries>> = FxHashMap::default();
|
||||||
|
let mut years_by_type: Vec<Vec<i32>> = Vec::with_capacity(crime_type_cols.len());
|
||||||
|
let row_count = df.height();
|
||||||
|
|
||||||
|
for (type_idx, (_, col_name)) in crime_type_cols.iter().enumerate() {
|
||||||
|
let mut years_for_type = std::collections::BTreeSet::new();
|
||||||
|
let col = df
|
||||||
|
.column(col_name)
|
||||||
|
.with_context(|| format!("Missing crime-by-year column '{col_name}'"))?;
|
||||||
|
let list_ca = col
|
||||||
|
.list()
|
||||||
|
.with_context(|| format!("Column '{col_name}' is not a list"))?;
|
||||||
|
|
||||||
|
for row in 0..row_count {
|
||||||
|
let Some(inner) = list_ca.get_as_series(row) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if inner.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let structs = inner
|
||||||
|
.struct_()
|
||||||
|
.with_context(|| format!("Inner of '{col_name}' is not a struct"))?;
|
||||||
|
let years = structs
|
||||||
|
.field_by_name("year")
|
||||||
|
.with_context(|| format!("Missing 'year' field in '{col_name}'"))?;
|
||||||
|
let counts = structs
|
||||||
|
.field_by_name("count")
|
||||||
|
.with_context(|| format!("Missing 'count' field in '{col_name}'"))?;
|
||||||
|
|
||||||
|
let mut points: Vec<YearPoint> = Vec::with_capacity(inner.len());
|
||||||
|
for idx in 0..inner.len() {
|
||||||
|
let yr = match years.get(idx).ok() {
|
||||||
|
Some(AnyValue::Int32(y)) => y,
|
||||||
|
Some(AnyValue::Int64(y)) => y as i32,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let cnt = match counts.get(idx).ok() {
|
||||||
|
Some(AnyValue::Float32(c)) => c,
|
||||||
|
Some(AnyValue::Float64(c)) => c as f32,
|
||||||
|
Some(AnyValue::Int32(c)) => c as f32,
|
||||||
|
Some(AnyValue::Int64(c)) => c as f32,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
points.push(YearPoint {
|
||||||
|
year: yr,
|
||||||
|
count: cnt,
|
||||||
|
});
|
||||||
|
years_for_type.insert(yr);
|
||||||
|
}
|
||||||
|
if points.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
points.sort_by_key(|p| p.year);
|
||||||
|
|
||||||
|
series_by_lsoa
|
||||||
|
.entry(lsoa_values[row].clone())
|
||||||
|
.or_default()
|
||||||
|
.push(LsoaCrimeSeries {
|
||||||
|
type_idx: type_idx as u16,
|
||||||
|
points,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
years_by_type.push(years_for_type.into_iter().collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
lsoas = series_by_lsoa.len(),
|
||||||
|
crime_types = crime_types.len(),
|
||||||
|
"Crime-by-year data loaded"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
crime_types,
|
||||||
|
years_by_type,
|
||||||
|
series_by_lsoa,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -131,6 +131,51 @@ pub fn resolve_poi_category_filter(category_values: &[String], categories: &str)
|
||||||
selected
|
selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Metadata for state-funded school POIs (sourced from the DfE GIAS register).
|
||||||
|
/// Every field is optional because GIAS does not populate every column for every
|
||||||
|
/// establishment type (e.g. nurseries have no sixth form, FE colleges no FSM).
|
||||||
|
#[derive(Serialize, Clone, Default)]
|
||||||
|
pub struct SchoolMetadata {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phase: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub r#type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub type_group: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub age_range: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gender: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub religious_character: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub admissions_policy: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub nursery_provision: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sixth_form: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub capacity: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pupils: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fsm_percent: Option<f32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trust: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub address: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub postcode: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub local_authority: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub website: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub telephone: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub head_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct POIData {
|
pub struct POIData {
|
||||||
/// Contiguous buffer holding all POI ID strings end-to-end.
|
/// Contiguous buffer holding all POI ID strings end-to-end.
|
||||||
id_buffer: String,
|
id_buffer: String,
|
||||||
|
|
@ -149,6 +194,11 @@ pub struct POIData {
|
||||||
/// uniform subset when the POI count exceeds the per-request limit.
|
/// uniform subset when the POI count exceeds the per-request limit.
|
||||||
/// Computed once at load time so the same POIs are always chosen for a given viewport.
|
/// Computed once at load time so the same POIs are always chosen for a given viewport.
|
||||||
pub priority: Vec<u32>,
|
pub priority: Vec<u32>,
|
||||||
|
/// Indirection table: row idx → index into `school_meta`, or u32::MAX when
|
||||||
|
/// the POI is not a school. Keeps the per-row overhead at 4 bytes regardless
|
||||||
|
/// of how many school metadata fields we carry.
|
||||||
|
school_meta_idx: Vec<u32>,
|
||||||
|
school_meta: Vec<SchoolMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl POIData {
|
impl POIData {
|
||||||
|
|
@ -158,6 +208,16 @@ impl POIData {
|
||||||
let length = self.id_lengths[row] as usize;
|
let length = self.id_lengths[row] as usize;
|
||||||
&self.id_buffer[offset..offset + length]
|
&self.id_buffer[offset..offset + length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the school metadata for a given row, or None if not a school.
|
||||||
|
pub fn school(&self, row: usize) -> Option<&SchoolMetadata> {
|
||||||
|
let idx = self.school_meta_idx[row];
|
||||||
|
if idx == u32::MAX {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&self.school_meta[idx as usize])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
|
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
|
||||||
|
|
@ -195,6 +255,146 @@ fn extract_f32_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<f32>> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an optional string column. Returns None when the column itself is missing
|
||||||
|
/// (older POI parquets without the school_* extension); returns Some(vec) of
|
||||||
|
/// length row_count where each entry is None for null cells.
|
||||||
|
fn extract_optional_str_col(
|
||||||
|
df: &DataFrame,
|
||||||
|
name: &str,
|
||||||
|
) -> anyhow::Result<Option<Vec<Option<String>>>> {
|
||||||
|
let column = match df.column(name) {
|
||||||
|
Ok(column) => column,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let string_column = column
|
||||||
|
.str()
|
||||||
|
.with_context(|| format!("Column '{name}' is not a string column"))?;
|
||||||
|
Ok(Some(
|
||||||
|
string_column
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.map(ToString::to_string))
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_optional_u32_col(
|
||||||
|
df: &DataFrame,
|
||||||
|
name: &str,
|
||||||
|
) -> anyhow::Result<Option<Vec<Option<u32>>>> {
|
||||||
|
let column = match df.column(name) {
|
||||||
|
Ok(column) => column,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let cast = column
|
||||||
|
.cast(&DataType::Int64)
|
||||||
|
.with_context(|| format!("Failed to cast column '{name}' to Int64"))?;
|
||||||
|
let int_column = cast
|
||||||
|
.i64()
|
||||||
|
.with_context(|| format!("Column '{name}' is not an integer column"))?;
|
||||||
|
Ok(Some(
|
||||||
|
int_column
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.and_then(|v| if v < 0 { None } else { Some(v as u32) }))
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_optional_f32_col(
|
||||||
|
df: &DataFrame,
|
||||||
|
name: &str,
|
||||||
|
) -> anyhow::Result<Option<Vec<Option<f32>>>> {
|
||||||
|
let column = match df.column(name) {
|
||||||
|
Ok(column) => column,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let cast = column
|
||||||
|
.cast(&DataType::Float32)
|
||||||
|
.with_context(|| format!("Failed to cast column '{name}' to Float32"))?;
|
||||||
|
let float_column = cast
|
||||||
|
.f32()
|
||||||
|
.with_context(|| format!("Column '{name}' is not a float32 column"))?;
|
||||||
|
Ok(Some(float_column.into_iter().collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_school_meta(
|
||||||
|
row_count: usize,
|
||||||
|
df: &DataFrame,
|
||||||
|
) -> anyhow::Result<(Vec<u32>, Vec<SchoolMetadata>)> {
|
||||||
|
let phase = extract_optional_str_col(df, "school_phase")?;
|
||||||
|
if phase.is_none() {
|
||||||
|
// POI parquet predates the school metadata extension — record an empty
|
||||||
|
// table and a sentinel-filled index, so callers transparently see None.
|
||||||
|
return Ok((vec![u32::MAX; row_count], Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase = phase.unwrap();
|
||||||
|
let r#type = extract_optional_str_col(df, "school_type")?.unwrap_or_default();
|
||||||
|
let type_group = extract_optional_str_col(df, "school_type_group")?.unwrap_or_default();
|
||||||
|
let age_range = extract_optional_str_col(df, "school_age_range")?.unwrap_or_default();
|
||||||
|
let gender = extract_optional_str_col(df, "school_gender")?.unwrap_or_default();
|
||||||
|
let religious_character =
|
||||||
|
extract_optional_str_col(df, "school_religious_character")?.unwrap_or_default();
|
||||||
|
let admissions_policy =
|
||||||
|
extract_optional_str_col(df, "school_admissions_policy")?.unwrap_or_default();
|
||||||
|
let nursery_provision =
|
||||||
|
extract_optional_str_col(df, "school_nursery_provision")?.unwrap_or_default();
|
||||||
|
let sixth_form = extract_optional_str_col(df, "school_sixth_form")?.unwrap_or_default();
|
||||||
|
let capacity = extract_optional_u32_col(df, "school_capacity")?.unwrap_or_default();
|
||||||
|
let pupils = extract_optional_u32_col(df, "school_pupils")?.unwrap_or_default();
|
||||||
|
let fsm_percent = extract_optional_f32_col(df, "school_fsm_percent")?.unwrap_or_default();
|
||||||
|
let trust = extract_optional_str_col(df, "school_trust")?.unwrap_or_default();
|
||||||
|
let address = extract_optional_str_col(df, "school_address")?.unwrap_or_default();
|
||||||
|
let postcode = extract_optional_str_col(df, "school_postcode")?.unwrap_or_default();
|
||||||
|
let local_authority =
|
||||||
|
extract_optional_str_col(df, "school_local_authority")?.unwrap_or_default();
|
||||||
|
let website = extract_optional_str_col(df, "school_website")?.unwrap_or_default();
|
||||||
|
let telephone = extract_optional_str_col(df, "school_telephone")?.unwrap_or_default();
|
||||||
|
let head_name = extract_optional_str_col(df, "school_head_name")?.unwrap_or_default();
|
||||||
|
|
||||||
|
let fetch_str = |col: &Vec<Option<String>>, row: usize| -> Option<String> {
|
||||||
|
col.get(row).cloned().flatten()
|
||||||
|
};
|
||||||
|
let fetch_u32 =
|
||||||
|
|col: &Vec<Option<u32>>, row: usize| -> Option<u32> { col.get(row).copied().flatten() };
|
||||||
|
let fetch_f32 =
|
||||||
|
|col: &Vec<Option<f32>>, row: usize| -> Option<f32> { col.get(row).copied().flatten() };
|
||||||
|
|
||||||
|
let mut idx = vec![u32::MAX; row_count];
|
||||||
|
let mut meta = Vec::new();
|
||||||
|
for row in 0..row_count {
|
||||||
|
let type_group_val = fetch_str(&type_group, row);
|
||||||
|
let type_val = fetch_str(&r#type, row);
|
||||||
|
// type_group is present for every GIAS row, so use it as the sentinel
|
||||||
|
// for "this POI is a school" — matches the pipeline guarantee.
|
||||||
|
if type_group_val.is_none() && type_val.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
idx[row] = meta.len() as u32;
|
||||||
|
meta.push(SchoolMetadata {
|
||||||
|
phase: fetch_str(&phase, row),
|
||||||
|
r#type: type_val,
|
||||||
|
type_group: type_group_val,
|
||||||
|
age_range: fetch_str(&age_range, row),
|
||||||
|
gender: fetch_str(&gender, row),
|
||||||
|
religious_character: fetch_str(&religious_character, row),
|
||||||
|
admissions_policy: fetch_str(&admissions_policy, row),
|
||||||
|
nursery_provision: fetch_str(&nursery_provision, row),
|
||||||
|
sixth_form: fetch_str(&sixth_form, row),
|
||||||
|
capacity: fetch_u32(&capacity, row),
|
||||||
|
pupils: fetch_u32(&pupils, row),
|
||||||
|
fsm_percent: fetch_f32(&fsm_percent, row),
|
||||||
|
trust: fetch_str(&trust, row),
|
||||||
|
address: fetch_str(&address, row),
|
||||||
|
postcode: fetch_str(&postcode, row),
|
||||||
|
local_authority: fetch_str(&local_authority, row),
|
||||||
|
website: fetch_str(&website, row),
|
||||||
|
telephone: fetch_str(&telephone, row),
|
||||||
|
head_name: fetch_str(&head_name, row),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok((idx, meta))
|
||||||
|
}
|
||||||
|
|
||||||
impl POIData {
|
impl POIData {
|
||||||
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
|
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
|
||||||
super::run_polars_io(|| Self::load_inner(parquet_path))
|
super::run_polars_io(|| Self::load_inner(parquet_path))
|
||||||
|
|
@ -259,6 +459,9 @@ impl POIData {
|
||||||
// preventing visual "shuffling" when panning the map.
|
// preventing visual "shuffling" when panning the map.
|
||||||
let priority = generate_priorities(row_count);
|
let priority = generate_priorities(row_count);
|
||||||
|
|
||||||
|
let (school_meta_idx, school_meta) = build_school_meta(row_count, &df)?;
|
||||||
|
info!(schools = school_meta.len(), "Loaded GIAS school metadata");
|
||||||
|
|
||||||
info!("POI data loading complete.");
|
info!("POI data loading complete.");
|
||||||
|
|
||||||
Ok(POIData {
|
Ok(POIData {
|
||||||
|
|
@ -273,6 +476,8 @@ impl POIData {
|
||||||
lng,
|
lng,
|
||||||
emoji,
|
emoji,
|
||||||
priority,
|
priority,
|
||||||
|
school_meta_idx,
|
||||||
|
school_meta,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,13 @@ pub struct RenovationEvent {
|
||||||
pub event: String,
|
pub event: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct HistoricalPrice {
|
||||||
|
pub year: i32,
|
||||||
|
pub month: u8,
|
||||||
|
pub price: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Lightweight reference to quantization parameters for decoding u16 feature data.
|
/// Lightweight reference to quantization parameters for decoding u16 feature data.
|
||||||
pub struct QuantRef<'a> {
|
pub struct QuantRef<'a> {
|
||||||
pub dequant_a: &'a [f32],
|
pub dequant_a: &'a [f32],
|
||||||
|
|
@ -824,6 +831,10 @@ pub struct PropertyData {
|
||||||
/// Interned postcodes: reader is thread-safe, keys index into it.
|
/// Interned postcodes: reader is thread-safe, keys index into it.
|
||||||
postcode_interner: lasso::RodeoReader,
|
postcode_interner: lasso::RodeoReader,
|
||||||
postcode_keys: Vec<lasso::Spur>,
|
postcode_keys: Vec<lasso::Spur>,
|
||||||
|
/// Interned LSOA (2021) codes per row.
|
||||||
|
/// Used to look up per-LSOA side tables (e.g. crime time series).
|
||||||
|
lsoa_interner: lasso::RodeoReader,
|
||||||
|
lsoa_keys: Vec<lasso::Spur>,
|
||||||
/// Rows for each postcode, keyed by the interned postcode key.
|
/// Rows for each postcode, keyed by the interned postcode key.
|
||||||
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
|
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
|
||||||
/// Inverted index from address tokens to property rows.
|
/// Inverted index from address tokens to property rows.
|
||||||
|
|
@ -850,6 +861,9 @@ pub struct PropertyData {
|
||||||
/// Per-row renovation events. Keyed by (permuted) row index.
|
/// Per-row renovation events. Keyed by (permuted) row index.
|
||||||
/// Only rows with events are present in the map.
|
/// Only rows with events are present in the map.
|
||||||
renovation_history: FxHashMap<u32, Vec<RenovationEvent>>,
|
renovation_history: FxHashMap<u32, Vec<RenovationEvent>>,
|
||||||
|
/// Per-row historical sale transactions (Land Registry price-paid).
|
||||||
|
/// Keyed by (permuted) row index. Only rows with prices are present.
|
||||||
|
historical_prices: FxHashMap<u32, Vec<HistoricalPrice>>,
|
||||||
property_sub_type: FxHashMap<u32, String>,
|
property_sub_type: FxHashMap<u32, String>,
|
||||||
price_qualifier: FxHashMap<u32, String>,
|
price_qualifier: FxHashMap<u32, String>,
|
||||||
}
|
}
|
||||||
|
|
@ -867,6 +881,11 @@ impl PropertyData {
|
||||||
self.postcode_interner.resolve(&self.postcode_keys[row])
|
self.postcode_interner.resolve(&self.postcode_keys[row])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the LSOA (2021) code for a given row.
|
||||||
|
pub fn lsoa(&self, row: usize) -> &str {
|
||||||
|
self.lsoa_interner.resolve(&self.lsoa_keys[row])
|
||||||
|
}
|
||||||
|
|
||||||
/// Get postcode components for field-level borrowing (avoids conflicting borrows with feature_data).
|
/// Get postcode components for field-level borrowing (avoids conflicting borrows with feature_data).
|
||||||
pub fn postcode_parts(&self) -> (&lasso::RodeoReader, &[lasso::Spur]) {
|
pub fn postcode_parts(&self) -> (&lasso::RodeoReader, &[lasso::Spur]) {
|
||||||
(&self.postcode_interner, &self.postcode_keys)
|
(&self.postcode_interner, &self.postcode_keys)
|
||||||
|
|
@ -1044,6 +1063,14 @@ impl PropertyData {
|
||||||
.unwrap_or(&[])
|
.unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get historical sale transactions for a given row (empty slice if none).
|
||||||
|
pub fn historical_prices(&self, row: usize) -> &[HistoricalPrice] {
|
||||||
|
self.historical_prices
|
||||||
|
.get(&(row as u32))
|
||||||
|
.map(|v| v.as_slice())
|
||||||
|
.unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
/// Get property sub-type for a given row.
|
/// Get property sub-type for a given row.
|
||||||
pub fn property_sub_type(&self, row: usize) -> Option<&str> {
|
pub fn property_sub_type(&self, row: usize) -> Option<&str> {
|
||||||
self.property_sub_type
|
self.property_sub_type
|
||||||
|
|
@ -1505,6 +1532,15 @@ impl PropertyData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LSOA (2021) code per row, brought in via the postcode join. Used as a
|
||||||
|
// lookup key into per-LSOA side tables (e.g. crime time series).
|
||||||
|
match schema.get("lsoa21") {
|
||||||
|
Some(dtype) if matches!(dtype, DataType::String) || dtype.is_categorical() => {}
|
||||||
|
Some(dtype) => bail!("Column 'lsoa21' has unexpected type {:?}", dtype),
|
||||||
|
None => bail!("Required column 'lsoa21' not found in joined property data"),
|
||||||
|
}
|
||||||
|
select_exprs.push(col("lsoa21").cast(DataType::String));
|
||||||
|
|
||||||
// Enum features as String
|
// Enum features as String
|
||||||
for &name in &enum_names {
|
for &name in &enum_names {
|
||||||
select_exprs.push(col(name).cast(DataType::String));
|
select_exprs.push(col(name).cast(DataType::String));
|
||||||
|
|
@ -1519,6 +1555,10 @@ impl PropertyData {
|
||||||
if has_renovation_history {
|
if has_renovation_history {
|
||||||
select_exprs.push(col("renovation_history"));
|
select_exprs.push(col("renovation_history"));
|
||||||
}
|
}
|
||||||
|
let has_historical_prices = schema.get("historical_prices").is_some();
|
||||||
|
if has_historical_prices {
|
||||||
|
select_exprs.push(col("historical_prices"));
|
||||||
|
}
|
||||||
let df = combined_lf
|
let df = combined_lf
|
||||||
.filter(col("lat").is_not_null().and(col("lon").is_not_null()))
|
.filter(col("lat").is_not_null().and(col("lon").is_not_null()))
|
||||||
.select(select_exprs)
|
.select(select_exprs)
|
||||||
|
|
@ -1655,9 +1695,33 @@ impl PropertyData {
|
||||||
Ok(vec![None; row_count])
|
Ok(vec![None; row_count])
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let extract_required_trimmed_string_col =
|
||||||
|
|df: &DataFrame, name: &str| -> anyhow::Result<Vec<String>> {
|
||||||
|
let column = df
|
||||||
|
.column(name)
|
||||||
|
.with_context(|| format!("Required column '{name}' not found in parquet"))?;
|
||||||
|
let string_column = column
|
||||||
|
.str()
|
||||||
|
.with_context(|| format!("Column '{name}' is not a string column"))?;
|
||||||
|
string_column
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row, value)| {
|
||||||
|
let value = value.with_context(|| {
|
||||||
|
format!("Required column '{name}' has null at row {row}")
|
||||||
|
})?;
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
bail!("Required column '{name}' has blank value at row {row}");
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?;
|
let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?;
|
||||||
let price_qualifier_raw = extract_optional_string_col(&df, "Price qualifier")?;
|
let price_qualifier_raw = extract_optional_string_col(&df, "Price qualifier")?;
|
||||||
|
let lsoa_raw = extract_required_trimmed_string_col(&df, "lsoa21")?;
|
||||||
|
|
||||||
tracing::info!("Building enum features");
|
tracing::info!("Building enum features");
|
||||||
// enum_col_major: Vec<(values_list, encoded_as_f32)>
|
// enum_col_major: Vec<(values_list, encoded_as_f32)>
|
||||||
|
|
@ -1801,6 +1865,70 @@ impl PropertyData {
|
||||||
FxHashMap::default()
|
FxHashMap::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract historical_prices: List<Struct{year: i32, month: u8, price: i64}>
|
||||||
|
let mut historical_prices_raw: FxHashMap<u32, Vec<HistoricalPrice>> =
|
||||||
|
if has_historical_prices {
|
||||||
|
tracing::info!("Extracting historical prices");
|
||||||
|
let prices_col = df
|
||||||
|
.column("historical_prices")
|
||||||
|
.context("Missing historical_prices column")?;
|
||||||
|
let list_ca = prices_col
|
||||||
|
.list()
|
||||||
|
.context("historical_prices is not a list column")?;
|
||||||
|
|
||||||
|
let mut history: FxHashMap<u32, Vec<HistoricalPrice>> = FxHashMap::default();
|
||||||
|
for old_row in 0..row_count {
|
||||||
|
if let Some(inner) = list_ca.get_as_series(old_row) {
|
||||||
|
if inner.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let structs = inner
|
||||||
|
.struct_()
|
||||||
|
.context("historical_prices inner is not a struct")?;
|
||||||
|
let years = structs
|
||||||
|
.field_by_name("year")
|
||||||
|
.context("Missing 'year' field in historical_prices struct")?;
|
||||||
|
let months = structs
|
||||||
|
.field_by_name("month")
|
||||||
|
.context("Missing 'month' field in historical_prices struct")?;
|
||||||
|
let prices = structs
|
||||||
|
.field_by_name("price")
|
||||||
|
.context("Missing 'price' field in historical_prices struct")?;
|
||||||
|
|
||||||
|
let mut row_prices = Vec::new();
|
||||||
|
for idx in 0..inner.len() {
|
||||||
|
let year = years.get(idx).context("Failed to get year value")?;
|
||||||
|
let month = months.get(idx).context("Failed to get month value")?;
|
||||||
|
let price = prices.get(idx).context("Failed to get price value")?;
|
||||||
|
let AnyValue::Int32(year_i32) = year else {
|
||||||
|
bail!("historical_prices.year is not Int32 at row {old_row}, got {year:?}");
|
||||||
|
};
|
||||||
|
let AnyValue::UInt8(month_u8) = month else {
|
||||||
|
bail!("historical_prices.month is not UInt8 at row {old_row}, got {month:?}");
|
||||||
|
};
|
||||||
|
let AnyValue::Int64(price_i64) = price else {
|
||||||
|
bail!("historical_prices.price is not Int64 at row {old_row}, got {price:?}");
|
||||||
|
};
|
||||||
|
row_prices.push(HistoricalPrice {
|
||||||
|
year: year_i32,
|
||||||
|
month: month_u8,
|
||||||
|
price: price_i64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !row_prices.is_empty() {
|
||||||
|
history.insert(old_row as u32, row_prices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
properties_with_prices = history.len(),
|
||||||
|
"Historical prices extracted"
|
||||||
|
);
|
||||||
|
history
|
||||||
|
} else {
|
||||||
|
FxHashMap::default()
|
||||||
|
};
|
||||||
|
|
||||||
// Free the projected joined frame before building the row-major matrix.
|
// Free the projected joined frame before building the row-major matrix.
|
||||||
drop(df);
|
drop(df);
|
||||||
|
|
||||||
|
|
@ -1904,6 +2032,14 @@ impl PropertyData {
|
||||||
}
|
}
|
||||||
let postcode_interner = postcode_rodeo.into_reader();
|
let postcode_interner = postcode_rodeo.into_reader();
|
||||||
|
|
||||||
|
// Intern LSOA codes (permuted).
|
||||||
|
let mut lsoa_rodeo = lasso::Rodeo::default();
|
||||||
|
let mut lsoa_keys: Vec<lasso::Spur> = Vec::with_capacity(row_count);
|
||||||
|
for &perm_index in perm.iter() {
|
||||||
|
lsoa_keys.push(lsoa_rodeo.get_or_intern(&lsoa_raw[perm_index as usize]));
|
||||||
|
}
|
||||||
|
let lsoa_interner = lsoa_rodeo.into_reader();
|
||||||
|
|
||||||
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
|
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
|
||||||
vec![NO_POI_METRIC_ROW; row_count]
|
vec![NO_POI_METRIC_ROW; row_count]
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1939,6 +2075,20 @@ impl PropertyData {
|
||||||
map
|
map
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-key historical_prices by permuted row index
|
||||||
|
let historical_prices: FxHashMap<u32, Vec<HistoricalPrice>> = {
|
||||||
|
let mut map = FxHashMap::with_capacity_and_hasher(
|
||||||
|
historical_prices_raw.len(),
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
for (new_row, &old_row) in perm.iter().enumerate() {
|
||||||
|
if let Some(prices) = historical_prices_raw.remove(&old_row) {
|
||||||
|
map.insert(new_row as u32, prices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
};
|
||||||
|
|
||||||
// Permute optional string columns into sparse HashMaps
|
// Permute optional string columns into sparse HashMaps
|
||||||
let property_sub_type: FxHashMap<u32, String> = {
|
let property_sub_type: FxHashMap<u32, String> = {
|
||||||
let mut map = FxHashMap::default();
|
let mut map = FxHashMap::default();
|
||||||
|
|
@ -2061,6 +2211,8 @@ impl PropertyData {
|
||||||
address_lengths,
|
address_lengths,
|
||||||
postcode_interner,
|
postcode_interner,
|
||||||
postcode_keys,
|
postcode_keys,
|
||||||
|
lsoa_interner,
|
||||||
|
lsoa_keys,
|
||||||
postcode_row_index,
|
postcode_row_index,
|
||||||
address_token_index,
|
address_token_index,
|
||||||
address_prefix_index,
|
address_prefix_index,
|
||||||
|
|
@ -2072,6 +2224,7 @@ impl PropertyData {
|
||||||
enum_counts,
|
enum_counts,
|
||||||
approx_build_date_bits,
|
approx_build_date_bits,
|
||||||
renovation_history,
|
renovation_history,
|
||||||
|
historical_prices,
|
||||||
property_sub_type,
|
property_sub_type,
|
||||||
price_qualifier,
|
price_qualifier,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,20 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
raw: false,
|
raw: false,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
}),
|
}),
|
||||||
|
Feature::Enum(EnumFeatureConfig {
|
||||||
|
name: "Within conservation area",
|
||||||
|
order: Some(&["Yes", "No"]),
|
||||||
|
description: "Whether the postcode point falls inside a designated conservation area",
|
||||||
|
detail: "Historic England conservation area boundaries, matched to the postcode representative point. The national dataset is indicative rather than definitive, so boundary-sensitive decisions should be checked with the local planning authority.",
|
||||||
|
source: "conservation-areas",
|
||||||
|
}),
|
||||||
|
Feature::Enum(EnumFeatureConfig {
|
||||||
|
name: "Listed building",
|
||||||
|
order: Some(&["Yes", "No"]),
|
||||||
|
description: "Whether this property appears to match a Historic England listed building entry",
|
||||||
|
detail: "Historic England National Heritage List for England listed-building points, matched conservatively to property addresses using the listed-entry name and nearby postcode candidates. Treat this as a screening signal, not a legal determination: verify any specific property on the NHLE and with the local planning authority.",
|
||||||
|
source: "listed-buildings",
|
||||||
|
}),
|
||||||
Feature::Numeric(FeatureConfig {
|
Feature::Numeric(FeatureConfig {
|
||||||
name: "Noise (dB)",
|
name: "Noise (dB)",
|
||||||
bounds: Bounds::Fixed {
|
bounds: Bounds::Fixed {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ mod routes;
|
||||||
mod state;
|
mod state;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|
@ -167,6 +167,18 @@ struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
tiles: PathBuf,
|
tiles: PathBuf,
|
||||||
|
|
||||||
|
/// Optional PMTiles raster overlay for high-resolution strategic noise.
|
||||||
|
#[arg(long, env = "NOISE_OVERLAY_TILES")]
|
||||||
|
noise_overlay_tiles: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Optional PMTiles vector overlay for crime heatmap points.
|
||||||
|
#[arg(long, env = "CRIME_HOTSPOT_TILES")]
|
||||||
|
crime_hotspot_tiles: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Optional PMTiles vector overlay for Trees Outside Woodland polygons.
|
||||||
|
#[arg(long, env = "TREE_OVERLAY_TILES")]
|
||||||
|
tree_overlay_tiles: Option<PathBuf>,
|
||||||
|
|
||||||
/// Path to the frontend dist directory (optional; disables static serving and OG injection when omitted)
|
/// Path to the frontend dist directory (optional; disables static serving and OG injection when omitted)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
dist: Option<PathBuf>,
|
dist: Option<PathBuf>,
|
||||||
|
|
@ -207,6 +219,10 @@ struct Cli {
|
||||||
#[arg(long, env = "ACTUAL_LISTINGS_PATH")]
|
#[arg(long, env = "ACTUAL_LISTINGS_PATH")]
|
||||||
actual_listings_path: Option<PathBuf>,
|
actual_listings_path: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Optional path to the per-LSOA per-year crime parquet (display-only side table for the right pane).
|
||||||
|
#[arg(long, env = "CRIME_BY_YEAR_PATH")]
|
||||||
|
crime_by_year_path: Option<PathBuf>,
|
||||||
|
|
||||||
/// Google Maps API key for Street View metadata lookups
|
/// Google Maps API key for Street View metadata lookups
|
||||||
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
|
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
|
||||||
google_maps_api_key: String,
|
google_maps_api_key: String,
|
||||||
|
|
@ -280,6 +296,36 @@ async fn capture_server_error_responses(
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn init_optional_tile_reader(
|
||||||
|
label: &'static str,
|
||||||
|
path: Option<&PathBuf>,
|
||||||
|
) -> anyhow::Result<Option<Arc<routes::TileReader>>> {
|
||||||
|
let Some(path) = path else {
|
||||||
|
info!("{label} overlay tiles not configured");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
bail!("{label} overlay PMTiles not found: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Loading {label} overlay PMTiles from {}", path.display());
|
||||||
|
Ok(Some(Arc::new(routes::init_tile_reader(path).await?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured_or_default_overlay_path(
|
||||||
|
configured: &Option<PathBuf>,
|
||||||
|
tiles_path: &Path,
|
||||||
|
file_name: &str,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
if let Some(path) = configured {
|
||||||
|
return Some(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_path = tiles_path.parent()?.join(file_name);
|
||||||
|
default_path.exists().then_some(default_path)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
@ -424,6 +470,29 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let tile_reader = Arc::new(routes::init_tile_reader(tiles_path).await?);
|
let tile_reader = Arc::new(routes::init_tile_reader(tiles_path).await?);
|
||||||
info!("PMTiles loaded successfully");
|
info!("PMTiles loaded successfully");
|
||||||
|
|
||||||
|
let noise_overlay_tiles = configured_or_default_overlay_path(
|
||||||
|
&cli.noise_overlay_tiles,
|
||||||
|
tiles_path,
|
||||||
|
"noise_lden_10m.pmtiles",
|
||||||
|
);
|
||||||
|
let crime_hotspot_tiles = configured_or_default_overlay_path(
|
||||||
|
&cli.crime_hotspot_tiles,
|
||||||
|
tiles_path,
|
||||||
|
"crime_hotspots.pmtiles",
|
||||||
|
);
|
||||||
|
let tree_overlay_tiles = configured_or_default_overlay_path(
|
||||||
|
&cli.tree_overlay_tiles,
|
||||||
|
tiles_path,
|
||||||
|
"trees_outside_woodlands.pmtiles",
|
||||||
|
);
|
||||||
|
|
||||||
|
let noise_overlay_reader =
|
||||||
|
init_optional_tile_reader("Noise", noise_overlay_tiles.as_ref()).await?;
|
||||||
|
let crime_hotspot_reader =
|
||||||
|
init_optional_tile_reader("Crime hotspots", crime_hotspot_tiles.as_ref()).await?;
|
||||||
|
let tree_overlay_reader =
|
||||||
|
init_optional_tile_reader("Trees outside woodland", tree_overlay_tiles.as_ref()).await?;
|
||||||
|
|
||||||
let feature_name_to_index: rustc_hash::FxHashMap<String, usize> = property_data
|
let feature_name_to_index: rustc_hash::FxHashMap<String, usize> = property_data
|
||||||
.feature_names
|
.feature_names
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -550,6 +619,18 @@ async fn main() -> anyhow::Result<()> {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let crime_by_year = if let Some(path) = cli.crime_by_year_path.as_ref() {
|
||||||
|
if !path.exists() {
|
||||||
|
bail!("Crime-by-year parquet not found: {}", path.display());
|
||||||
|
}
|
||||||
|
let data = data::CrimeByYearData::load(path)?;
|
||||||
|
trim_allocator("crime-by-year load");
|
||||||
|
Arc::new(data)
|
||||||
|
} else {
|
||||||
|
info!("CRIME_BY_YEAR_PATH not set; crime-over-time chart disabled");
|
||||||
|
Arc::new(data::CrimeByYearData::empty())
|
||||||
|
};
|
||||||
|
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
data: property_data,
|
data: property_data,
|
||||||
grid,
|
grid,
|
||||||
|
|
@ -576,6 +657,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
gemini_model: cli.gemini_model,
|
gemini_model: cli.gemini_model,
|
||||||
travel_time_store,
|
travel_time_store,
|
||||||
actual_listings,
|
actual_listings,
|
||||||
|
crime_by_year,
|
||||||
token_cache,
|
token_cache,
|
||||||
superuser_token_cache,
|
superuser_token_cache,
|
||||||
share_cache,
|
share_cache,
|
||||||
|
|
@ -610,6 +692,9 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let reader_tile = tile_reader.clone();
|
let reader_tile = tile_reader.clone();
|
||||||
let reader_style = tile_reader.clone();
|
let reader_style = tile_reader.clone();
|
||||||
|
let reader_noise_overlay = noise_overlay_reader.clone();
|
||||||
|
let reader_crime_hotspot = crime_hotspot_reader.clone();
|
||||||
|
let reader_tree_overlay = tree_overlay_reader.clone();
|
||||||
let public_url_tiles = initial_state.public_url.clone();
|
let public_url_tiles = initial_state.public_url.clone();
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
|
|
@ -773,6 +858,42 @@ async fn main() -> anyhow::Result<()> {
|
||||||
})
|
})
|
||||||
.layer(ConcurrencyLimitLayer::new(20)),
|
.layer(ConcurrencyLimitLayer::new(20)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/overlays/noise/{z}/{x}/{y}",
|
||||||
|
get(move |path| {
|
||||||
|
routes::get_overlay_tile(
|
||||||
|
reader_noise_overlay.clone(),
|
||||||
|
routes::OverlayTileFormat::RasterPng,
|
||||||
|
"noise",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.layer(ConcurrencyLimitLayer::new(30)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/overlays/crime-hotspots/{z}/{x}/{y}",
|
||||||
|
get(move |path| {
|
||||||
|
routes::get_overlay_tile(
|
||||||
|
reader_crime_hotspot.clone(),
|
||||||
|
routes::OverlayTileFormat::VectorMvtGzip,
|
||||||
|
"crime-hotspots",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.layer(ConcurrencyLimitLayer::new(30)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/overlays/trees-outside-woodlands/{z}/{x}/{y}",
|
||||||
|
get(move |path| {
|
||||||
|
routes::get_overlay_tile(
|
||||||
|
reader_tree_overlay.clone(),
|
||||||
|
routes::OverlayTileFormat::VectorMvtGzip,
|
||||||
|
"trees-outside-woodlands",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.layer(ConcurrencyLimitLayer::new(30)),
|
||||||
|
)
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.route(
|
.route(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ mod invites;
|
||||||
mod journey;
|
mod journey;
|
||||||
mod me;
|
mod me;
|
||||||
mod newsletter;
|
mod newsletter;
|
||||||
|
mod overlays;
|
||||||
mod pb_proxy;
|
mod pb_proxy;
|
||||||
mod places;
|
mod places;
|
||||||
mod pois;
|
mod pois;
|
||||||
|
|
@ -42,6 +43,7 @@ pub use invites::{get_invite, get_invites, post_invites, post_redeem_invite};
|
||||||
pub use journey::get_journey;
|
pub use journey::get_journey;
|
||||||
pub use me::get_me;
|
pub use me::get_me;
|
||||||
pub use newsletter::patch_newsletter;
|
pub use newsletter::patch_newsletter;
|
||||||
|
pub use overlays::{get_overlay_tile, OverlayTileFormat};
|
||||||
pub use pb_proxy::proxy_to_pocketbase;
|
pub use pb_proxy::proxy_to_pocketbase;
|
||||||
pub use places::get_places;
|
pub use places::get_places;
|
||||||
pub use pois::{get_poi_categories, get_pois};
|
pub use pois::{get_poi_categories, get_pois};
|
||||||
|
|
@ -56,6 +58,6 @@ pub use shorten::{get_share_links, get_short_url, post_shorten};
|
||||||
pub use streetview::get_streetview;
|
pub use streetview::get_streetview;
|
||||||
pub use stripe_webhook::post_stripe_webhook;
|
pub use stripe_webhook::post_stripe_webhook;
|
||||||
pub use telemetry::post_telemetry;
|
pub use telemetry::post_telemetry;
|
||||||
pub use tiles::{get_style, get_tile, init_tile_reader};
|
pub use tiles::{get_style, get_tile, init_tile_reader, TileReader};
|
||||||
pub use travel_destinations::get_travel_destinations;
|
pub use travel_destinations::get_travel_destinations;
|
||||||
pub use travel_modes::get_travel_modes;
|
pub use travel_modes::get_travel_modes;
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,9 @@ pub fn build_system_prompt(
|
||||||
- \"cycle\" / \"bike\" / \"cycling\" = bicycle mode\n\
|
- \"cycle\" / \"bike\" / \"cycling\" = bicycle mode\n\
|
||||||
- \"walk\" / \"walking\" / \"on foot\" = walking mode\n\
|
- \"walk\" / \"walking\" / \"on foot\" = walking mode\n\
|
||||||
- \"train\" / \"tube\" / \"bus\" / \"public transport\" / \"commute\" = transit mode\n\
|
- \"train\" / \"tube\" / \"bus\" / \"public transport\" / \"commute\" = transit mode\n\
|
||||||
|
- \"without buses\" / \"no bus\" / \"rail only\" = transit-no-bus mode\n\
|
||||||
|
- \"no change\" / \"no transfer\" / \"direct\" / \"single bus/train\" = transit-no-change mode\n\
|
||||||
|
- \"no change and no bus\" / \"direct rail/tube\" = transit-no-change-no-bus mode\n\
|
||||||
- If a mode appears in the available mode list but is not named above, you may still \
|
- If a mode appears in the available mode list but is not named above, you may still \
|
||||||
use the exact mode string from the list.\n\
|
use the exact mode string from the list.\n\
|
||||||
\n\
|
\n\
|
||||||
|
|
@ -417,7 +420,7 @@ pub fn build_system_prompt(
|
||||||
mention it in \"notes\" (e.g. \"No travel data for: Gatwick Airport\") and do NOT \
|
mention it in \"notes\" (e.g. \"No travel data for: Gatwick Airport\") and do NOT \
|
||||||
include a travel_time_filter for it.\n\
|
include a travel_time_filter for it.\n\
|
||||||
\n\
|
\n\
|
||||||
Travel time values are in MINUTES (0-120 range).\n\
|
Travel time values are in MINUTES (0-90 range; data is capped at 90 min).\n\
|
||||||
- \"within 30 minutes\" = set \"max\": 30\n\
|
- \"within 30 minutes\" = set \"max\": 30\n\
|
||||||
- \"at least 10 minutes\" = set \"min\": 10\n\
|
- \"at least 10 minutes\" = set \"min\": 10\n\
|
||||||
- \"30-45 minute commute\" = set \"min\": 30 and \"max\": 45 on the same travel_time_filter\n\
|
- \"30-45 minute commute\" = set \"min\": 30 and \"max\": 45 on the same travel_time_filter\n\
|
||||||
|
|
@ -1256,11 +1259,15 @@ pub async fn post_ai_filters(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum travel-time minutes the data can contain. Matches the Java pipeline's
|
||||||
|
/// MAX_TRIP_DURATION_MINUTES and the frontend's MAX_TRAVEL_MINUTES.
|
||||||
|
const TRAVEL_TIME_MAX_MINUTES: f64 = 90.0;
|
||||||
|
|
||||||
fn travel_time_minute_field(item: &Value, key: &str) -> Option<f32> {
|
fn travel_time_minute_field(item: &Value, key: &str) -> Option<f32> {
|
||||||
item.get(key)
|
item.get(key)
|
||||||
.and_then(|val| val.as_f64())
|
.and_then(|val| val.as_f64())
|
||||||
.filter(|val| val.is_finite())
|
.filter(|val| val.is_finite())
|
||||||
.map(|val| val.clamp(0.0, 120.0) as f32)
|
.map(|val| val.clamp(0.0, TRAVEL_TIME_MAX_MINUTES) as f32)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_travel_time_bounds(item: &Value) -> (Option<f32>, Option<f32>) {
|
fn parse_travel_time_bounds(item: &Value) -> (Option<f32>, Option<f32>) {
|
||||||
|
|
@ -1527,7 +1534,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn travel_time_bounds_clamp_and_order_range() {
|
fn travel_time_bounds_clamp_and_order_range() {
|
||||||
|
// Data ceiling is 90 (matches Java MAX_TRIP_DURATION_MINUTES).
|
||||||
|
// Inputs outside [0, 90] clamp; min/max ordering is preserved as-given here.
|
||||||
let item = json!({ "min": 150, "max": -10 });
|
let item = json!({ "min": 150, "max": -10 });
|
||||||
assert_eq!(parse_travel_time_bounds(&item), (Some(0.0), Some(120.0)));
|
assert_eq!(parse_travel_time_bounds(&item), (Some(0.0), Some(90.0)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ fn build_frontend_params(
|
||||||
zoom: f64,
|
zoom: f64,
|
||||||
filters_str: Option<&str>,
|
filters_str: Option<&str>,
|
||||||
travel_params: &[String],
|
travel_params: &[String],
|
||||||
|
overlay_params: &[String],
|
||||||
share: Option<&str>,
|
share: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
|
|
@ -159,18 +160,23 @@ fn build_frontend_params(
|
||||||
parts.push(format!("tt={}", urlencoding::encode(entry.trim())));
|
parts.push(format!("tt={}", urlencoding::encode(entry.trim())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for entry in overlay_params {
|
||||||
|
if !entry.is_empty() {
|
||||||
|
parts.push(format!("overlay={}", urlencoding::encode(entry.trim())));
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(share) = share.filter(|value| !value.is_empty()) {
|
if let Some(share) = share.filter(|value| !value.is_empty()) {
|
||||||
parts.push(format!("share={}", urlencoding::encode(share)));
|
parts.push(format!("share={}", urlencoding::encode(share)));
|
||||||
}
|
}
|
||||||
parts.join("&")
|
parts.join("&")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_travel_state_params(query: Option<&str>) -> Vec<String> {
|
fn collect_repeated_state_params(query: Option<&str>, target_key: &str) -> Vec<String> {
|
||||||
query
|
query
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|qs| url::form_urlencoded::parse(qs.as_bytes()))
|
.flat_map(|qs| url::form_urlencoded::parse(qs.as_bytes()))
|
||||||
.filter_map(|(key, value)| {
|
.filter_map(|(key, value)| {
|
||||||
if key == "tt" && !value.is_empty() {
|
if key == target_key && !value.is_empty() {
|
||||||
Some(value.into_owned())
|
Some(value.into_owned())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -179,6 +185,14 @@ fn collect_travel_state_params(query: Option<&str>) -> Vec<String> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_travel_state_params(query: Option<&str>) -> Vec<String> {
|
||||||
|
collect_repeated_state_params(query, "tt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_overlay_state_params(query: Option<&str>) -> Vec<String> {
|
||||||
|
collect_repeated_state_params(query, "overlay")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_export(
|
pub async fn get_export(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|
@ -221,6 +235,7 @@ pub async fn get_export(
|
||||||
.iter()
|
.iter()
|
||||||
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
|
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
|
||||||
let travel_state_params = collect_travel_state_params(uri.query());
|
let travel_state_params = collect_travel_state_params(uri.query());
|
||||||
|
let overlay_state_params = collect_overlay_state_params(uri.query());
|
||||||
let fields_str = params.fields;
|
let fields_str = params.fields;
|
||||||
let share_code = params.share;
|
let share_code = params.share;
|
||||||
|
|
||||||
|
|
@ -241,6 +256,7 @@ pub async fn get_export(
|
||||||
zoom,
|
zoom,
|
||||||
filters_str.as_deref(),
|
filters_str.as_deref(),
|
||||||
&travel_state_params,
|
&travel_state_params,
|
||||||
|
&overlay_state_params,
|
||||||
share_code.as_deref(),
|
share_code.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -776,6 +792,16 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_overlay_state_params_preserves_repeated_overlay_params() {
|
||||||
|
let query = "bounds=1,2,3,4&overlay=noise&overlay=crime-hotspots";
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
collect_overlay_state_params(Some(query)),
|
||||||
|
vec!["noise", "crime-hotspots"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn export_query_deserializes_when_tt_is_a_single_string() {
|
fn export_query_deserializes_when_tt_is_a_single_string() {
|
||||||
let uri: Uri = "/api/export?bounds=1,2,3,4&tt=transit%3Abank%3ABank%2520station%3A0%3A52"
|
let uri: Uri = "/api/export?bounds=1,2,3,4&tt=transit%3Abank%3ABank%2520station%3A0%3A52"
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,20 @@ pub struct PricePoint {
|
||||||
pub price: f32,
|
pub price: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CrimeYearPoint {
|
||||||
|
pub year: i32,
|
||||||
|
pub count: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CrimeYearStats {
|
||||||
|
/// Underlying crime type (e.g. "Burglary"). Matches existing crime feature
|
||||||
|
/// names with the `" (avg/yr)"` suffix stripped.
|
||||||
|
pub name: String,
|
||||||
|
pub points: Vec<CrimeYearPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct FilterExclusion {
|
pub struct FilterExclusion {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -114,6 +128,8 @@ pub struct HexagonStatsResponse {
|
||||||
pub enum_features: Vec<EnumFeatureStats>,
|
pub enum_features: Vec<EnumFeatureStats>,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub price_history: Vec<PricePoint>,
|
pub price_history: Vec<PricePoint>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub crime_by_year: Vec<CrimeYearStats>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub central_postcode: Option<String>,
|
pub central_postcode: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
|
@ -593,6 +609,14 @@ pub async fn get_hexagon_stats(
|
||||||
let price_history =
|
let price_history =
|
||||||
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
||||||
|
|
||||||
|
let crime_by_year = stats::compute_crime_by_year(
|
||||||
|
&matching_rows,
|
||||||
|
&state.data,
|
||||||
|
&state.crime_by_year,
|
||||||
|
fields_specified,
|
||||||
|
&field_set,
|
||||||
|
);
|
||||||
|
|
||||||
let (mut numeric_features, enum_features_out) = stats::compute_feature_stats(
|
let (mut numeric_features, enum_features_out) = stats::compute_feature_stats(
|
||||||
&matching_rows,
|
&matching_rows,
|
||||||
&state.data,
|
&state.data,
|
||||||
|
|
@ -626,6 +650,7 @@ pub async fn get_hexagon_stats(
|
||||||
numeric_features,
|
numeric_features,
|
||||||
enum_features: enum_features_out,
|
enum_features: enum_features_out,
|
||||||
price_history,
|
price_history,
|
||||||
|
crime_by_year,
|
||||||
central_postcode,
|
central_postcode,
|
||||||
filter_exclusions,
|
filter_exclusions,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
85
server-rs/src/routes/overlays.rs
Normal file
85
server-rs/src/routes/overlays.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::Path;
|
||||||
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use pmtiles::TileCoord;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use super::TileReader;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum OverlayTileFormat {
|
||||||
|
VectorMvtGzip,
|
||||||
|
RasterPng,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OverlayTileFormat {
|
||||||
|
fn content_type(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::VectorMvtGzip => "application/x-protobuf",
|
||||||
|
Self::RasterPng => "image/png",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_gzip_encoded(self) -> bool {
|
||||||
|
matches!(self, Self::VectorMvtGzip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_overlay_tile(
|
||||||
|
reader: Option<Arc<TileReader>>,
|
||||||
|
format: OverlayTileFormat,
|
||||||
|
overlay_name: &'static str,
|
||||||
|
Path((zoom, col, row)): Path<(u8, u32, u32)>,
|
||||||
|
) -> Response {
|
||||||
|
let Some(reader) = reader else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let tile_coord = match TileCoord::new(zoom, col, row) {
|
||||||
|
Ok(tile_coord) => tile_coord,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
overlay = overlay_name,
|
||||||
|
zoom,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
error = %err,
|
||||||
|
"Invalid overlay tile coordinate"
|
||||||
|
);
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match reader.get_tile(tile_coord).await {
|
||||||
|
Ok(Some(tile_bytes)) => {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static(format.content_type()),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=86400"),
|
||||||
|
);
|
||||||
|
if format.is_gzip_encoded() {
|
||||||
|
headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, headers, tile_bytes.to_vec()).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
overlay = overlay_name,
|
||||||
|
zoom,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
error = %err,
|
||||||
|
"Failed to get overlay tile"
|
||||||
|
);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ use tracing::info;
|
||||||
|
|
||||||
use crate::api_error::ApiError;
|
use crate::api_error::ApiError;
|
||||||
use crate::consts::MAX_POIS_PER_REQUEST;
|
use crate::consts::MAX_POIS_PER_REQUEST;
|
||||||
use crate::data::{resolve_poi_category_filter, POICategoryGroup};
|
use crate::data::{resolve_poi_category_filter, POICategoryGroup, SchoolMetadata};
|
||||||
use crate::parsing::require_bounds;
|
use crate::parsing::require_bounds;
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
|
@ -22,6 +22,8 @@ pub struct POI {
|
||||||
lat: f32,
|
lat: f32,
|
||||||
lng: f32,
|
lng: f32,
|
||||||
emoji: String,
|
emoji: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
school: Option<SchoolMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -93,6 +95,7 @@ pub async fn get_pois(
|
||||||
lat: state.poi_data.lat[row],
|
lat: state.poi_data.lat[row],
|
||||||
lng: state.poi_data.lng[row],
|
lng: state.poi_data.lng[row],
|
||||||
emoji: state.poi_data.emoji.get(row).to_string(),
|
emoji: state.poi_data.emoji.get(row).to_string(),
|
||||||
|
school: state.poi_data.school(row).cloned(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,14 @@ pub async fn get_postcode_stats(
|
||||||
let price_history =
|
let price_history =
|
||||||
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
||||||
|
|
||||||
|
let crime_by_year = stats::compute_crime_by_year(
|
||||||
|
&matching_rows,
|
||||||
|
&state.data,
|
||||||
|
&state.crime_by_year,
|
||||||
|
fields_specified,
|
||||||
|
&field_set,
|
||||||
|
);
|
||||||
|
|
||||||
let (mut numeric_features, enum_features_out) = stats::compute_feature_stats(
|
let (mut numeric_features, enum_features_out) = stats::compute_feature_stats(
|
||||||
&matching_rows,
|
&matching_rows,
|
||||||
&state.data,
|
&state.data,
|
||||||
|
|
@ -181,6 +189,7 @@ pub async fn get_postcode_stats(
|
||||||
numeric_features,
|
numeric_features,
|
||||||
enum_features: enum_features_out,
|
enum_features: enum_features_out,
|
||||||
price_history,
|
price_history,
|
||||||
|
crime_by_year,
|
||||||
central_postcode: None,
|
central_postcode: None,
|
||||||
filter_exclusions,
|
filter_exclusions,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::auth::OptionalUser;
|
use crate::auth::OptionalUser;
|
||||||
use crate::consts::PROPERTIES_LIMIT;
|
use crate::consts::PROPERTIES_LIMIT;
|
||||||
use crate::data::RenovationEvent;
|
use crate::data::{HistoricalPrice, RenovationEvent};
|
||||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||||
use crate::parsing::{
|
use crate::parsing::{
|
||||||
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters_with_poi, row_passes_filters,
|
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters_with_poi, row_passes_filters,
|
||||||
|
|
@ -47,6 +47,8 @@ pub struct Property {
|
||||||
pub property_sub_type: Option<String>,
|
pub property_sub_type: Option<String>,
|
||||||
pub price_qualifier: Option<String>,
|
pub price_qualifier: Option<String>,
|
||||||
pub former_council_house: Option<String>,
|
pub former_council_house: Option<String>,
|
||||||
|
pub within_conservation_area: Option<String>,
|
||||||
|
pub listed_building: Option<String>,
|
||||||
|
|
||||||
// Numeric fields
|
// Numeric fields
|
||||||
pub lat: f32,
|
pub lat: f32,
|
||||||
|
|
@ -57,6 +59,9 @@ pub struct Property {
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub renovation_history: Vec<RenovationEvent>,
|
pub renovation_history: Vec<RenovationEvent>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub historical_prices: Vec<HistoricalPrice>,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub features: FxHashMap<String, f32>,
|
pub features: FxHashMap<String, f32>,
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +172,7 @@ pub fn build_property(
|
||||||
lat: state.data.lat[row],
|
lat: state.data.lat[row],
|
||||||
lon: state.data.lon[row],
|
lon: state.data.lon[row],
|
||||||
renovation_history: state.data.renovation_history(row).to_vec(),
|
renovation_history: state.data.renovation_history(row).to_vec(),
|
||||||
|
historical_prices: state.data.historical_prices(row).to_vec(),
|
||||||
property_sub_type: state.data.property_sub_type(row).map(String::from),
|
property_sub_type: state.data.property_sub_type(row).map(String::from),
|
||||||
price_qualifier: state.data.price_qualifier(row).map(String::from),
|
price_qualifier: state.data.price_qualifier(row).map(String::from),
|
||||||
former_council_house: lookup_enum_value(
|
former_council_house: lookup_enum_value(
|
||||||
|
|
@ -176,6 +182,20 @@ pub fn build_property(
|
||||||
row,
|
row,
|
||||||
"Former council house",
|
"Former council house",
|
||||||
),
|
),
|
||||||
|
within_conservation_area: lookup_enum_value(
|
||||||
|
feature_name_to_index,
|
||||||
|
&state.data,
|
||||||
|
enum_values,
|
||||||
|
row,
|
||||||
|
"Within conservation area",
|
||||||
|
),
|
||||||
|
listed_building: lookup_enum_value(
|
||||||
|
feature_name_to_index,
|
||||||
|
&state.data,
|
||||||
|
enum_values,
|
||||||
|
row,
|
||||||
|
"Listed building",
|
||||||
|
),
|
||||||
features,
|
features,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ fn is_allowed_param_key(key: &str) -> bool {
|
||||||
| "amenityCount2km"
|
| "amenityCount2km"
|
||||||
| "amenityCount5km"
|
| "amenityCount5km"
|
||||||
| "poi"
|
| "poi"
|
||||||
|
| "overlay"
|
||||||
| "tab"
|
| "tab"
|
||||||
| "pc"
|
| "pc"
|
||||||
| "tt"
|
| "tt"
|
||||||
|
|
@ -570,6 +571,20 @@ mod tests {
|
||||||
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&share=oldcode");
|
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&share=oldcode");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_overlay_params_for_share_links() {
|
||||||
|
let params = sanitized_query_params(
|
||||||
|
"lat=51.5&lon=-0.1&zoom=12&overlay=noise&overlay=crime-hotspots",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
params,
|
||||||
|
"lat=51.5&lon=-0.1&zoom=12&overlay=noise&overlay=crime-hotspots"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escapes_html_attributes() {
|
fn escapes_html_attributes() {
|
||||||
assert_eq!(escape_attr(r#""'><&"#), ""'><&");
|
assert_eq!(escape_attr(r#""'><&"#), ""'><&");
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@ use rustc_hash::FxHashMap;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
|
use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
|
||||||
|
use crate::data::crime_by_year::CrimeByYearData;
|
||||||
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
|
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
|
||||||
|
|
||||||
use super::hexagon_stats::{EnumFeatureStats, HistogramStats, NumericFeatureStats, PricePoint};
|
use super::hexagon_stats::{
|
||||||
|
CrimeYearPoint, CrimeYearStats, EnumFeatureStats, HistogramStats, NumericFeatureStats,
|
||||||
|
PricePoint,
|
||||||
|
};
|
||||||
|
|
||||||
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
|
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
|
||||||
pub fn extract_price_history(
|
pub fn extract_price_history(
|
||||||
|
|
@ -251,6 +255,91 @@ pub fn compute_feature_stats(
|
||||||
(numeric_features, enum_features_out)
|
(numeric_features, enum_features_out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute property-weighted per-year crime means across the selection.
|
||||||
|
///
|
||||||
|
/// Each matching property contributes its LSOA's per-year counts; this is the
|
||||||
|
/// same property-weighted-LSOA-average shape used elsewhere in the right pane.
|
||||||
|
/// LSOAs with no series for a given crime type contribute 0 for that type
|
||||||
|
/// (matching how the existing `(avg/yr)` columns treat missing crime types).
|
||||||
|
pub fn compute_crime_by_year(
|
||||||
|
matching_rows: &[usize],
|
||||||
|
data: &PropertyData,
|
||||||
|
crime_by_year: &CrimeByYearData,
|
||||||
|
fields_specified: bool,
|
||||||
|
field_set: &HashSet<String>,
|
||||||
|
) -> Vec<CrimeYearStats> {
|
||||||
|
if crime_by_year.crime_types.is_empty() || matching_rows.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each crime type, accumulate per-year sums and the count of rows whose
|
||||||
|
// LSOA exists in the crime side table.
|
||||||
|
let num_types = crime_by_year.crime_types.len();
|
||||||
|
let mut per_type_year_sums: Vec<FxHashMap<i32, f64>> =
|
||||||
|
(0..num_types).map(|_| FxHashMap::default()).collect();
|
||||||
|
let mut per_type_row_counts: Vec<u32> = vec![0; num_types];
|
||||||
|
|
||||||
|
for &row in matching_rows {
|
||||||
|
let lsoa = data.lsoa(row);
|
||||||
|
let Some(series_list) = crime_by_year.series_by_lsoa.get(lsoa) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// For every type the LSOA reports, add its per-year counts.
|
||||||
|
// For types it doesn't report, treat the row as contributing 0 — so we
|
||||||
|
// bump the row count for *every* known type below.
|
||||||
|
for series in series_list {
|
||||||
|
let acc = &mut per_type_year_sums[series.type_idx as usize];
|
||||||
|
for point in &series.points {
|
||||||
|
*acc.entry(point.year).or_insert(0.0) += point.count as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in per_type_row_counts.iter_mut() {
|
||||||
|
*c += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (type_idx, name) in crime_by_year.crime_types.iter().enumerate() {
|
||||||
|
// Crime types in the by-year side table are bare (e.g. "Burglary"), while
|
||||||
|
// the configured feature names carry an " (avg/yr)" suffix. Match either
|
||||||
|
// form so callers can pass the feature names they already know.
|
||||||
|
if fields_specified {
|
||||||
|
let with_suffix = format!("{name} (avg/yr)");
|
||||||
|
if !field_set.contains(name.as_str()) && !field_set.contains(with_suffix.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let row_count = per_type_row_counts[type_idx];
|
||||||
|
if row_count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let years = crime_by_year
|
||||||
|
.years_by_type
|
||||||
|
.get(type_idx)
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or(&[]);
|
||||||
|
if years.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let denom = row_count as f64;
|
||||||
|
let sums = &per_type_year_sums[type_idx];
|
||||||
|
let points: Vec<CrimeYearPoint> = years
|
||||||
|
.iter()
|
||||||
|
.map(|&year| CrimeYearPoint {
|
||||||
|
year,
|
||||||
|
count: (sums.get(&year).copied().unwrap_or(0.0) / denom) as f32,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.push(CrimeYearStats {
|
||||||
|
name: name.clone(),
|
||||||
|
points,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn compute_poi_feature_stats(
|
pub fn compute_poi_feature_stats(
|
||||||
matching_rows: &[usize],
|
matching_rows: &[usize],
|
||||||
poi_metrics: &PostcodePoiMetrics,
|
poi_metrics: &PostcodePoiMetrics,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ use rustc_hash::FxHashMap;
|
||||||
use crate::auth::TokenCache;
|
use crate::auth::TokenCache;
|
||||||
use crate::bugsink::FrontendConfig as BugsinkFrontendConfig;
|
use crate::bugsink::FrontendConfig as BugsinkFrontendConfig;
|
||||||
use crate::data::{
|
use crate::data::{
|
||||||
ActualListingData, OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData,
|
ActualListingData, CrimeByYearData, OutcodeData, POICategoryGroup, POIData, PlaceData,
|
||||||
PropertyData, TravelTimeStore,
|
PostcodeData, PropertyData, TravelTimeStore,
|
||||||
};
|
};
|
||||||
use crate::licensing::ShareBoundsCache;
|
use crate::licensing::ShareBoundsCache;
|
||||||
use crate::pocketbase::SuperuserTokenCache;
|
use crate::pocketbase::SuperuserTokenCache;
|
||||||
|
|
@ -46,6 +46,9 @@ pub struct AppState {
|
||||||
pub travel_time_store: Arc<TravelTimeStore>,
|
pub travel_time_store: Arc<TravelTimeStore>,
|
||||||
/// Optional real-world listings (e.g. Rightmove / Zoopla data) loaded from ACTUAL_LISTINGS_PATH.
|
/// Optional real-world listings (e.g. Rightmove / Zoopla data) loaded from ACTUAL_LISTINGS_PATH.
|
||||||
pub actual_listings: Option<Arc<ActualListingData>>,
|
pub actual_listings: Option<Arc<ActualListingData>>,
|
||||||
|
/// Per-LSOA per-year crime counts used by the right pane to plot trends.
|
||||||
|
/// Empty when the side parquet was not supplied.
|
||||||
|
pub crime_by_year: Arc<CrimeByYearData>,
|
||||||
/// Token validation cache (60s TTL)
|
/// Token validation cache (60s TTL)
|
||||||
pub token_cache: Arc<TokenCache>,
|
pub token_cache: Arc<TokenCache>,
|
||||||
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
|
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
|
||||||
|
|
|
||||||
0
uv-282b6c2ab09acf58.lock
Normal file
0
uv-282b6c2ab09acf58.lock
Normal file
|
|
@ -107,6 +107,17 @@ done
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# -- scratch space ------------------------------------------------------------
|
||||||
|
# Host /tmp is a small tmpfs (often <512MB) and /dev/shm is even smaller, but
|
||||||
|
# Playwright spawns a fresh Chromium user-data dir per launch (GPU shader
|
||||||
|
# cache, blob storage, …) and ffmpeg/uv also fall back to TMPDIR for their
|
||||||
|
# intermediates. Steer everything to a local scratch dir under video/ so the
|
||||||
|
# render survives on hosts with a tight /tmp budget. Override SCRATCH_DIR to
|
||||||
|
# point elsewhere if disk under video/ is also constrained.
|
||||||
|
SCRATCH_DIR="${SCRATCH_DIR:-$PWD/.tmp}"
|
||||||
|
mkdir -p "$SCRATCH_DIR"
|
||||||
|
export TMPDIR="$SCRATCH_DIR"
|
||||||
|
|
||||||
# -- helpers ------------------------------------------------------------------
|
# -- helpers ------------------------------------------------------------------
|
||||||
say() { printf '\n[render] %s\n' "$*"; }
|
say() { printf '\n[render] %s\n' "$*"; }
|
||||||
fail() { printf '\n[render] FAIL: %s\n' "$*" >&2; exit 1; }
|
fail() { printf '\n[render] FAIL: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
@ -152,12 +163,27 @@ if [ ! -d node_modules ]; then
|
||||||
npm install --no-audit --no-fund
|
npm install --no-audit --no-fund
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Chromium binary lives in Playwright's cache; install if missing.
|
# Chromium binaries live in Playwright's cache; install if missing.
|
||||||
|
# Playwright launches via chrome-headless-shell by default, so we must verify
|
||||||
|
# BOTH the chromium and chrome-headless-shell executables exist. Checking the
|
||||||
|
# actual binary (not just the parent dir) is intentional: a previously failed
|
||||||
|
# install (e.g. ENOSPC mid-download) leaves the chromium-* / chromium_headless_shell-*
|
||||||
|
# dir in place but without the binary inside, and a dir-only check would
|
||||||
|
# treat that as "installed".
|
||||||
|
chromium_bin="$(find "$HOME/.cache/ms-playwright" -path '*/chromium-*/chrome-linux/chrome' -print -quit 2>/dev/null || true)"
|
||||||
|
headless_shell_bin="$(find "$HOME/.cache/ms-playwright" -path '*/chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell' -print -quit 2>/dev/null || true)"
|
||||||
if ! npx --no-install playwright --version >/dev/null 2>&1 \
|
if ! npx --no-install playwright --version >/dev/null 2>&1 \
|
||||||
|| [ ! -d "$HOME/.cache/ms-playwright" ] \
|
|| [ ! -x "${chromium_bin:-/nonexistent}" ] \
|
||||||
|| ! find "$HOME/.cache/ms-playwright" -maxdepth 1 -name "chromium-*" -print -quit | grep -q .; then
|
|| [ ! -x "${headless_shell_bin:-/nonexistent}" ]; then
|
||||||
say "Installing Playwright Chromium"
|
say "Installing Playwright Chromium"
|
||||||
npx playwright install chromium
|
# Playwright stages the ~170MB browser zip in $TMPDIR before extracting into
|
||||||
|
# ~/.cache/ms-playwright. /tmp is often a small tmpfs (we've hit ENOSPC at
|
||||||
|
# 92%), so steer the staging dir into the cache dir itself, which lives on
|
||||||
|
# the same (larger) filesystem we're extracting into anyway.
|
||||||
|
pw_tmpdir="$HOME/.cache/ms-playwright/.tmp"
|
||||||
|
mkdir -p "$pw_tmpdir"
|
||||||
|
TMPDIR="$pw_tmpdir" npx playwright install chromium
|
||||||
|
rm -rf "$pw_tmpdir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# System libs Chromium dlopens (libnspr4, libnss3, libasound2…). Detect via
|
# System libs Chromium dlopens (libnspr4, libnss3, libasound2…). Detect via
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue