diff --git a/Dockerfile b/Dockerfile index 144b06c..71839f5 100644 --- a/Dockerfile +++ b/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"] diff --git a/Makefile.data b/Makefile.data index d300565..a1cf3e4 100644 --- a/Makefile.data +++ b/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 $@ diff --git a/README.md b/README.md index eecb882..d9c4b9a 100644 --- a/README.md +++ b/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 perfect postcode - - -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. - - - -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 diff --git a/docker-compose.yml b/docker-compose.yml index eda4612..1a5b3b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c749be6..b051336 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 8af6cd0..e83aab9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/cereal.png b/frontend/public/cereal.png new file mode 100644 index 0000000..715d31e Binary files /dev/null and b/frontend/public/cereal.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..d7b72c8 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/house.png b/frontend/public/house.png new file mode 100644 index 0000000..d012170 Binary files /dev/null and b/frontend/public/house.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cff85e1..c74acc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' ? ( - navigateTo('dashboard')} theme={theme} /> + navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} /> ) : activePage === 'data-sources' ? ( ) : activePage === 'faq' ? ( + ) : activePage === 'pricing' ? ( + navigateTo('dashboard')} /> ) : activePage === 'saved-searches' ? ( + + {/* Green hill */} + + {/* Inner shadow for depth */} + + + + {/* House */} + + + + ); +} diff --git a/frontend/src/components/home/CategoryArt.tsx b/frontend/src/components/home/CategoryArt.tsx new file mode 100644 index 0000000..ea5e46c --- /dev/null +++ b/frontend/src/components/home/CategoryArt.tsx @@ -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 ( + + + + + + ); + case 'Transport': + // Converging route lines + return ( + + + + + + + ); + case 'Crime': + // Shield outline + return ( + + + + + ); + case 'Education': + // Mortarboard / books + return ( + + + + + + ); + case 'Amenities': + // Scattered dots (map pins) + return ( + + + + + + + + + ); + case 'Demographics': + // Pie/donut segment + return ( + + + + + + ); + case 'Environment': + // Terrain wave lines + return ( + + + + + + ); + case 'Broadband': + // Signal waves (wifi) + return ( + + + + + + + ); + case 'Deprivation': + // Scale / balance + return ( + + + + + + + + ); + default: + return null; + } +} diff --git a/frontend/src/components/home/HexCanvas.tsx b/frontend/src/components/home/HexCanvas.tsx index 88e63b8..210477b 100644 --- a/frontend/src/components/home/HexCanvas.tsx +++ b/frontend/src/components/home/HexCanvas.tsx @@ -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(null); const hexesRef = useRef([]); 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); diff --git a/frontend/src/components/home/HomeDemo.tsx b/frontend/src/components/home/HomeDemo.tsx new file mode 100644 index 0000000..3f77084 --- /dev/null +++ b/frontend/src/components/home/HomeDemo.tsx @@ -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([]); + const [sliderValues, setSliderValues] = useState>({}); + const [activeFeature, setActiveFeature] = useState(null); + const [dragValue, setDragValue] = useState<[number, number] | null>(null); + const [dragHexData, setDragHexData] = useState(null); + const fetchTimeoutRef = useRef>(); + const abortRef = useRef(); + const dragAbortRef = useRef(); + const activeFeatureRef = useRef(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 = {}; + 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 ( +
+ {/* Map */} +
+
+
+ +
+ {/* Colour spectrum legend */} +
+
+
+ {activeFeature ? viewMeta?.name || activeFeature : 'Property density'} +
+
+ {colorRange && ( +
+ + +
+ )} +
+
+
+ + {/* Sliders */} +
+ {demoFeatures.map((feature) => { + const value = sliderValues[feature.name]; + if (!value || feature.min == null || feature.max == null) return null; + const isActive = activeFeature === feature.name; + return ( +
+
+ + {feature.name} + + + {formatValue(value[0], feature)} – {formatValue(value[1], feature)} + +
+ handleSliderChange(feature.name, [min, max])} + onPointerDown={() => handleDragStart(feature.name)} + onPointerUp={() => handleDragEnd()} + /> +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 9474261..bd47980 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -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(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 (
- -
- {/* Hero */} -
-
-

- Find where to live, not just what's for sale + {/* Hero — full-bleed */} +

+ + {/* Radial teal glow */} +
+
+

+ Browsing listings is not a strategy. Knowing what you want is.

-

- Every neighbourhood +

+ Find your{' '} + perfect postcode
- in England & Wales. -
- One map. Your rules. + before you find your property.

-

- Set the commute, budget, school rating, noise level, and crime threshold you'll - accept. Perfect Postcodes shows you every area that qualifies — instantly. +

+ Set the sliders to your expectations and the map highlights the areas that actually + match. Instantly.

-
+
- - No signup · Free · Open data - +
-
-
- - {/* The flip */} -
-
-
-
-
-

- The old way -

-

- 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. -

+
+
+
+
-
-

- With Perfect Postcodes -

-

- 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. -

+
properties
+
+
+
+
+
data layers
+
+
+
Every
+
postcode in England
- {/* Filter showcase */} + {/* Map + Slider demo */} +
+
+

+ See it in action +

+

+ Drag the sliders and watch the map respond. Every postcode scored, every filter instant. +

+
+ +
+
+
+ + {/* Scale — "That's just two" + category cards */}
-
+

- 12 datasets. One slider each. + That's just three. We've built 43.

- 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.

-
- {FILTERS.map((f) => ( -
-
{f.icon}
-
- {f.label} +
+ {CATEGORIES.map((c) => ( +
+
+
+
+ {c.icon} +
+ + {c.label} + +
+ +
-
{f.example}
-
))}
- {/* How it works */} -
-
-

- Three clicks to clarity -

-
- {STEPS.map((step, i) => ( -
- - {i + 1} - -
-

- {step.title} -

-

{step.body}

-
-
- ))} + {/* Problem / solution / philosophy */} +
+ {/* Cereal box — quirky margin note, hidden on narrow screens */} +
+
+ Discounted cereal box
+

+ Your home is not a box of cereal. Don't let a discount on the wrong + property distract you from finding the right one. +

-
- {/* Numbers */} -
-
-
- {STATS.map((s) => ( -
-
{s.value}
-
{s.label}
-
- ))} -
+
+

+ 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 — not ones the market set for you. +

{/* Final CTA */} -
+

- Ready to narrow it down? + The biggest financial decision of your life +
+ deserves proper tools behind it.

- 100% open data. No account required. Just set your filters and go. + One payment, lifetime access. Set your filters and go.

- +
+ + +
+ + {/* Bottom illustration */} +
); } -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', + + }, ]; diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx index d440d99..0531b35 100644 --- a/frontend/src/components/map/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend.tsx @@ -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({
{mode === 'density' ? ( <> - {formatValue(range[0])} - {formatValue(range[1])} + + ) : enumValues && enumValues.length > 0 ? ( <> @@ -60,8 +61,8 @@ export default function MapLegend({ ) : ( <> - {formatValue(range[0])} - {formatValue(range[1])} + + )}
diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index 6c64e4f..23d898d 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -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 }) { )}
)} + {estimatedPrice !== undefined && ( +
+ Est. value:{' '} + + £{formatNumber(estimatedPrice)} + +
+ )}
{property.property_type && ( diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx new file mode 100644 index 0000000..d944f82 --- /dev/null +++ b/frontend/src/components/pricing/PricingPage.tsx @@ -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 ( +
+
+
+

+ One price. Yours forever. +

+

+ No subscriptions, no recurring fees. Pay once and get lifetime access to every feature. +

+
+ +
+ {/* Price header */} +
+
+ Lifetime License +
+
+ £100 + /once +
+

+ One-time payment, no subscription +

+
+ + {/* Features list */} +
+
    + {FEATURES.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + +

+ 30-day money-back guarantee +

+
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 1a6168d..465b18e 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -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')} > - + Perfect Postcodes @@ -124,6 +124,9 @@ export default function Header({ + )}
diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx index 742a539..98d4dc5 100644 --- a/frontend/src/components/ui/MobileMenu.tsx +++ b/frontend/src/components/ui/MobileMenu.tsx @@ -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' && ( diff --git a/frontend/src/components/ui/TickerValue.tsx b/frontend/src/components/ui/TickerValue.tsx new file mode 100644 index 0000000..e144499 --- /dev/null +++ b/frontend/src/components/ui/TickerValue.tsx @@ -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 {char}; + + const offset = active ? -idx * H : 0; + + return ( + + + {DIGITS.split('').map((d) => ( + + {d} + + ))} + + + ); +} + +export function TickerValue({ text, active = true }: { text: string; active?: boolean }) { + const chars = text.split(''); + const len = chars.length; + return ( + + {chars.map((ch, i) => ( + + ))} + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index c1c10ce..00a6f87 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; +} + diff --git a/homepage.md b/homepage.md deleted file mode 100644 index d1e825c..0000000 --- a/homepage.md +++ /dev/null @@ -1,21 +0,0 @@ -(above title) Browsing listings is not a strategy. Knowing what you want is. - -(title) Find your perfect postcode before you find your property. - -Set the sliders to your expectations and the map highlights the areas that actually match. Instantly. - - - -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 diff --git a/manual-data/.gitignore b/manual-data/.gitignore index 40d8742..9361c39 100644 --- a/manual-data/.gitignore +++ b/manual-data/.gitignore @@ -1,2 +1,3 @@ certificates.csv crime +postcode_boundaries diff --git a/pipeline/download/tiles.py b/pipeline/download/tiles.py new file mode 100644 index 0000000..e0302c1 --- /dev/null +++ b/pipeline/download/tiles.py @@ -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() diff --git a/pipeline/transform/merge.py b/pipeline/transform/merge.py index 3dfb621..bdbea53 100644 --- a/pipeline/transform/merge.py +++ b/pipeline/transform/merge.py @@ -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) diff --git a/scripts/remove_bg.py b/scripts/remove_bg.py new file mode 100644 index 0000000..4bf87d8 --- /dev/null +++ b/scripts/remove_bg.py @@ -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 [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) diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs index 007f3c8..e9fc688 100644 --- a/server-rs/src/features.rs +++ b/server-rs/src/features.rs @@ -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 {