This commit is contained in:
Andras Schmelczer 2026-02-09 19:26:54 +00:00
parent 5b68c8da04
commit 536fd14378
28 changed files with 1683 additions and 313 deletions

View file

@ -20,11 +20,11 @@ WORKDIR /app
COPY --from=server /app/server-rs/target/release/property-map-server ./
COPY --from=frontend /app/frontend/dist ./dist/
# COPY property-data/wide.parquet ./data/
# COPY property-data/filtered_uk_pois.parquet ./data/
# COPY property-data/uk.pmtiles ./data/
# COPY property-data/postcodes ./data/postcodes/
COPY property-data/wide.parquet ./data/
COPY property-data/filtered_uk_pois.parquet ./data/
COPY property-data/uk.pmtiles ./data/
COPY manual-data/postcode_boundaries ./data/postcode_boundaries/
EXPOSE 8001
ENTRYPOINT ["./property-map-server"]
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/new_postcode_boundaries"]
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries"]

View file

@ -3,15 +3,14 @@
# Usage:
# make -f Makefile.data prepare # Build wide.parquet (+ all deps)
# make -f Makefile.data tiles # Download UK map tiles
# make -f Makefile.data download-pois # Download a single dataset
# make -f Makefile.data help # List all targets
#
# Or include from the main Makefile and use targets directly.
SHELL := /bin/bash
.DELETE_ON_ERROR:
DATA_DIR := /bulk/property-data
DATA_DIR := ./property-data
MANUAL_DATA := ./manual-data
# ── Output files ──────────────────────────────────────────────────────────────
@ -24,11 +23,13 @@ POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
EPC_PP := $(DATA_DIR)/epc_pp.parquet
WIDE := $(DATA_DIR)/wide.parquet
EPC := $(DATA_DIR)/certificates.csv
JT_BANK := $(DATA_DIR)/journey_times_bank.parquet
JT_FITZROVIA := $(DATA_DIR)/journey_times_fitzrovia.parquet
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/certificates.csv
JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet
JT_FITZROVIA := $(MANUAL_DATA)/journey_times_fitzrovia.parquet
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
CRIME_DIR := $(DATA_DIR)/crime
CRIME_DIR := $(MANUAL_DATA)/crime
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
NOISE := $(DATA_DIR)/road_noise.parquet
OFSTED := $(DATA_DIR)/ofsted.parquet
@ -40,28 +41,28 @@ GEOSURE := $(DATA_DIR)/geosure.parquet
INSPIRE_DIR := $(DATA_DIR)/inspire
OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg
UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet
PC_BOUNDARIES := $(DATA_DIR)/new_postcode_boundaries
PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries
# Sentinel files for directory targets (Make doesn't track directories well)
GEOSURE_STAMP := $(GEOSURE_DIR)/.done
INSPIRE_STAMP := $(INSPIRE_DIR)/.done
MANUAL_DATA := $(DATA_DIR)/manual_data
PMTILES_VERSION := 1.22.3
PMTILES_BIN := $(DATA_DIR)/pmtiles
# ── Phony aliases ─────────────────────────────────────────────────────────────
.PHONY: prepare tiles \
.PHONY: prepare wide tiles \
download-arcgis download-price-paid download-deprivation download-ethnicity \
download-naptan download-pois download-ofsted download-broadband \
download-postcodes download-geosure download-noise download-inspire \
download-oa-boundaries download-uprn-lookup \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
transform-school-proximity transform-geosure transform-postcode-boundaries \
generate-postcode-boundaries \
journey-times
prepare: $(WIDE)
prepare: $(DATA_DIR)/.prices_done
wide: $(WIDE)
tiles: $(TILES)
download-arcgis: $(ARCGIS)
download-price-paid: $(PRICE_PAID)
@ -84,19 +85,17 @@ transform-poi-proximity: $(POI_PROXIMITY)
transform-school-proximity: $(SCHOOL_PROX)
transform-geosure: $(GEOSURE)
transform-postcode-boundaries: $(PC_BOUNDARIES)
generate-postcode-boundaries: $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP)
uv run python -m pipeline.transform.postcode_boundaries \
--uprn $(UPRN_LOOKUP) \
--oa-boundaries $(OA_BOUNDARIES) \
--inspire $(INSPIRE_DIR) \
--output $(PC_BOUNDARIES)
# ── Downloads ─────────────────────────────────────────────────────────────────
$(TILES):
@echo "Downloading UK PMTiles (~1.5GB)..."
@echo "This extracts UK tiles from the Protomaps planet file."
@if [ ! -f "$(PMTILES_BIN)" ]; then \
echo "Downloading pmtiles CLI v$(PMTILES_VERSION)..."; \
curl -sL "https://github.com/protomaps/go-pmtiles/releases/download/v$(PMTILES_VERSION)/go-pmtiles_$(PMTILES_VERSION)_Linux_x86_64.tar.gz" \
| tar -xz -C "$(DATA_DIR)" pmtiles; \
chmod +x "$(PMTILES_BIN)"; \
fi
"$(PMTILES_BIN)" extract https://build.protomaps.com/20260201.pmtiles $@ --bbox=-10.5,49.5,2.5,61
uv run -m pipeline.download.tiles --output $@ --pmtiles-version $(PMTILES_VERSION)
# EPC requires manual registration — fail with instructions
$(EPC):
@ -105,6 +104,7 @@ $(EPC):
@echo "The EPC certificates file is required: $@"
@echo ""
@echo "To obtain it, register at https://epc.opendatacommunities.org/login"
@echo "and place certificates.csv in manual-data/"
@echo ""
@exit 1
@ -155,32 +155,22 @@ $(UPRN_LOOKUP):
# ── Journey times (requires TFL_API_KEY) ──────────────────────────────────────
$(JT_BANK):
@if [ -f "$(MANUAL_DATA)/journey_times_bank.parquet" ]; then \
echo "Copying journey_times_bank.parquet from manual_data/"; \
cp "$(MANUAL_DATA)/journey_times_bank.parquet" $@; \
else \
echo ""; \
echo "=== TFL journey times (bank) not found ==="; \
echo "Either place the file in $(MANUAL_DATA)/journey_times_bank.parquet"; \
echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"; \
echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank"; \
echo ""; \
exit 1; \
fi
@echo ""
@echo "=== TFL journey times (bank) not found ==="
@echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/"
@echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
@echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank"
@echo ""
@exit 1
$(JT_FITZROVIA):
@if [ -f "$(MANUAL_DATA)/journey_times_fitzrovia.parquet" ]; then \
echo "Copying journey_times_fitzrovia.parquet from manual_data/"; \
cp "$(MANUAL_DATA)/journey_times_fitzrovia.parquet" $@; \
else \
echo ""; \
echo "=== TFL journey times (fitzrovia) not found ==="; \
echo "Either place the file in $(MANUAL_DATA)/journey_times_fitzrovia.parquet"; \
echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"; \
echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia"; \
echo ""; \
exit 1; \
fi
@echo ""
@echo "=== TFL journey times (fitzrovia) not found ==="
@echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/"
@echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
@echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia"
@echo ""
@exit 1
journey-times: $(ARCGIS)
ifndef DEST
@ -197,6 +187,14 @@ $(EPC_PP): $(PRICE_PAID) $(EPC)
uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
$(CRIME):
@if [ ! -d "$(CRIME_DIR)" ]; then \
echo ""; \
echo "=== Crime dataset not found ==="; \
echo "Place police.uk crime CSVs in $(CRIME_DIR)/"; \
echo "Download from https://data.police.uk/data/"; \
echo ""; \
exit 1; \
fi
uv run python -m pipeline.transform.crime --input $(CRIME_DIR) --output $@
$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED)
@ -208,12 +206,20 @@ $(SCHOOL_PROX): $(OFSTED) $(ARCGIS)
$(GEOSURE): $(GEOSURE_STAMP) $(ARCGIS)
uv run python -m pipeline.transform.transform_geosure --geosure $(GEOSURE_DIR) --arcgis $(ARCGIS) --output $@
$(PC_BOUNDARIES): $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP)
uv run python -m pipeline.transform.postcode_boundaries \
--uprn $(UPRN_LOOKUP) \
--oa-boundaries $(OA_BOUNDARIES) \
--inspire $(INSPIRE_DIR) \
--output $@
# Postcode boundaries require manual generation — fail with instructions
$(PC_BOUNDARIES):
@echo ""
@echo "=== Postcode boundaries not found ==="
@echo "The postcode boundaries directory is required: $@"
@echo ""
@echo "Generate it with:"
@echo " uv run python -m pipeline.transform.postcode_boundaries \\"
@echo " --uprn $(UPRN_LOOKUP) \\"
@echo " --oa-boundaries $(OA_BOUNDARIES) \\"
@echo " --inspire $(INSPIRE_DIR) \\"
@echo " --output $@"
@echo ""
@exit 1
# ── Final merge ───────────────────────────────────────────────────────────────
@ -233,3 +239,12 @@ $(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA)
--broadband $(BROADBAND) \
--geosure $(GEOSURE) \
--output $@
# ── Price estimation (post-merge) ────────────────────────────────────────────
$(PRICE_INDEX): $(WIDE)
uv run python -m pipeline.transform.price_index --input $(WIDE) --output $@
$(PRICES_STAMP): $(WIDE) $(PRICE_INDEX)
uv run python -m pipeline.transform.price_estimate --input $(WIDE) --index $(PRICE_INDEX)
@touch $@

View file

@ -30,42 +30,11 @@ rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip
https://xploria.co.uk/data-sources/
epc oopt out https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure
epc oopt out
We mapped every neighbourhood in England. You're welcome.
Harness our supercharged data to find your <green>perfect postcode</green>
Set the interactive filters to match your preferences and watch the hidden gems get uncovered - instantly. Only you know your preferences. We will give you all the data that exists, aggregated into dozens of personalised sliders for you to compile the list of perfect postcodes.
<example with two sliders, last known price, minor crime/>
Now imagine this, but with 43 different filters across noise levels, environment risk, nearby amenities, demographics, socioeconomics, crime rate, transport link, and properties. (show the filter types with small cards).
We strongly believe that the smart way to buy a property is by first understanding your expectations.
We give you the best-in-class tools to be intentional about the trade-offs you make in one of the most important decisions of your life.
You may buy a box of cereal youve never tried because its 20% off. Your future home is not a box of cereal. Dont let a seemingly good deal that just popped up turn into life-long regret because you got a discount but not on the home you needed. We help you reverse the equation and allow you to be intentional in your search. Dont wait for good deals to come your way, if you can go out and find them yourself.
There are too few properties listed at any time to give you a full and nuanced picture of the entire property landscape but therere too many to go through one by one and evaluate them all. You cant get a complete picture of the property market by looking at current listings. To understand the landscape, you have to look at historical trends. Dont let the market sway you, anchor your expectations based on the choices millions of other buyers whove been in the same situation as you are now.
We all care about different things in our homes and living environments. Some of us are weary of noise and would like to avoid living next to a loud airfield as much as possible. And some of us are avid plane spotters.
@ -87,17 +56,17 @@ We give you all the data and tools to become an Well-informed Buyer through the
- register for email
FAQ:
- Why hexagons?
- Why the price tag?
- contact support
-
make -f Makefile.data prepare
make -f Makefile.data tiles
## outstadning prompts
@ -110,3 +79,11 @@ Add licensing to the app. By default, anonymous users can use the map but only
Stop wrapping everything in cards. Be bold and stop being lazy around text formatting.
uv run python scripts/remove_bg.py house-og.png 200 house.png

View file

@ -5,7 +5,7 @@ services:
command: >
bash -c "
cargo install cargo-watch &&
cargo watch -x 'run -- --data /data/wide.parquet --pois /data/filtered_uk_pois.parquet --tiles /data/uk.pmtiles --postcodes /data/postcodes'
cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries'
"
ports:
- "8001:8001"
@ -15,9 +15,10 @@ services:
- "host.docker.internal:host-gateway"
volumes:
- .:/app
- /bulk/property-data:/data:ro
- cargo-registry:/usr/local/cargo/registry
- cargo-target:/app/server-rs/target
- ./property-data:/app/data:ro
environment:
POCKETBASE_URL: http://pocketbase:8090
SCREENSHOT_URL: http://screenshot:8002

View file

@ -40,6 +40,8 @@
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"favicons": "^7.2.0",
"favicons-webpack-plugin": "^6.0.1",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.0",
@ -1872,6 +1874,16 @@
"node": ">=10.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dev": true,
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -2083,6 +2095,367 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"dev": true,
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -5227,6 +5600,15 @@
"node": ">= 0.4"
}
},
"node_modules/author-regex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/author-regex/-/author-regex-1.0.0.tgz",
"integrity": "sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==",
"dev": true,
"engines": {
"node": ">=0.8"
}
},
"node_modules/autoprefixer": {
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@ -5953,6 +6335,19 @@
"node": ">=6"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5973,6 +6368,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dev": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@ -6584,6 +6989,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@ -7769,6 +8183,41 @@
"reusify": "^1.0.4"
}
},
"node_modules/favicons": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/favicons/-/favicons-7.2.0.tgz",
"integrity": "sha512-k/2rVBRIRzOeom3wI9jBPaSEvoTSQEW4iM0EveBmBBKFxO8mSyyRWtDlfC3VnEfu0avmjrMzy8/ZFPSe6F71Hw==",
"dev": true,
"dependencies": {
"escape-html": "^1.0.3",
"sharp": "^0.33.1",
"xml2js": "^0.6.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/favicons-webpack-plugin": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/favicons-webpack-plugin/-/favicons-webpack-plugin-6.0.1.tgz",
"integrity": "sha512-Gl0Co4zIZq74EKXdpfe8FaoJqbuf0undV4UgpsL34vqICRAYUDwQdp3D+z+uxEOV0i9o+vHDn7Q6jaSxRiDJUA==",
"dev": true,
"dependencies": {
"find-root": "^1.1.0",
"parse-author": "^2.0.0",
"parse5": "^7.1.1"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"html-webpack-plugin": "^5.5.0"
},
"peerDependencies": {
"favicons": "^7.0.1",
"webpack": "^5.0.0"
}
},
"node_modules/faye-websocket": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
@ -7859,6 +8308,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"dev": true
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -10586,6 +11041,18 @@
"node": ">=6"
}
},
"node_modules/parse-author": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parse-author/-/parse-author-2.0.0.tgz",
"integrity": "sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==",
"dev": true,
"dependencies": {
"author-regex": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -10605,6 +11072,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -11928,6 +12419,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/sax": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
"dev": true,
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -12226,6 +12726,45 @@
"node": ">=8"
}
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -12338,6 +12877,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"dev": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"dev": true
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -14171,6 +14725,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"dev": true,
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"dev": true,
"engines": {
"node": ">=4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -45,6 +45,8 @@
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"favicons": "^7.2.0",
"favicons-webpack-plugin": "^6.0.1",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.0",

BIN
frontend/public/cereal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#1de4c3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L20.7 7v10L12 22l-8.7-5V7z"/><path d="M8.5 12.5l2.5 2.5 4.5-5"/></svg>

After

Width:  |  Height:  |  Size: 238 B

BIN
frontend/public/house.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import MapPage, { type ExportState } from './components/map/MapPage';
import DataSourcesPage from './components/data-sources/DataSourcesPage';
import FAQPage from './components/faq/FAQPage';
import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import Header, { type Page } from './components/ui/Header';
@ -32,6 +33,8 @@ function pageToPath(page: Page): string {
return '/faq';
case 'saved-searches':
return '/saved';
case 'pricing':
return '/pricing';
default:
return '/';
}
@ -42,6 +45,7 @@ function pathToPage(pathname: string): Page | null {
if (pathname === '/data-sources') return 'data-sources';
if (pathname === '/faq') return 'faq';
if (pathname === '/saved') return 'saved-searches';
if (pathname === '/pricing') return 'pricing';
if (pathname === '/') return 'home';
return null;
}
@ -235,11 +239,13 @@ export default function App() {
isMobile={isMobile}
/>
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'data-sources' ? (
<DataSourcesPage />
) : activePage === 'faq' ? (
<FAQPage />
) : activePage === 'pricing' ? (
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
) : activePage === 'saved-searches' ? (
<SavedSearchesPage
searches={savedSearches.searches}

View file

@ -0,0 +1,22 @@
interface Props {
isDark: boolean;
}
export default function BottomIllustration({ isDark }: Props) {
const hillColor = isDark ? '#16a34a' : '#22c55e';
return (
<div className="w-full">
<svg viewBox="0 100 1600 250" className="w-full block" preserveAspectRatio="xMidYMax meet">
{/* Green hill */}
<path d="M0,350 C400,150 1200,150 1600,350 Z" fill={hillColor} />
{/* Inner shadow for depth */}
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
{/* House */}
<image href="/house.png" x="735" y="100" width="130" height="120" />
</svg>
</div>
);
}

View file

@ -0,0 +1,118 @@
/**
* Decorative mini SVGs for homepage category cards.
* Purely visual rendered at low opacity in the corner of each card.
*/
export default function CategoryArt({
category,
className = '',
}: {
category: string;
className?: string;
}) {
const props = { className, width: 36, height: 36, viewBox: '0 0 36 36', fill: 'none' };
switch (category) {
case 'Property':
// Ascending bar chart
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="22" width="6" height="10" rx="1" fill="currentColor" opacity="0.5" />
<rect x="13" y="14" width="6" height="18" rx="1" fill="currentColor" opacity="0.65" />
<rect x="22" y="6" width="6" height="26" rx="1" fill="currentColor" opacity="0.8" />
</svg>
);
case 'Transport':
// Converging route lines
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M4 6 Q18 18 32 12" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<path d="M4 18 Q18 18 32 18" stroke="currentColor" strokeWidth="2" opacity="0.7" />
<path d="M4 30 Q18 18 32 24" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<circle cx="32" cy="18" r="3" fill="currentColor" opacity="0.5" />
</svg>
);
case 'Crime':
// Shield outline
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path
d="M18 4 L30 10 V20 C30 26 24 32 18 34 C12 32 6 26 6 20 V10 Z"
stroke="currentColor"
strokeWidth="2"
opacity="0.6"
/>
<path d="M14 18 L17 21 L23 14" stroke="currentColor" strokeWidth="2" opacity="0.5" />
</svg>
);
case 'Education':
// Mortarboard / books
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M18 8 L4 16 L18 24 L32 16 Z" fill="currentColor" opacity="0.5" />
<path d="M10 19 V27 L18 31 L26 27 V19" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<line x1="30" y1="16" x2="30" y2="28" stroke="currentColor" strokeWidth="2" opacity="0.4" />
</svg>
);
case 'Amenities':
// Scattered dots (map pins)
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="10" r="3" fill="currentColor" opacity="0.5" />
<circle cx="22" cy="7" r="2.5" fill="currentColor" opacity="0.4" />
<circle cx="30" cy="16" r="2" fill="currentColor" opacity="0.5" />
<circle cx="14" cy="22" r="3.5" fill="currentColor" opacity="0.6" />
<circle cx="26" cy="28" r="2.5" fill="currentColor" opacity="0.45" />
<circle cx="6" cy="30" r="2" fill="currentColor" opacity="0.35" />
</svg>
);
case 'Demographics':
// Pie/donut segment
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="13" stroke="currentColor" strokeWidth="3" opacity="0.3" />
<path
d="M18 5 A13 13 0 0 1 30 14 L18 18 Z"
fill="currentColor"
opacity="0.6"
/>
<path
d="M18 5 A13 13 0 0 0 8 12 L18 18 Z"
fill="currentColor"
opacity="0.4"
/>
</svg>
);
case 'Environment':
// Terrain wave lines
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M2 20 Q9 12 18 18 Q27 24 34 16" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<path d="M2 26 Q9 18 18 24 Q27 30 34 22" stroke="currentColor" strokeWidth="2" opacity="0.45" />
<path d="M2 14 Q9 6 18 12 Q27 18 34 10" stroke="currentColor" strokeWidth="2" opacity="0.35" />
</svg>
);
case 'Broadband':
// Signal waves (wifi)
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M6 16 Q18 4 30 16" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.4" />
<path d="M10 21 Q18 12 26 21" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.55" />
<path d="M14 26 Q18 20 22 26" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.7" />
<circle cx="18" cy="30" r="2.5" fill="currentColor" opacity="0.7" />
</svg>
);
case 'Deprivation':
// Scale / balance
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<line x1="18" y1="6" x2="18" y2="30" stroke="currentColor" strokeWidth="2" opacity="0.4" />
<line x1="6" y1="14" x2="30" y2="14" stroke="currentColor" strokeWidth="2" opacity="0.5" />
<path d="M6 14 L3 24 H12 Z" fill="currentColor" opacity="0.4" />
<path d="M30 14 L27 22 H33 Z" fill="currentColor" opacity="0.5" />
<rect x="14" y="28" width="8" height="3" rx="1" fill="currentColor" opacity="0.3" />
</svg>
);
default:
return null;
}
}

View file

@ -1,6 +1,6 @@
import { useRef, useEffect } from 'react';
const HEX_COUNT = 60;
const HEX_COUNT = 70;
const TAU = Math.PI * 2;
interface Hex {
@ -17,12 +17,14 @@ function initHexes(w: number, h: number): Hex[] {
const hexes: Hex[] = [];
for (let i = 0; i < HEX_COUNT; i++) {
const y = Math.random() * h;
const side = Math.random() < 0.5 ? 'left' : 'right';
const x = side === 'left' ? Math.random() * w * 0.3 : w * 0.7 + Math.random() * w * 0.3;
hexes.push({
x: Math.random() * w,
x,
y,
baseY: y,
size: 8 + Math.random() * 20,
opacity: 0.06 + Math.random() * 0.12,
opacity: 0.08 + Math.random() * 0.15,
speed: 6 + Math.random() * 14,
phase: Math.random() * TAU,
});
@ -42,18 +44,10 @@ function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: numbe
ctx.closePath();
}
export default function HexCanvas({
scrollProgress,
isDark = false,
}: {
scrollProgress: number;
isDark?: boolean;
}) {
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hexesRef = useRef<Hex[]>([]);
const animRef = useRef(0);
const scrollRef = useRef(scrollProgress);
scrollRef.current = scrollProgress;
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
@ -88,27 +82,29 @@ export default function HexCanvas({
function frame(now: number) {
const dt = (now - prev) / 1000;
prev = now;
const scroll = scrollRef.current;
ctx!.clearRect(0, 0, w, h);
const globalAlpha = Math.max(0, 1 - scroll * 2);
for (const hex of hexesRef.current) {
hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2);
const bob = Math.sin(now / 1000 + hex.phase) * 8;
const parallax = scroll * h * 0.3 * (hex.speed / 20);
hex.y = hex.baseY + bob - parallax;
hex.x += hex.speed * dt * 0.3;
if (hex.x > w * 0.3 + hex.size && hex.x < w * 0.7 - hex.size) {
hex.x = w * 0.7 + hex.size;
}
if (hex.x > w + hex.size * 2) {
hex.x = -hex.size * 2;
hex.y = Math.random() * h;
hex.baseY = hex.y;
}
if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4;
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4;
const bob = Math.sin(now / 1000 + hex.phase) * 8;
hex.y = hex.baseY + bob;
const dark = isDarkRef.current;
ctx!.globalAlpha = hex.opacity * globalAlpha * (dark ? 0.6 : 1);
ctx!.globalAlpha = hex.opacity * (dark ? 0.6 : 1);
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.fill();
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha * (dark ? 0.6 : 1);
ctx!.globalAlpha = hex.opacity * 0.5 * (dark ? 0.6 : 1);
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
ctx!.lineWidth = 1;
drawHex(ctx!, hex.x, hex.y, hex.size);

View file

@ -0,0 +1,243 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { Slider } from '../ui/Slider';
import { apiUrl, authHeaders } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { TickerValue } from '../ui/TickerValue';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_FEATURE_NAMES = ['Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km'];
const DEMO_BOUNDS = '49,-9.5,57,5';
const DEMO_RESOLUTION = 5;
const noop = () => {};
const featureGradientStyle = gradientToCss(FEATURE_GRADIENT);
interface HomeDemoProps {
features: FeatureMeta[];
theme: 'light' | 'dark';
}
export default function HomeDemo({ features, theme }: HomeDemoProps) {
const [hexData, setHexData] = useState<HexagonData[]>([]);
const [sliderValues, setSliderValues] = useState<Record<string, [number, number]>>({});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const abortRef = useRef<AbortController>();
const dragAbortRef = useRef<AbortController>();
const activeFeatureRef = useRef<string | null>(null);
activeFeatureRef.current = activeFeature;
const demoFeatures = useMemo(
() =>
DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter(
Boolean
) as FeatureMeta[],
[features]
);
// Initialize slider values when features arrive
useEffect(() => {
if (demoFeatures.length === 0) return;
const initial: Record<string, [number, number]> = {};
for (const f of demoFeatures) {
if (f.min != null && f.max != null) {
initial[f.name] = [f.min, f.max];
}
}
setSliderValues(initial);
}, [demoFeatures]);
// Feature coloring only during drag; density (property count) otherwise
const viewFeatureName = activeFeature;
const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null;
const colorRange: [number, number] | null =
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
const filterRange: [number, number] | null = activeFeature && dragValue ? dragValue : null;
const displayData = dragHexData ?? hexData;
// Fetch hexagons (debounced) — skipped while dragging
const fetchHexagons = useCallback(() => {
if (activeFeatureRef.current) return;
if (features.length === 0 || Object.keys(sliderValues).length === 0) return;
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
const filterParts: string[] = [];
for (const [name, [min, max]] of Object.entries(sliderValues)) {
const meta = features.find((f) => f.name === name);
if (meta?.min != null && meta?.max != null) {
if (min !== meta.min || max !== meta.max) {
filterParts.push(`${name}:${min}:${max}`);
}
}
}
if (filterParts.length > 0) {
params.set('filters', filterParts.join(','));
}
abortRef.current?.abort();
abortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
.then((res) => res.json())
.then((data: { features: HexagonData[] }) => setHexData(data.features))
.catch(() => {});
}, [features, sliderValues]);
useEffect(() => {
clearTimeout(fetchTimeoutRef.current);
fetchTimeoutRef.current = setTimeout(fetchHexagons, 200);
return () => clearTimeout(fetchTimeoutRef.current);
}, [fetchHexagons]);
useEffect(() => {
return () => {
abortRef.current?.abort();
dragAbortRef.current?.abort();
clearTimeout(fetchTimeoutRef.current);
};
}, []);
// Drag start: fetch preview data with other filters only, fields=dragged feature
const handleDragStart = useCallback(
(name: string) => {
setActiveFeature(name);
const currentVal = sliderValues[name];
const meta = features.find((f) => f.name === name);
setDragValue(currentVal || (meta?.min != null ? [meta.min, meta.max!] : null));
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
const otherFilterParts: string[] = [];
for (const [n, [min, max]] of Object.entries(sliderValues)) {
if (n === name) continue;
const m = features.find((f) => f.name === n);
if (m?.min != null && m?.max != null && (min !== m.min || max !== m.max)) {
otherFilterParts.push(`${n}:${min}:${max}`);
}
}
if (otherFilterParts.length > 0) {
params.set('filters', otherFilterParts.join(','));
}
params.set('fields', name);
dragAbortRef.current?.abort();
dragAbortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
.catch(() => {});
},
[features, sliderValues]
);
const handleSliderChange = useCallback(
(name: string, value: [number, number]) => {
setSliderValues((prev) => ({ ...prev, [name]: value }));
if (activeFeatureRef.current === name) {
setDragValue(value);
}
},
[]
);
const handleDragEnd = useCallback(() => {
setActiveFeature(null);
setDragValue(null);
setDragHexData(null);
}, []);
return (
<div className="flex flex-col md:flex-row gap-6">
{/* Map */}
<div className="relative rounded-xl overflow-hidden shadow-sm aspect-[4/3] md:w-3/5">
<div className="absolute inset-0 z-50 cursor-default" />
<div className="absolute inset-0">
<MapComponent
data={displayData}
postcodeData={[]}
usePostcodeView={false}
pois={[]}
onViewChange={noop}
viewFeature={viewFeatureName}
colorRange={colorRange}
filterRange={filterRange}
viewSource={activeFeature ? 'drag' : null}
onCancelPin={noop}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={noop}
onHexagonHover={noop}
initialViewState={DEMO_VIEW}
theme={theme}
screenshotMode={true}
hideLegend={true}
/>
</div>
{/* Colour spectrum legend */}
<div className="absolute bottom-3 left-3 right-3 z-50 pointer-events-none">
<div className="bg-white/90 dark:bg-warm-800/90 rounded-lg px-3 py-2 backdrop-blur-sm text-xs">
<div className="font-semibold text-navy-950 dark:text-warm-100 mb-1 truncate">
{activeFeature ? viewMeta?.name || activeFeature : 'Property density'}
</div>
<div
className="h-2.5 rounded-full"
style={{
background: activeFeature
? featureGradientStyle
: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT),
}}
/>
{colorRange && (
<div className="flex justify-between mt-0.5 text-warm-500 dark:text-warm-400">
<TickerValue text={formatValue(colorRange[0], viewMeta ?? undefined)} />
<TickerValue text={formatValue(colorRange[1], viewMeta ?? undefined)} />
</div>
)}
</div>
</div>
</div>
{/* Sliders */}
<div className="md:w-2/5 flex flex-col justify-center space-y-6">
{demoFeatures.map((feature) => {
const value = sliderValues[feature.name];
if (!value || feature.min == null || feature.max == null) return null;
const isActive = activeFeature === feature.name;
return (
<div
key={feature.name}
className={`rounded-lg p-3 ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : ''}`}
>
<div className="flex justify-between mb-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
{feature.name}
</span>
<span className="text-sm text-warm-500 dark:text-warm-400">
{formatValue(value[0], feature)} &ndash; {formatValue(value[1], feature)}
</span>
</div>
<Slider
min={feature.min}
max={feature.max}
step={feature.step || 1}
value={[value[0], value[1]]}
onValueChange={([min, max]) => handleSliderChange(feature.name, [min, max])}
onPointerDown={() => handleDragStart(feature.name)}
onPointerUp={() => handleDragEnd()}
/>
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,221 +1,326 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { useRef, useState, useEffect } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
import HomeDemo from './HomeDemo';
import BottomIllustration from './BottomIllustration';
import CategoryArt from './CategoryArt';
import { TickerValue } from '../ui/TickerValue';
import type { FeatureMeta } from '../../types';
export default function HomePage({
onOpenDashboard,
onOpenPricing,
theme = 'light',
features = [],
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
theme?: 'light' | 'dark';
features?: FeatureMeta[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
if (max <= 0) return;
setScrollProgress(el.scrollTop / max);
}, []);
const [statsActive, setStatsActive] = useState(false);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
const timer = setTimeout(() => setStatsActive(true), 300);
return () => clearTimeout(timer);
}, []);
const heroRef = useFadeInRef();
const demoRef = useFadeInRef();
const scaleRef = useFadeInRef();
const problemRef = useFadeInRef();
const filtersRef = useFadeInRef();
const howRef = useFadeInRef();
const numbersRef = useFadeInRef();
const ctaRef = useFadeInRef();
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<HexCanvas scrollProgress={scrollProgress} isDark={theme === 'dark'} />
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="max-w-3xl mx-auto px-6 pt-12 pb-16 md:pt-20 md:pb-24">
<div
ref={heroRef}
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-navy-950/60 rounded-2xl p-8 -mx-2"
>
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
Find where to live, not just what&apos;s for sale
{/* Hero — full-bleed */}
<div
ref={heroRef}
className="fade-in-section relative overflow-hidden bg-gradient-to-br from-navy-950 via-navy-900 to-teal-900 dark:from-navy-950 dark:via-navy-900 dark:to-teal-900/60 pt-16 pb-20 md:pt-24 md:pb-28 shadow-[0_12px_50px_0px_rgba(13,148,136,0.5)] dark:shadow-[0_12px_50px_0px_rgba(13,148,136,0.4)]"
>
<HexCanvas isDark={theme === 'dark'} />
{/* Radial teal glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6">
<p className="text-teal-400 font-semibold tracking-wide uppercase text-sm mb-4">
Browsing listings is not a strategy. Knowing what you want is.
</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight">
Every neighbourhood
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-6 leading-[1.1] tracking-tight">
Find your{' '}
<span className="text-teal-400">perfect postcode</span>
<br />
in England &amp; Wales.
<br />
<span className="text-teal-600">One map. Your&nbsp;rules.</span>
<span className="text-warm-300">before you find your&nbsp;property.</span>
</h1>
<p className="text-xl text-warm-600 dark:text-warm-400 mb-8 leading-relaxed max-w-xl">
Set the commute, budget, school rating, noise level, and crime threshold you&apos;ll
accept. Perfect Postcodes shows you every area that qualifies &mdash; instantly.
<p className="text-xl text-warm-300 mb-8 leading-relaxed max-w-xl">
Set the sliders to your expectations and the map highlights the areas that actually
match. Instantly.
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 mb-12">
<button
onClick={onOpenDashboard}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
>
Explore the map
</button>
<span className="text-warm-400 text-sm">
No signup &middot; Free &middot; Open data
</span>
<button
onClick={onOpenPricing}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
>
Get a lifetime license
</button>
</div>
</div>
</div>
{/* The flip */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={problemRef} className="fade-in-section">
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
The old way
</h3>
<p className="text-warm-700 dark:text-warm-300 leading-relaxed">
Pick a postcode. Google the schools. Check crime stats on another site. Look up
commute times. Realise it&apos;s too expensive. Start over. Repeat 40 times.
</p>
<div className="flex gap-12 pt-6 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
</div>
<div>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">
With Perfect Postcodes
</h3>
<p className="text-warm-700 dark:text-warm-300 leading-relaxed">
Tell the map what you need. Every hexagon that lights up is a place worth
looking at. Drill into any one to see individual properties, prices, and energy
ratings.
</p>
<div className="text-sm text-warm-400">properties</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="56" active={statsActive} />
</div>
<div className="text-sm text-warm-400">data layers</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">Every</div>
<div className="text-sm text-warm-400">postcode in England</div>
</div>
</div>
</div>
</div>
{/* Filter showcase */}
{/* Map + Slider demo */}
<div className="max-w-4xl mx-auto px-6 pt-16 pb-20">
<div ref={demoRef} className="fade-in-section">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2">
See it in action
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-5 max-w-lg">
Drag the sliders and watch the map respond. Every postcode scored, every filter instant.
</p>
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-4 md:p-6">
<HomeDemo features={features} theme={theme} />
</div>
</div>
</div>
{/* Scale — "That's just two" + category cards */}
<div className="max-w-4xl mx-auto px-6 pb-20">
<div ref={filtersRef} className="fade-in-section">
<div ref={scaleRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2 text-center">
12 datasets. One slider&nbsp;each.
That&apos;s just three. We&apos;ve built&nbsp;43.
</h2>
<p className="text-warm-500 dark:text-warm-400 text-center mb-10 max-w-lg mx-auto">
Every filter narrows the map in real time. Combine as many as you like.
Spanning transport links, amenities, demographics, environment risk, broadband speeds,
crime, and more.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{FILTERS.map((f) => (
<div
key={f.label}
className="rounded-xl bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 p-4 shadow-sm hover:shadow-md hover:border-teal-300 dark:hover:border-teal-600 transition-all"
>
<div className="text-2xl mb-2">{f.icon}</div>
<div className="font-semibold text-navy-950 dark:text-warm-100 text-sm">
{f.label}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CATEGORIES.map((c) => (
<div
key={c.label}
className={`rounded-xl border-l-4 border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-4 shadow-sm hover:shadow-md transition-shadow ${c.borderClass} ${c.hoverBgClass}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`shrink-0 flex items-center justify-center rounded-lg w-8 h-8 text-base ${c.iconBgClass}`}
>
{c.icon}
</div>
<span className="font-semibold text-navy-950 dark:text-warm-100 text-sm">
{c.label}
</span>
</div>
<CategoryArt
category={c.group === 'Environment' && c.label === 'Broadband' ? 'Broadband' : c.group}
className={`shrink-0 ${c.artColorClass} opacity-40`}
/>
</div>
</div>
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5">{f.example}</div>
</div>
))}
</div>
</div>
</div>
{/* How it works */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={howRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
Three clicks to clarity
</h2>
<div className="space-y-6">
{STEPS.map((step, i) => (
<div key={i} className="flex gap-5 items-start">
<span className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center text-lg font-bold">
{i + 1}
</span>
<div>
<h3 className="font-semibold text-navy-950 dark:text-warm-100 text-lg">
{step.title}
</h3>
<p className="text-warm-600 dark:text-warm-400 mt-0.5">{step.body}</p>
</div>
</div>
))}
{/* Problem / solution / philosophy */}
<div className="max-w-4xl mx-auto px-6 pb-20 relative">
{/* Cereal box — quirky margin note, hidden on narrow screens */}
<div className="hidden lg:block group absolute -right-44 top-8 cursor-pointer">
<div className="cereal-wobble">
<img src="/cereal.png" alt="Discounted cereal box" className="w-36 h-auto" />
</div>
<p className="cereal-text text-sm italic text-warm-500 dark:text-warm-400 mt-2 w-[9rem] leading-snug">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong
property distract you from finding the right one.
</p>
</div>
</div>
{/* Numbers */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={numbersRef} className="fade-in-section">
<div className="grid grid-cols-3 gap-6 text-center">
{STATS.map((s) => (
<div key={s.label}>
<div className="text-2xl md:text-3xl font-extrabold text-teal-600">{s.value}</div>
<div className="text-sm text-warm-500 dark:text-warm-400 mt-1">{s.label}</div>
</div>
))}
</div>
<div ref={problemRef} className="fade-in-section">
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
Here&apos;s the problem with property search: listings only show you what&apos;s on
the market{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
&mdash; a thin slice of what an area is actually like. And even if you could look
beyond them, there are{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
millions of postcodes
</strong>{' '}
across England. You can&apos;t research them all yourself.
</p>
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
We built this for you &mdash; years of historical transactions and public records,
extended with proprietary algorithms so the map doesn&apos;t just show raw data, it{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
surfaces the patterns that matter
</strong>
.
</p>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 leading-relaxed">
Understand areas first. Then find the right property within them, with expectations
you&apos;ve set &mdash; not ones the market set for you.
</p>
</div>
</div>
{/* Final CTA */}
<div className="max-w-3xl mx-auto px-6 pb-24">
<div className="max-w-3xl mx-auto px-6 pb-12">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
Ready to narrow it down?
The biggest financial decision of your life
<br />
deserves proper tools behind&nbsp;it.
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-8 max-w-md mx-auto">
100% open data. No account required. Just set your filters and go.
One payment, lifetime access. Set your filters and go.
</p>
<button
onClick={onOpenDashboard}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Open the map
</button>
<div className="flex items-center justify-center gap-4">
<button
onClick={onOpenDashboard}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Give your journey a headstart
</button>
<button
onClick={onOpenPricing}
className="px-[30px] py-[14px] border-2 border-navy-950 dark:border-warm-300 text-navy-950 dark:text-warm-300 rounded-lg font-semibold hover:bg-navy-950/5 dark:hover:bg-warm-300/5 transition-colors text-lg"
>
See pricing
</button>
</div>
</div>
</div>
{/* Bottom illustration */}
<BottomIllustration isDark={theme === 'dark'} />
</div>
</div>
);
}
const FILTERS = [
{ icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' },
{ icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' },
{ icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' },
{ icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' },
{ icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' },
{ icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' },
{ icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' },
{ icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' },
];
interface Category {
icon: string;
label: string;
group: string;
borderClass: string;
hoverBgClass: string;
iconBgClass: string;
artColorClass: string;
}
const STEPS = [
const CATEGORIES: Category[] = [
{
title: 'Add your deal-breakers',
body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.',
},
{
title: 'Spot the clusters',
body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.',
},
{
title: 'Dive into a neighbourhood',
body: 'Click any hexagon to see every property inside it \u2014 sale prices, floor plans, energy ratings, tenure. Layer on cafes, GP surgeries, and parks from OpenStreetMap.',
},
];
icon: '\u{1F3E0}',
label: 'Property',
group: 'Property',
borderClass: 'border-l-teal-400 dark:border-l-teal-500',
hoverBgClass: 'hover:bg-teal-50/50 dark:hover:bg-teal-900/20',
iconBgClass: 'bg-teal-100 dark:bg-teal-900/40',
artColorClass: 'text-teal-400 dark:text-teal-600',
const STATS = [
{ value: '26M+', label: 'property records' },
{ value: '12', label: 'open datasets' },
{ value: '1.7M', label: 'postcodes mapped' },
},
{
icon: '\u{1F686}',
label: 'Transport',
group: 'Transport',
borderClass: 'border-l-blue-400 dark:border-l-blue-500',
hoverBgClass: 'hover:bg-blue-50/50 dark:hover:bg-blue-900/20',
iconBgClass: 'bg-blue-100 dark:bg-blue-900/40',
artColorClass: 'text-blue-400 dark:text-blue-600',
},
{
icon: '\u{1F3EB}',
label: 'Schools',
group: 'Education',
borderClass: 'border-l-amber-400 dark:border-l-amber-500',
hoverBgClass: 'hover:bg-amber-50/50 dark:hover:bg-amber-900/20',
iconBgClass: 'bg-amber-100 dark:bg-amber-900/40',
artColorClass: 'text-amber-400 dark:text-amber-600',
},
{
icon: '\u{1F6A8}',
label: 'Crime',
group: 'Crime',
borderClass: 'border-l-rose-400 dark:border-l-rose-500',
hoverBgClass: 'hover:bg-rose-50/50 dark:hover:bg-rose-900/20',
iconBgClass: 'bg-rose-100 dark:bg-rose-900/40',
artColorClass: 'text-rose-400 dark:text-rose-600',
},
{
icon: '\u{1F465}',
label: 'Demographics',
group: 'Demographics',
borderClass: 'border-l-violet-400 dark:border-l-violet-500',
hoverBgClass: 'hover:bg-violet-50/50 dark:hover:bg-violet-900/20',
iconBgClass: 'bg-violet-100 dark:bg-violet-900/40',
artColorClass: 'text-violet-400 dark:text-violet-600',
},
{
icon: '\u{1F3EA}',
label: 'Amenities',
group: 'Amenities',
borderClass: 'border-l-emerald-400 dark:border-l-emerald-500',
hoverBgClass: 'hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20',
iconBgClass: 'bg-emerald-100 dark:bg-emerald-900/40',
artColorClass: 'text-emerald-400 dark:text-emerald-600',
},
{
icon: '\u{1F30D}',
label: 'Environment',
group: 'Environment',
borderClass: 'border-l-orange-400 dark:border-l-orange-500',
hoverBgClass: 'hover:bg-orange-50/50 dark:hover:bg-orange-900/20',
iconBgClass: 'bg-orange-100 dark:bg-orange-900/40',
artColorClass: 'text-orange-400 dark:text-orange-600',
},
{
icon: '\u{1F4E1}',
label: 'Broadband',
group: 'Environment',
borderClass: 'border-l-sky-400 dark:border-l-sky-500',
hoverBgClass: 'hover:bg-sky-50/50 dark:hover:bg-sky-900/20',
iconBgClass: 'bg-sky-100 dark:bg-sky-900/40',
artColorClass: 'text-sky-400 dark:text-sky-600',
},
{
icon: '\u{1F4CA}',
label: 'Deprivation',
group: 'Deprivation',
borderClass: 'border-l-fuchsia-400 dark:border-l-fuchsia-500',
hoverBgClass: 'hover:bg-fuchsia-50/50 dark:hover:bg-fuchsia-900/20',
iconBgClass: 'bg-fuchsia-100 dark:bg-fuchsia-900/40',
artColorClass: 'text-fuchsia-400 dark:text-fuchsia-600',
},
];

View file

@ -2,6 +2,7 @@ import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
export default function MapLegend({
featureLabel,
@ -50,8 +51,8 @@ export default function MapLegend({
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? (
<>
<span>{formatValue(range[0])}</span>
<span>{formatValue(range[1])}</span>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
</>
) : enumValues && enumValues.length > 0 ? (
<>
@ -60,8 +61,8 @@ export default function MapLegend({
</>
) : (
<>
<span>{formatValue(range[0])}</span>
<span>{formatValue(range[1])}</span>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
</>
)}
</div>

View file

@ -143,6 +143,7 @@ function PropertyLoadingSkeleton() {
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum(
@ -172,6 +173,14 @@ function PropertyCard({ property }: { property: Property }) {
)}
</div>
)}
{estimatedPrice !== undefined && (
<div className="text-sm text-warm-600 dark:text-warm-400">
Est. value:{' '}
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(estimatedPrice)}
</span>
</div>
)}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && (

View file

@ -0,0 +1,69 @@
import { CheckIcon } from '../ui/icons/CheckIcon';
const FEATURES = [
'56 data layers across England',
'Every postcode scored and filterable',
'Unlimited map exploration and exports',
'Historical price data back to 1995',
'Crime, schools, transport, broadband & more',
'All future data updates included',
];
export default function PricingPage({
onOpenDashboard,
}: {
onOpenDashboard: () => void;
}) {
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-3xl mx-auto px-6 py-16">
<div className="text-center mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-warm-100 mb-3">
One price. Yours forever.
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime access to every feature.
</p>
</div>
<div className="max-w-md mx-auto bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
{/* Price header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-8 py-10 text-center">
<div className="text-sm font-semibold text-teal-400 uppercase tracking-wide mb-2">
Lifetime License
</div>
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl font-extrabold text-white">£100</span>
<span className="text-warm-400 text-lg">/once</span>
</div>
<p className="text-warm-300 text-sm mt-2">
One-time payment, no subscription
</p>
</div>
{/* Features list */}
<div className="px-8 py-8">
<ul className="space-y-4">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-3">
<CheckIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feature}</span>
</li>
))}
</ul>
<button
onClick={onOpenDashboard}
className="w-full mt-8 px-6 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Get started
</button>
<p className="text-center text-sm text-warm-400 dark:text-warm-500 mt-3">
30-day money-back guarantee
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { MapPinIcon } from './icons/MapPinIcon';
import { LogoIcon } from './icons/LogoIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { MenuIcon } from './icons/MenuIcon';
@ -12,7 +12,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches' | 'pricing';
export default function Header({
activePage,
@ -97,7 +97,7 @@ export default function Header({
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')}
>
<MapPinIcon className="w-5 h-5 text-teal-400" />
<LogoIcon className="w-5 h-5 text-teal-400" />
<span className="font-semibold text-lg">Perfect Postcodes</span>
</button>
@ -124,6 +124,9 @@ export default function Header({
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ
</button>
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
</nav>
)}
</div>

View file

@ -82,6 +82,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('data-sources', 'Data Sources')}
{mobileNavItem('faq', 'FAQ')}
{mobileNavItem('pricing', 'Pricing')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (

View file

@ -0,0 +1,39 @@
const DIGITS = '0123456789';
const H = 1.15; // digit slot height in em
function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) {
const idx = DIGITS.indexOf(char);
if (idx === -1) return <span>{char}</span>;
const offset = active ? -idx * H : 0;
return (
<span className="inline-block overflow-hidden" style={{ height: `${H}em` }}>
<span
className="block"
style={{
transform: `translateY(${offset}em)`,
transition: `transform 0.5s cubic-bezier(0.22, 1, 0.36, 1) ${delay}ms`,
}}
>
{DIGITS.split('').map((d) => (
<span key={d} className="block text-center" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
{d}
</span>
))}
</span>
</span>
);
}
export function TickerValue({ text, active = true }: { text: string; active?: boolean }) {
const chars = text.split('');
const len = chars.length;
return (
<span className="inline-flex" style={{ fontVariantNumeric: 'tabular-nums' }}>
{chars.map((ch, i) => (
<Digit key={i} char={ch} delay={(len - 1 - i) * 30} active={active} />
))}
</span>
);
}

View file

@ -53,3 +53,63 @@ h3 {
opacity: 1;
transform: translateY(0);
}
/* Cereal aside — hover to reveal */
@keyframes cereal-wobble {
0%,
100% {
transform: rotate(0deg);
}
15% {
transform: rotate(-8deg);
}
30% {
transform: rotate(6deg);
}
45% {
transform: rotate(-4deg);
}
60% {
transform: rotate(2deg);
}
80% {
transform: rotate(-1deg);
}
}
.cereal-wobble {
transform-origin: bottom center;
}
.group:hover .cereal-wobble {
animation: cereal-wobble 0.8s ease-in-out;
}
.cereal-reveal {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 0.5s ease-out,
color 0.2s ease;
}
.group:hover .cereal-reveal {
grid-template-rows: 1fr;
}
.cereal-reveal > * {
overflow: hidden;
}
.cereal-text {
opacity: 0;
transition:
opacity 0.4s ease-out,
color 0.2s ease;
}
.group:hover .cereal-text {
opacity: 1;
transition-delay: 0.2s, 0s;
}

View file

@ -1,21 +0,0 @@
(above title) Browsing listings is not a strategy. Knowing what you want is.
(title) Find your <green>perfect postcode</green> before you find your property.
Set the sliders to your expectations and the map highlights the areas that actually match. Instantly.
<example with two sliders, last known price, minor crime/>
That's just two. We've built 43 — spanning transport links, amenities, demographics, environment risk, broadband speeds, crime, and more. (show the filter types with small cards)
Here's the problem with property search: listings only show you what's on the market right now — a thin slice of what an area is actually like. And even if you could look beyond them, there are millions of postcodes across England. You can't research them all yourself.
We built this for you — years of historical transactions and public records, extended with proprietary algorithms so the map doesn't just show raw data, it surfaces the patterns that matter.
Understand areas first. Then find the right property within them, with expectations you've set rather than ones the market set for you.
(Fun cereal graphic on the side with this popup) You might buy a box of cereal because it's 20% off. Your next home is not a box of cereal. Don't let a discount on the wrong property distract you from finding the right one. Know what you're looking for, then go looking.
The biggest financial decision of your life deserves proper tools behind it.
[Explore the map] Button

View file

@ -1,2 +1,3 @@
certificates.csv
crime
postcode_boundaries

View file

@ -0,0 +1,89 @@
"""Download UK PMTiles extract from the latest Protomaps daily build."""
import argparse
import platform
import stat
import subprocess
import sys
import tarfile
import urllib.request
from datetime import datetime, timedelta
from io import BytesIO
from pathlib import Path
PROTOMAPS_BASE = "https://build.protomaps.com"
UK_BBOX = "-10.5,49.5,2.5,61"
MAX_AGE_DAYS = 14
def find_latest_build() -> str:
"""Find the most recent available Protomaps daily build."""
today = datetime.utcnow().date()
for i in range(MAX_AGE_DAYS):
d = today - timedelta(days=i)
url = f"{PROTOMAPS_BASE}/{d:%Y%m%d}.pmtiles"
req = urllib.request.Request(url, method="HEAD")
try:
urllib.request.urlopen(req)
print(f"Found build: {d:%Y%m%d}")
return url
except urllib.error.HTTPError:
continue
print(
f"ERROR: No Protomaps build found in the last {MAX_AGE_DAYS} days",
file=sys.stderr,
)
sys.exit(1)
def ensure_pmtiles_cli(bin_path: Path, version: str) -> None:
"""Download the pmtiles CLI if not already present."""
if bin_path.exists():
return
machine = platform.machine()
if machine == "x86_64":
arch = "x86_64"
elif machine == "aarch64":
arch = "arm64"
else:
arch = machine
url = (
f"https://github.com/protomaps/go-pmtiles/releases/download/"
f"v{version}/go-pmtiles_{version}_Linux_{arch}.tar.gz"
)
print(f"Downloading pmtiles CLI v{version}...")
data = urllib.request.urlopen(url).read()
with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tar:
member = tar.getmember("pmtiles")
f = tar.extractfile(member)
assert f is not None
bin_path.parent.mkdir(parents=True, exist_ok=True)
bin_path.write_bytes(f.read())
bin_path.chmod(bin_path.stat().st_mode | stat.S_IEXEC)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True, help="Output .pmtiles path")
parser.add_argument(
"--pmtiles-version", default="1.22.3", help="go-pmtiles release version"
)
args = parser.parse_args()
bin_path = args.output.parent / "pmtiles"
ensure_pmtiles_cli(bin_path, args.pmtiles_version)
source_url = find_latest_build()
print(f"Extracting UK tiles from {source_url}...")
subprocess.run(
[str(bin_path), "extract", source_url, str(args.output), f"--bbox={UK_BBOX}"],
check=True,
)
size_mb = args.output.stat().st_size / (1024 * 1024)
print(f"Wrote {args.output} ({size_mb:.1f} MB)")
if __name__ == "__main__":
main()

View file

@ -55,12 +55,16 @@ def _build_wide(
)
)
arcgis = pl.scan_parquet(arcgis_path).select(
pl.col("pcds").alias("postcode"),
"lat",
pl.col("long").alias("lon"),
"lsoa21",
"oa21",
arcgis = (
pl.scan_parquet(arcgis_path)
.filter(pl.col("ctry") == "E92000001") # England only
.select(
pl.col("pcds").alias("postcode"),
"lat",
pl.col("long").alias("lon"),
"lsoa21",
"oa21",
)
)
wide = wide.join(arcgis, on="postcode", how="full", coalesce=True)

53
scripts/remove_bg.py Normal file
View file

@ -0,0 +1,53 @@
"""Remove white background from an image by flood-filling from edges only."""
import sys
from collections import deque
from PIL import Image
def remove_white_bg(path: str, tolerance: int = 20, out: str | None = None):
img = Image.open(path).convert("RGBA")
pixels = img.load()
w, h = img.size
threshold = 255 - tolerance
visited = set()
queue = deque()
# Seed from all edge pixels
for x in range(w):
queue.append((x, 0))
queue.append((x, h - 1))
for y in range(h):
queue.append((0, y))
queue.append((w - 1, y))
while queue:
x, y = queue.popleft()
if (x, y) in visited or x < 0 or y < 0 or x >= w or y >= h:
continue
visited.add((x, y))
r, g, b, a = pixels[x, y]
if r >= threshold and g >= threshold and b >= threshold:
pixels[x, y] = (r, g, b, 0)
queue.append((x + 1, y))
queue.append((x - 1, y))
queue.append((x, y + 1))
queue.append((x, y - 1))
# Crop to bounding box of non-transparent pixels
bbox = img.getbbox()
if bbox:
img = img.crop(bbox)
dest = out or path
img.save(dest)
print(f"Saved to {dest} ({img.size[0]}x{img.size[1]})")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python remove_bg.py <image> [tolerance] [output]")
sys.exit(1)
path = sys.argv[1]
tol = int(sys.argv[2]) if len(sys.argv) > 2 else 20
out = sys.argv[3] if len(sys.argv) > 3 else None
remove_white_bg(path, tol, out)

View file

@ -86,20 +86,20 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
},
// FeatureConfig {
// name: "Estimated current price",
// bounds: Bounds::Fixed {
// min: 0.0,
// max: 2_000_000.0,
// },
// step: 10000.0,
// description: "Inflation-adjusted estimate of the current property value",
// detail: "Estimated by applying a repeat-sales price index to the last known sale price. The index tracks price changes within each postcode sector and property type. Properties sold recently will have estimates close to their sale price; older sales are adjusted more. Coverage depends on having enough repeat sales in the local area to build the index.",
// source: "price-paid",
// prefix: "£",
// suffix: "",
// raw: false,
// },
FeatureConfig {
name: "Estimated current price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_000_000.0,
},
step: 10000.0,
description: "Inflation-adjusted estimate of the current property value",
detail: "Estimated by applying a repeat-sales price index to the last known sale price. The index tracks price changes within each postcode sector and property type. Properties sold recently will have estimates close to their sale price; older sales are adjusted more. Coverage depends on having enough repeat sales in the local area to build the index.",
source: "price-paid",
prefix: "£",
suffix: "",
raw: false,
},
FeatureConfig {
name: "Price per sqm",
bounds: Bounds::Percentile {