Progress
This commit is contained in:
parent
5b68c8da04
commit
536fd14378
28 changed files with 1683 additions and 313 deletions
10
Dockerfile
10
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
117
Makefile.data
117
Makefile.data
|
|
@ -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 $@
|
||||
|
|
|
|||
47
README.md
47
README.md
|
|
@ -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 you’ve never tried because it’s 20% off. Your future home is not a box of cereal. Don’t 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. Don’t 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 there’re too many to go through one by one and evaluate them all. You can’t get a complete picture of the property market by looking at current listings. To understand the landscape, you have to look at historical trends. Don’t let the market sway you, anchor your expectations based on the choices millions of other buyers who’ve 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
576
frontend/package-lock.json
generated
576
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
BIN
frontend/public/cereal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal 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
BIN
frontend/public/house.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -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}
|
||||
|
|
|
|||
22
frontend/src/components/home/BottomIllustration.tsx
Normal file
22
frontend/src/components/home/BottomIllustration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/home/CategoryArt.tsx
Normal file
118
frontend/src/components/home/CategoryArt.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
243
frontend/src/components/home/HomeDemo.tsx
Normal file
243
frontend/src/components/home/HomeDemo.tsx
Normal 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)} – {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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 & Wales.
|
||||
<br />
|
||||
<span className="text-teal-600">One map. Your rules.</span>
|
||||
<span className="text-warm-300">before you find your 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'll
|
||||
accept. Perfect Postcodes shows you every area that qualifies — 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 · Free · 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'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 each.
|
||||
That's just three. We've built 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'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's the problem with property search: listings only show you what's on
|
||||
the market{' '}
|
||||
<strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
|
||||
— 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'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 — years of historical transactions and public records,
|
||||
extended with proprietary algorithms so the map doesn'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've set — 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 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',
|
||||
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
69
frontend/src/components/pricing/PricingPage.tsx
Normal file
69
frontend/src/components/pricing/PricingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
39
frontend/src/components/ui/TickerValue.tsx
Normal file
39
frontend/src/components/ui/TickerValue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
21
homepage.md
21
homepage.md
|
|
@ -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
|
||||
1
manual-data/.gitignore
vendored
1
manual-data/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
certificates.csv
|
||||
crime
|
||||
postcode_boundaries
|
||||
|
|
|
|||
89
pipeline/download/tiles.py
Normal file
89
pipeline/download/tiles.py
Normal 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()
|
||||
|
|
@ -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
53
scripts/remove_bg.py
Normal 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)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue