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=server /app/server-rs/target/release/property-map-server ./
COPY --from=frontend /app/frontend/dist ./dist/ COPY --from=frontend /app/frontend/dist ./dist/
# COPY property-data/wide.parquet ./data/ COPY property-data/wide.parquet ./data/
# COPY property-data/filtered_uk_pois.parquet ./data/ COPY property-data/filtered_uk_pois.parquet ./data/
# COPY property-data/uk.pmtiles ./data/ COPY property-data/uk.pmtiles ./data/
# COPY property-data/postcodes ./data/postcodes/ COPY manual-data/postcode_boundaries ./data/postcode_boundaries/
EXPOSE 8001 EXPOSE 8001
ENTRYPOINT ["./property-map-server"] 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: # Usage:
# make -f Makefile.data prepare # Build wide.parquet (+ all deps) # make -f Makefile.data prepare # Build wide.parquet (+ all deps)
# make -f Makefile.data tiles # Download UK map tiles # 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. # Or include from the main Makefile and use targets directly.
SHELL := /bin/bash SHELL := /bin/bash
.DELETE_ON_ERROR: .DELETE_ON_ERROR:
DATA_DIR := /bulk/property-data DATA_DIR := ./property-data
MANUAL_DATA := ./manual-data
# ── Output files ────────────────────────────────────────────────────────────── # ── Output files ──────────────────────────────────────────────────────────────
@ -24,11 +23,13 @@ POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
EPC_PP := $(DATA_DIR)/epc_pp.parquet EPC_PP := $(DATA_DIR)/epc_pp.parquet
WIDE := $(DATA_DIR)/wide.parquet WIDE := $(DATA_DIR)/wide.parquet
EPC := $(DATA_DIR)/certificates.csv PRICE_INDEX := $(DATA_DIR)/price_index.parquet
JT_BANK := $(DATA_DIR)/journey_times_bank.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done
JT_FITZROVIA := $(DATA_DIR)/journey_times_fitzrovia.parquet 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 ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
CRIME_DIR := $(DATA_DIR)/crime CRIME_DIR := $(MANUAL_DATA)/crime
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
NOISE := $(DATA_DIR)/road_noise.parquet NOISE := $(DATA_DIR)/road_noise.parquet
OFSTED := $(DATA_DIR)/ofsted.parquet OFSTED := $(DATA_DIR)/ofsted.parquet
@ -40,28 +41,28 @@ GEOSURE := $(DATA_DIR)/geosure.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)/new_postcode_boundaries PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries
# Sentinel files for directory targets (Make doesn't track directories well) # Sentinel files for directory targets (Make doesn't track directories well)
GEOSURE_STAMP := $(GEOSURE_DIR)/.done GEOSURE_STAMP := $(GEOSURE_DIR)/.done
INSPIRE_STAMP := $(INSPIRE_DIR)/.done INSPIRE_STAMP := $(INSPIRE_DIR)/.done
MANUAL_DATA := $(DATA_DIR)/manual_data
PMTILES_VERSION := 1.22.3 PMTILES_VERSION := 1.22.3
PMTILES_BIN := $(DATA_DIR)/pmtiles
# ── Phony aliases ───────────────────────────────────────────────────────────── # ── Phony aliases ─────────────────────────────────────────────────────────────
.PHONY: prepare tiles \ .PHONY: prepare wide tiles \
download-arcgis download-price-paid download-deprivation download-ethnicity \ download-arcgis download-price-paid download-deprivation download-ethnicity \
download-naptan download-pois download-ofsted download-broadband \ download-naptan download-pois download-ofsted download-broadband \
download-postcodes download-geosure download-noise download-inspire \ download-postcodes download-geosure download-noise download-inspire \
download-oa-boundaries download-uprn-lookup \ download-oa-boundaries download-uprn-lookup \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \
transform-school-proximity transform-geosure transform-postcode-boundaries \ transform-school-proximity transform-geosure transform-postcode-boundaries \
generate-postcode-boundaries \
journey-times journey-times
prepare: $(WIDE) prepare: $(DATA_DIR)/.prices_done
wide: $(WIDE)
tiles: $(TILES) tiles: $(TILES)
download-arcgis: $(ARCGIS) download-arcgis: $(ARCGIS)
download-price-paid: $(PRICE_PAID) download-price-paid: $(PRICE_PAID)
@ -84,19 +85,17 @@ transform-poi-proximity: $(POI_PROXIMITY)
transform-school-proximity: $(SCHOOL_PROX) transform-school-proximity: $(SCHOOL_PROX)
transform-geosure: $(GEOSURE) transform-geosure: $(GEOSURE)
transform-postcode-boundaries: $(PC_BOUNDARIES) 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 ───────────────────────────────────────────────────────────────── # ── Downloads ─────────────────────────────────────────────────────────────────
$(TILES): $(TILES):
@echo "Downloading UK PMTiles (~1.5GB)..." uv run -m pipeline.download.tiles --output $@ --pmtiles-version $(PMTILES_VERSION)
@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
# EPC requires manual registration — fail with instructions # EPC requires manual registration — fail with instructions
$(EPC): $(EPC):
@ -105,6 +104,7 @@ $(EPC):
@echo "The EPC certificates file is required: $@" @echo "The EPC certificates file is required: $@"
@echo "" @echo ""
@echo "To obtain it, register at https://epc.opendatacommunities.org/login" @echo "To obtain it, register at https://epc.opendatacommunities.org/login"
@echo "and place certificates.csv in manual-data/"
@echo "" @echo ""
@exit 1 @exit 1
@ -155,32 +155,22 @@ $(UPRN_LOOKUP):
# ── Journey times (requires TFL_API_KEY) ────────────────────────────────────── # ── Journey times (requires TFL_API_KEY) ──────────────────────────────────────
$(JT_BANK): $(JT_BANK):
@if [ -f "$(MANUAL_DATA)/journey_times_bank.parquet" ]; then \ @echo ""
echo "Copying journey_times_bank.parquet from manual_data/"; \ @echo "=== TFL journey times (bank) not found ==="
cp "$(MANUAL_DATA)/journey_times_bank.parquet" $@; \ @echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/"
else \ @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
echo ""; \ @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank"
echo "=== TFL journey times (bank) not found ==="; \ @echo ""
echo "Either place the file in $(MANUAL_DATA)/journey_times_bank.parquet"; \ @exit 1
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
$(JT_FITZROVIA): $(JT_FITZROVIA):
@if [ -f "$(MANUAL_DATA)/journey_times_fitzrovia.parquet" ]; then \ @echo ""
echo "Copying journey_times_fitzrovia.parquet from manual_data/"; \ @echo "=== TFL journey times (fitzrovia) not found ==="
cp "$(MANUAL_DATA)/journey_times_fitzrovia.parquet" $@; \ @echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/"
else \ @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
echo ""; \ @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia"
echo "=== TFL journey times (fitzrovia) not found ==="; \ @echo ""
echo "Either place the file in $(MANUAL_DATA)/journey_times_fitzrovia.parquet"; \ @exit 1
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
journey-times: $(ARCGIS) journey-times: $(ARCGIS)
ifndef DEST 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 $@ uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@
$(CRIME): $(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 $@ uv run python -m pipeline.transform.crime --input $(CRIME_DIR) --output $@
$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED)
@ -208,12 +206,20 @@ $(SCHOOL_PROX): $(OFSTED) $(ARCGIS)
$(GEOSURE): $(GEOSURE_STAMP) $(ARCGIS) $(GEOSURE): $(GEOSURE_STAMP) $(ARCGIS)
uv run python -m pipeline.transform.transform_geosure --geosure $(GEOSURE_DIR) --arcgis $(ARCGIS) --output $@ uv run python -m pipeline.transform.transform_geosure --geosure $(GEOSURE_DIR) --arcgis $(ARCGIS) --output $@
$(PC_BOUNDARIES): $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) # Postcode boundaries require manual generation — fail with instructions
uv run python -m pipeline.transform.postcode_boundaries \ $(PC_BOUNDARIES):
--uprn $(UPRN_LOOKUP) \ @echo ""
--oa-boundaries $(OA_BOUNDARIES) \ @echo "=== Postcode boundaries not found ==="
--inspire $(INSPIRE_DIR) \ @echo "The postcode boundaries directory is required: $@"
--output $@ @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 ─────────────────────────────────────────────────────────────── # ── Final merge ───────────────────────────────────────────────────────────────
@ -233,3 +239,12 @@ $(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA)
--broadband $(BROADBAND) \ --broadband $(BROADBAND) \
--geosure $(GEOSURE) \ --geosure $(GEOSURE) \
--output $@ --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/ 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. 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 - register for email
FAQ: FAQ:
- Why hexagons? - Why hexagons?
- Why the price tag? - Why the price tag?
- contact support - contact support
- -
make -f Makefile.data prepare
make -f Makefile.data tiles
## outstadning prompts ## 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: > command: >
bash -c " bash -c "
cargo install cargo-watch && 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: ports:
- "8001:8001" - "8001:8001"
@ -15,9 +15,10 @@ services:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes: volumes:
- .:/app - .:/app
- /bulk/property-data:/data:ro
- cargo-registry:/usr/local/cargo/registry - cargo-registry:/usr/local/cargo/registry
- cargo-target:/app/server-rs/target - cargo-target:/app/server-rs/target
- ./property-data:/app/data:ro
environment: environment:
POCKETBASE_URL: http://pocketbase:8090 POCKETBASE_URL: http://pocketbase:8090
SCREENSHOT_URL: http://screenshot:8002 SCREENSHOT_URL: http://screenshot:8002

View file

@ -40,6 +40,8 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0", "eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"favicons": "^7.2.0",
"favicons-webpack-plugin": "^6.0.1",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.9.0", "mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
@ -1872,6 +1874,16 @@
"node": ">=10.0.0" "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": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -2083,6 +2095,367 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -5227,6 +5600,15 @@
"node": ">= 0.4" "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": { "node_modules/autoprefixer": {
"version": "10.4.23", "version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@ -5953,6 +6335,19 @@
"node": ">=6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5973,6 +6368,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/colorette": {
"version": "2.0.20", "version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@ -6584,6 +6989,15 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/detect-node": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@ -7769,6 +8183,41 @@
"reusify": "^1.0.4" "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": { "node_modules/faye-websocket": {
"version": "0.11.4", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
@ -7859,6 +8308,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -10586,6 +11041,18 @@
"node": ">=6" "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": { "node_modules/parse-json": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -10605,6 +11072,30 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -11928,6 +12419,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -12226,6 +12726,45 @@
"node": ">=8" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -12338,6 +12877,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -14171,6 +14725,28 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -45,6 +45,8 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0", "eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"favicons": "^7.2.0",
"favicons-webpack-plugin": "^6.0.1",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.9.0", "mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.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 MapPage, { type ExportState } from './components/map/MapPage';
import DataSourcesPage from './components/data-sources/DataSourcesPage'; import DataSourcesPage from './components/data-sources/DataSourcesPage';
import FAQPage from './components/faq/FAQPage'; import FAQPage from './components/faq/FAQPage';
import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage'; import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage'; import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import Header, { type Page } from './components/ui/Header'; import Header, { type Page } from './components/ui/Header';
@ -32,6 +33,8 @@ function pageToPath(page: Page): string {
return '/faq'; return '/faq';
case 'saved-searches': case 'saved-searches':
return '/saved'; return '/saved';
case 'pricing':
return '/pricing';
default: default:
return '/'; return '/';
} }
@ -42,6 +45,7 @@ function pathToPage(pathname: string): Page | null {
if (pathname === '/data-sources') return 'data-sources'; if (pathname === '/data-sources') return 'data-sources';
if (pathname === '/faq') return 'faq'; if (pathname === '/faq') return 'faq';
if (pathname === '/saved') return 'saved-searches'; if (pathname === '/saved') return 'saved-searches';
if (pathname === '/pricing') return 'pricing';
if (pathname === '/') return 'home'; if (pathname === '/') return 'home';
return null; return null;
} }
@ -235,11 +239,13 @@ export default function App() {
isMobile={isMobile} isMobile={isMobile}
/> />
{activePage === 'home' ? ( {activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} /> <HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'data-sources' ? ( ) : activePage === 'data-sources' ? (
<DataSourcesPage /> <DataSourcesPage />
) : activePage === 'faq' ? ( ) : activePage === 'faq' ? (
<FAQPage /> <FAQPage />
) : activePage === 'pricing' ? (
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
) : activePage === 'saved-searches' ? ( ) : activePage === 'saved-searches' ? (
<SavedSearchesPage <SavedSearchesPage
searches={savedSearches.searches} 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'; import { useRef, useEffect } from 'react';
const HEX_COUNT = 60; const HEX_COUNT = 70;
const TAU = Math.PI * 2; const TAU = Math.PI * 2;
interface Hex { interface Hex {
@ -17,12 +17,14 @@ function initHexes(w: number, h: number): Hex[] {
const hexes: Hex[] = []; const hexes: Hex[] = [];
for (let i = 0; i < HEX_COUNT; i++) { for (let i = 0; i < HEX_COUNT; i++) {
const y = Math.random() * h; 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({ hexes.push({
x: Math.random() * w, x,
y, y,
baseY: y, baseY: y,
size: 8 + Math.random() * 20, size: 8 + Math.random() * 20,
opacity: 0.06 + Math.random() * 0.12, opacity: 0.08 + Math.random() * 0.15,
speed: 6 + Math.random() * 14, speed: 6 + Math.random() * 14,
phase: Math.random() * TAU, phase: Math.random() * TAU,
}); });
@ -42,18 +44,10 @@ function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: numbe
ctx.closePath(); ctx.closePath();
} }
export default function HexCanvas({ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
scrollProgress,
isDark = false,
}: {
scrollProgress: number;
isDark?: boolean;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const hexesRef = useRef<Hex[]>([]); const hexesRef = useRef<Hex[]>([]);
const animRef = useRef(0); const animRef = useRef(0);
const scrollRef = useRef(scrollProgress);
scrollRef.current = scrollProgress;
const isDarkRef = useRef(isDark); const isDarkRef = useRef(isDark);
isDarkRef.current = isDark; isDarkRef.current = isDark;
@ -88,27 +82,29 @@ export default function HexCanvas({
function frame(now: number) { function frame(now: number) {
const dt = (now - prev) / 1000; const dt = (now - prev) / 1000;
prev = now; prev = now;
const scroll = scrollRef.current;
ctx!.clearRect(0, 0, w, h); ctx!.clearRect(0, 0, w, h);
const globalAlpha = Math.max(0, 1 - scroll * 2);
for (const hex of hexesRef.current) { for (const hex of hexesRef.current) {
hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2); hex.x += hex.speed * dt * 0.3;
const bob = Math.sin(now / 1000 + hex.phase) * 8; if (hex.x > w * 0.3 + hex.size && hex.x < w * 0.7 - hex.size) {
const parallax = scroll * h * 0.3 * (hex.speed / 20); hex.x = w * 0.7 + hex.size;
hex.y = hex.baseY + bob - parallax; }
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; const bob = Math.sin(now / 1000 + hex.phase) * 8;
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4; hex.y = hex.baseY + bob;
const dark = isDarkRef.current; 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'; ctx!.fillStyle = dark ? '#058172' : '#00a28c';
drawHex(ctx!, hex.x, hex.y, hex.size); drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.fill(); 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!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
ctx!.lineWidth = 1; ctx!.lineWidth = 1;
drawHex(ctx!, hex.x, hex.y, hex.size); 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 { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas'; 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({ export default function HomePage({
onOpenDashboard, onOpenDashboard,
onOpenPricing,
theme = 'light', theme = 'light',
features = [],
}: { }: {
onOpenDashboard: () => void; onOpenDashboard: () => void;
onOpenPricing: () => void;
theme?: 'light' | 'dark'; theme?: 'light' | 'dark';
features?: FeatureMeta[];
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [scrollProgress, setScrollProgress] = useState(0); const [statsActive, setStatsActive] = useState(false);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
if (max <= 0) return;
setScrollProgress(el.scrollTop / max);
}, []);
useEffect(() => { useEffect(() => {
const el = scrollRef.current; const timer = setTimeout(() => setStatsActive(true), 300);
if (!el) return; return () => clearTimeout(timer);
el.addEventListener('scroll', handleScroll, { passive: true }); }, []);
return () => el.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
const heroRef = useFadeInRef(); const heroRef = useFadeInRef();
const demoRef = useFadeInRef();
const scaleRef = useFadeInRef();
const problemRef = useFadeInRef(); const problemRef = useFadeInRef();
const filtersRef = useFadeInRef();
const howRef = useFadeInRef();
const numbersRef = useFadeInRef();
const ctaRef = useFadeInRef(); const ctaRef = useFadeInRef();
return ( return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative"> <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 }}> <div className="relative" style={{ zIndex: 1 }}>
{/* Hero */} {/* Hero — full-bleed */}
<div className="max-w-3xl mx-auto px-6 pt-12 pb-16 md:pt-20 md:pb-24"> <div
<div ref={heroRef}
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)]"
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-navy-950/60 rounded-2xl p-8 -mx-2" >
> <HexCanvas isDark={theme === 'dark'} />
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4"> {/* Radial teal glow */}
Find where to live, not just what&apos;s for sale <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> </p>
<h1 className="text-3xl md:text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight"> <h1 className="text-3xl md:text-5xl font-extrabold text-white mb-6 leading-[1.1] tracking-tight">
Every neighbourhood Find your{' '}
<span className="text-teal-400">perfect postcode</span>
<br /> <br />
in England &amp; Wales. <span className="text-warm-300">before you find your&nbsp;property.</span>
<br />
<span className="text-teal-600">One map. Your&nbsp;rules.</span>
</h1> </h1>
<p className="text-xl text-warm-600 dark:text-warm-400 mb-8 leading-relaxed max-w-xl"> <p className="text-xl text-warm-300 mb-8 leading-relaxed max-w-xl">
Set the commute, budget, school rating, noise level, and crime threshold you&apos;ll Set the sliders to your expectations and the map highlights the areas that actually
accept. Perfect Postcodes shows you every area that qualifies &mdash; instantly. match. Instantly.
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 mb-12">
<button <button
onClick={onOpenDashboard} 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" 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 Explore the map
</button> </button>
<span className="text-warm-400 text-sm"> <button
No signup &middot; Free &middot; Open data onClick={onOpenPricing}
</span> 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> <div className="flex gap-12 pt-6 border-t border-white/10">
</div> <div>
<div className="text-2xl md:text-3xl font-bold text-white">
{/* The flip */} <TickerValue text="13M" active={statsActive} />
<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> </div>
<div> <div className="text-sm text-warm-400">properties</div>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2"> </div>
With Perfect Postcodes <div>
</h3> <div className="text-2xl md:text-3xl font-bold text-white">
<p className="text-warm-700 dark:text-warm-300 leading-relaxed"> <TickerValue text="56" active={statsActive} />
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> </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>
</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 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"> <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> </h2>
<p className="text-warm-500 dark:text-warm-400 text-center mb-10 max-w-lg mx-auto"> <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> </p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{FILTERS.map((f) => ( {CATEGORIES.map((c) => (
<div <div
key={f.label} key={c.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" 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="text-2xl mb-2">{f.icon}</div> <div className="flex items-start justify-between gap-2">
<div className="font-semibold text-navy-950 dark:text-warm-100 text-sm"> <div className="flex items-center gap-2.5 min-w-0">
{f.label} <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>
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5">{f.example}</div>
</div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
{/* How it works */} {/* Problem / solution / philosophy */}
<div className="max-w-3xl mx-auto px-6 pb-20"> <div className="max-w-4xl mx-auto px-6 pb-20 relative">
<div ref={howRef} className="fade-in-section"> {/* Cereal box — quirky margin note, hidden on narrow screens */}
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center"> <div className="hidden lg:block group absolute -right-44 top-8 cursor-pointer">
Three clicks to clarity <div className="cereal-wobble">
</h2> <img src="/cereal.png" alt="Discounted cereal box" className="w-36 h-auto" />
<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>
))}
</div> </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>
</div>
{/* Numbers */} <div ref={problemRef} className="fade-in-section">
<div className="max-w-3xl mx-auto px-6 pb-20"> <p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
<div ref={numbersRef} className="fade-in-section"> Here&apos;s the problem with property search: listings only show you what&apos;s on
<div className="grid grid-cols-3 gap-6 text-center"> the market{' '}
{STATS.map((s) => ( <strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
<div key={s.label}> &mdash; a thin slice of what an area is actually like. And even if you could look
<div className="text-2xl md:text-3xl font-extrabold text-teal-600">{s.value}</div> beyond them, there are{' '}
<div className="text-sm text-warm-500 dark:text-warm-400 mt-1">{s.label}</div> <strong className="font-semibold text-navy-950 dark:text-warm-100">
</div> millions of postcodes
))} </strong>{' '}
</div> 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>
</div> </div>
{/* Final CTA */} {/* 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"> <div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3"> <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> </h2>
<p className="text-warm-500 dark:text-warm-400 mb-8 max-w-md mx-auto"> <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> </p>
<button <div className="flex items-center justify-center gap-4">
onClick={onOpenDashboard} <button
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" 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> 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>
</div> </div>
{/* Bottom illustration */}
<BottomIllustration isDark={theme === 'dark'} />
</div> </div>
</div> </div>
); );
} }
const FILTERS = [ interface Category {
{ icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' }, icon: string;
{ icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' }, label: string;
{ icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' }, group: string;
{ icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' }, borderClass: string;
{ icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' }, hoverBgClass: string;
{ icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' }, iconBgClass: string;
{ icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' }, artColorClass: string;
{ icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' }, }
];
const STEPS = [ const CATEGORIES: Category[] = [
{ {
title: 'Add your deal-breakers', icon: '\u{1F3E0}',
body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.', label: 'Property',
}, group: 'Property',
{ borderClass: 'border-l-teal-400 dark:border-l-teal-500',
title: 'Spot the clusters', hoverBgClass: 'hover:bg-teal-50/50 dark:hover:bg-teal-900/20',
body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.', iconBgClass: 'bg-teal-100 dark:bg-teal-900/40',
}, artColorClass: 'text-teal-400 dark:text-teal-600',
{
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.',
},
];
const STATS = [ },
{ value: '26M+', label: 'property records' }, {
{ value: '12', label: 'open datasets' }, icon: '\u{1F686}',
{ value: '1.7M', label: 'postcodes mapped' }, 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 { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils'; import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon'; import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
export default function MapLegend({ export default function MapLegend({
featureLabel, featureLabel,
@ -50,8 +51,8 @@ export default function MapLegend({
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200"> <div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? ( {mode === 'density' ? (
<> <>
<span>{formatValue(range[0])}</span> <TickerValue text={formatValue(range[0])} />
<span>{formatValue(range[1])}</span> <TickerValue text={formatValue(range[1])} />
</> </>
) : enumValues && enumValues.length > 0 ? ( ) : enumValues && enumValues.length > 0 ? (
<> <>
@ -60,8 +61,8 @@ export default function MapLegend({
</> </>
) : ( ) : (
<> <>
<span>{formatValue(range[0])}</span> <TickerValue text={formatValue(range[0])} />
<span>{formatValue(range[1])}</span> <TickerValue text={formatValue(range[1])} />
</> </>
)} )}
</div> </div>

View file

@ -143,6 +143,7 @@ function PropertyLoadingSkeleton() {
function PropertyCard({ property }: { property: Property }) { function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price'); 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 pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area'); const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum( const rooms = getNum(
@ -172,6 +173,14 @@ function PropertyCard({ property }: { property: Property }) {
)} )}
</div> </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"> <div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && ( {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 type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon'; import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon'; import { BookmarkIcon } from './icons/BookmarkIcon';
import { MapPinIcon } from './icons/MapPinIcon'; import { LogoIcon } from './icons/LogoIcon';
import { CheckIcon } from './icons/CheckIcon'; import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon'; import { ClipboardIcon } from './icons/ClipboardIcon';
import { MenuIcon } from './icons/MenuIcon'; import { MenuIcon } from './icons/MenuIcon';
@ -12,7 +12,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu'; 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({ export default function Header({
activePage, activePage,
@ -97,7 +97,7 @@ export default function Header({
className="flex items-center gap-2 hover:opacity-80 transition-opacity" className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')} 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> <span className="font-semibold text-lg">Perfect Postcodes</span>
</button> </button>
@ -124,6 +124,9 @@ export default function Header({
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}> <button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ FAQ
</button> </button>
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
</nav> </nav>
)} )}
</div> </div>

View file

@ -82,6 +82,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')} {user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('data-sources', 'Data Sources')} {mobileNavItem('data-sources', 'Data Sources')}
{mobileNavItem('faq', 'FAQ')} {mobileNavItem('faq', 'FAQ')}
{mobileNavItem('pricing', 'Pricing')}
{/* Dashboard actions */} {/* Dashboard actions */}
{activePage === 'dashboard' && ( {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; opacity: 1;
transform: translateY(0); 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 certificates.csv
crime 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( arcgis = (
pl.col("pcds").alias("postcode"), pl.scan_parquet(arcgis_path)
"lat", .filter(pl.col("ctry") == "E92000001") # England only
pl.col("long").alias("lon"), .select(
"lsoa21", pl.col("pcds").alias("postcode"),
"oa21", "lat",
pl.col("long").alias("lon"),
"lsoa21",
"oa21",
)
) )
wide = wide.join(arcgis, on="postcode", how="full", coalesce=True) 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: "", suffix: "",
raw: false, raw: false,
}, },
// FeatureConfig { FeatureConfig {
// name: "Estimated current price", name: "Estimated current price",
// bounds: Bounds::Fixed { bounds: Bounds::Fixed {
// min: 0.0, min: 0.0,
// max: 2_000_000.0, max: 2_000_000.0,
// }, },
// step: 10000.0, step: 10000.0,
// description: "Inflation-adjusted estimate of the current property value", 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.", 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", source: "price-paid",
// prefix: "£", prefix: "£",
// suffix: "", suffix: "",
// raw: false, raw: false,
// }, },
FeatureConfig { FeatureConfig {
name: "Price per sqm", name: "Price per sqm",
bounds: Bounds::Percentile { bounds: Bounds::Percentile {