This commit is contained in:
Andras Schmelczer 2026-02-18 21:22:15 +00:00
parent 524580eb25
commit ffe080adef
82 changed files with 2652 additions and 2956 deletions

12
Dockerfile.finder Normal file
View file

@ -0,0 +1,12 @@
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY finder/pyproject.toml ./
RUN uv pip install --system -r pyproject.toml
COPY finder/*.py ./
COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet
CMD ["python3", "main.py"]

View file

@ -1,7 +1,7 @@
# Data pipeline — download sources and build wide.parquet
# Data pipeline — download sources and build postcode.parquet + properties.parquet
#
# Usage:
# make -f Makefile.data prepare # Build wide.parquet (+ all deps)
# make -f Makefile.data prepare # Build all parquets (+ all deps)
# make -f Makefile.data tiles # Download UK map tiles
#
# Or include from the main Makefile and use targets directly.
@ -22,7 +22,9 @@ POIS_RAW := $(DATA_DIR)/uk_pois.parquet
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
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
MERGE_STAMP := $(DATA_DIR)/.merge_done
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/certificates.csv
@ -48,9 +50,8 @@ TRANSIT_STAMP := $(TRANSIT_DIR)/.done
GREENSPACE := $(DATA_DIR)/greenspace_water.parquet
PBF := $(DATA_DIR)/great-britain-latest.osm.pbf
PLACES := $(DATA_DIR)/places.parquet
RIGHTMOVE_BUY := $(DATA_DIR)/rightmove_buy.parquet
RIGHTMOVE_RENT := $(DATA_DIR)/rightmove_rent.parquet
ONLINE_STAMP := $(DATA_DIR)/.online_done
LISTINGS_BUY := $(DATA_DIR)/online_listings_buy.parquet
LISTINGS_RENT := $(DATA_DIR)/online_listings_rent.parquet
# Sentinel files for directory targets (Make doesn't track directories well)
GEOSURE_STAMP := $(GEOSURE_DIR)/.done
@ -60,7 +61,7 @@ PMTILES_VERSION := 1.22.3
# ── Phony aliases ─────────────────────────────────────────────────────────────
.PHONY: prepare wide tiles \
.PHONY: prepare merge tiles \
download-arcgis download-price-paid download-deprivation download-ethnicity \
download-naptan download-pois download-ofsted download-broadband download-rental-prices \
download-postcodes download-geosure download-noise download-inspire \
@ -70,8 +71,8 @@ PMTILES_VERSION := 1.22.3
generate-postcode-boundaries \
journey-times
prepare: $(DATA_DIR)/.prices_done
wide: $(WIDE)
prepare: $(PRICES_STAMP)
merge: $(MERGE_STAMP)
tiles: $(TILES)
download-arcgis: $(ARCGIS)
download-price-paid: $(PRICE_PAID)
@ -253,9 +254,9 @@ $(PC_BOUNDARIES):
@echo ""
@exit 1
# ── Final merge ───────────────────────────────────────────────────────────────
# ── Final merge → postcode.parquet + properties.parquet ──────────────────────
$(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL)
uv run python -m pipeline.transform.merge \
--epc-pp $(EPC_PP) \
@ -271,22 +272,15 @@ $(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA)
--broadband $(BROADBAND) \
--geosure $(GEOSURE) \
--rental-prices $(RENTAL) \
--output $@
# ── Online listings (post-merge, pre-pricing) ───────────────────────────────
$(ONLINE_STAMP): $(WIDE) $(RIGHTMOVE_BUY) $(RIGHTMOVE_RENT)
uv run python -m pipeline.transform.add_online_listings \
--input $(WIDE) \
--buy $(RIGHTMOVE_BUY) \
--rent $(RIGHTMOVE_RENT)
--output-postcodes $(POSTCODES_PQ) \
--output-properties $(PROPERTIES_PQ)
@touch $@
# ── Price estimation (post-merge + online) ──────────────────────────────────
# ── Price estimation (post-merge) ───────────────────────────────────────────
$(PRICE_INDEX): $(ONLINE_STAMP)
uv run python -m pipeline.transform.price_estimation.index --input $(WIDE) --output $@
$(PRICE_INDEX): $(MERGE_STAMP)
uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@
$(PRICES_STAMP): $(ONLINE_STAMP) $(PRICE_INDEX)
uv run python -m pipeline.transform.price_estimation.estimate --input $(WIDE) --index $(PRICE_INDEX)
$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX)
uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX)
@touch $@

100
README.md
View file

@ -30,16 +30,8 @@ https://xploria.co.uk/data-sources/
---
- fix frontend
- map hexagons
- stripe
- update texts
- move data to raid
- extract all user-facing texts into a yaml file for easy editing
- register for email
FAQ:
- Why hexagons?
- Why the price tag?
@ -50,54 +42,10 @@ FAQ:
make -f Makefile.data prepare
make -f Makefile.data tiles
## outstadning prompts
Add licensing to the app. By default, anonymous users can use the map but only in central london. if they try zooming out, the server refuses to provide data and the users will be prompted to buy a lifetime license to continue (or zoom back in). Just before buying a license, they have to register by providing their email address and password, then they need to complete the stripe check out workflow. Implement the full pocketbase/server/frontend integration. For admins, give an option to generate an invite link, opening which prompts you to register and gives you a free license forever. Have a cool animation with party poppers on the successful acquiring of a license. For non-admin users, allow inviting friends for 30% off the price. Also add a support page that shows my email address, and add a FAQ on the same page too.
-
- add blue/green rollout
- fully remove the AI summary
- show all the info about listings
- put the travel times into categories
- move the support page under Learn and merge the FAQs
- add an account page and merge it with Saved
- padding between email and resend verification
- rename historical/bu/rent
- the hero page with the floating card is ugly, make them better integrated
- make the active filters much bigger on the demo page
- make the pricing tiers different cards next to each other
- hide the pricing if you're logged in
- make the loading more obvious and in the middle of the map
- the typeahead no longer works. I see the requests but nothing shows up
- make hero 100% height
- unsquish hexagons
- Get lifetime access
- "let's see an example with arrow down"
- make demo filters adjustable
- add next button to cards
@ -108,18 +56,44 @@ Add licensing to the app. By default, anonymous users can use the map but only
- start epxloring should bring to dashboard
- get lifetime access should say you have it already
- density -> number properties
- make price history a rolling avg
- zoom in at the end of the demo
- referal link is broken
- remove random wales/ir/scotland areas from rightmove
- load test
- saved search Only superusers can perform this action.
imrpove walkthrough
load tests with grafana
house reposession
## execution
enum colour coding
Better school searchs
save -> dashboard
fix links to markets,
404,
Jittery slider number label
Odd vertical spacing on mobile
Show even number of cards on mobile
Construction age is spaced oit
Make prop density smaller
Test on safari
Test on android
check rendered index html,
only support new finder.py parquet type

View file

@ -10,7 +10,7 @@ services:
command: >
bash -c "
cargo install cargo-watch &&
cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times'
cargo watch -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.parquet --listings-buy /app/data/online_listings_buy.parquet --listings-rent /app/data/online_listings_rent.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times'
"
ports:
- "8001:8001"
@ -38,9 +38,9 @@ services:
STRIPE_REFERRAL_COUPON_ID: L5uQqagl
GOOGLE_OAUTH_CLIENT_ID: 536485512604-740bbn3tf027ogrdcr5sqor4ntorkaqv.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET: 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
APPLE_OAUTH_CLIENT_ID: ${APPLE_OAUTH_CLIENT_ID}
APPLE_OAUTH_CLIENT_SECRET: ${APPLE_OAUTH_CLIENT_SECRET}
depends_on:
screenshot:
condition: service_healthy
pocketbase:
condition: service_healthy
@ -107,46 +107,46 @@ services:
retries: 3
start_period: 5s
gluetun:
image: qmcgaw/gluetun:v3.40.4
volumes:
- gluetun-cache-v2:/gluetun
- gluetun-auth:/gluetun/auth:ro
environment:
# See https://github.com/qdm12/gluetun-wiki/tree/main/setup#setup
VPN_SERVICE_PROVIDER: mullvad
VPN_TYPE: wireguard
WIREGUARD_PRIVATE_KEY: "8FFKmtTvDsZlShnKl/opDDwCwb9v2ox4+Kkl3wX+9Gw="
WIREGUARD_ADDRESSES: "10.66.109.86/32"
OWNED_ONLY: "yes"
UPDATER_PERIOD: 24h
SERVER_COUNTRIES: Serbia,Slovakia,Croatia,Austria,Denmark,Finland
TZ: $TIME_ZONE
restart: unless-stopped
ports:
- "1234:1234"
healthcheck:
test: "wget -q https://www.google.com || exit 1"
interval: 1m
timeout: 15s
retries: 2
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
# gluetun:
# image: qmcgaw/gluetun:v3.40.4
# volumes:
# - gluetun-cache-v2:/gluetun
# - gluetun-auth:/gluetun/auth:ro
# environment:
# # See https://github.com/qdm12/gluetun-wiki/tree/main/setup#setup
# VPN_SERVICE_PROVIDER: mullvad
# VPN_TYPE: wireguard
# WIREGUARD_PRIVATE_KEY: "8FFKmtTvDsZlShnKl/opDDwCwb9v2ox4+Kkl3wX+9Gw="
# WIREGUARD_ADDRESSES: "10.66.109.86/32"
# OWNED_ONLY: "yes"
# UPDATER_PERIOD: 24h
# SERVER_COUNTRIES: Serbia,Slovakia,Croatia,Austria,Denmark,Finland
# TZ: $TIME_ZONE
# restart: unless-stopped
# ports:
# - "1234:1234"
# healthcheck:
# test: "wget -q https://www.google.com || exit 1"
# interval: 1m
# timeout: 15s
# retries: 2
# cap_add:
# - NET_ADMIN
# devices:
# - /dev/net/tun:/dev/net/tun
finder:
build: ./finder
init: true
network_mode: service:gluetun
volumes:
- ./finder:/app
- ./property-data/arcgis_data.parquet:/data/arcgis_data.parquet:ro
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
# finder:
# build:
# context: .
# dockerfile: Dockerfile.finder
# init: true
# network_mode: service:gluetun
# volumes:
# - ./finder:/app
# depends_on:
# gluetun:
# condition: service_healthy
# restart: unless-stopped
volumes:

View file

@ -7,5 +7,6 @@ COPY pyproject.toml ./
RUN uv pip install --system -r pyproject.toml
COPY *.py ./
COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet
CMD ["python3", "main.py"]

View file

@ -11,6 +11,11 @@ RETRY_BASE_DELAY = 2.0
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
SEED = 42
# Schedule: hour of day (UTC) to auto-run scrape. Set to -1 to disable.
SCHEDULE_HOUR = int(os.environ.get("SCHEDULE_HOUR", "3"))
# Whether to run a scrape immediately on startup
RUN_ON_STARTUP = os.environ.get("RUN_ON_STARTUP", "true").lower() in ("1", "true", "yes")
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"

View file

@ -28329,3 +28329,27 @@
* Running on http://127.0.0.1:1234
* Running on http://10.66.109.86:1234
2026-02-15 22:37:52,025 [INFO] Press CTRL+C to quit
2026-02-15 23:00:08,987 [INFO] Loading arcgis data...
2026-02-15 23:00:08,988 [INFO] Loading outcodes from /data/arcgis_data.parquet
2026-02-15 23:00:09,078 [INFO] England postcodes: 2260065
2026-02-15 23:00:09,118 [INFO] Unique England outcodes: 2323
2026-02-15 23:00:09,118 [INFO] Building postcode spatial index from /data/arcgis_data.parquet
2026-02-15 23:00:10,418 [INFO] Postcode spatial index: 113226 cells, 2260065 postcodes
2026-02-15 23:00:10,434 [INFO] Ready — 2323 outcodes, postcode index built
2026-02-15 23:00:10,446 [INFO] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:1234
* Running on http://10.66.109.86:1234
2026-02-15 23:00:10,446 [INFO] Press CTRL+C to quit
2026-02-16 19:56:56,857 [INFO] Loading arcgis data...
2026-02-16 19:56:56,857 [INFO] Loading outcodes from /data/arcgis_data.parquet
2026-02-16 19:56:57,061 [INFO] England postcodes: 2260065
2026-02-16 19:56:57,161 [INFO] Unique England outcodes: 2323
2026-02-16 19:56:57,162 [INFO] Building postcode spatial index from /data/arcgis_data.parquet
2026-02-16 19:57:00,146 [INFO] Postcode spatial index: 113226 cells, 2260065 postcodes
2026-02-16 19:57:00,227 [INFO] Ready — 2323 outcodes, postcode index built
2026-02-16 19:57:00,247 [INFO] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:1234
* Running on http://10.66.109.86:1234
2026-02-16 19:57:00,248 [INFO] Press CTRL+C to quit

View file

@ -1,12 +1,13 @@
import logging
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from flask import Flask, Response, jsonify, send_from_directory
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
from constants import DATA_DIR
from constants import DATA_DIR, RUN_ON_STARTUP, SCHEDULE_HOUR
from rightmove import outcode_cache
from scraper import (
_sync_gauges,
@ -46,6 +47,52 @@ OUTCODES = load_outcodes()
PC_INDEX = build_postcode_index()
log.info("Ready — %d outcodes, postcode index built", len(OUTCODES))
# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------
def _start_scrape() -> bool:
"""Try to start a scrape. Returns True if started, False if already running."""
with status_lock:
if status.state == "running":
return False
status.state = "running"
thread = threading.Thread(target=run_scrape, args=(OUTCODES, PC_INDEX), daemon=True)
thread.start()
return True
def _seconds_until(hour: int) -> float:
"""Seconds from now until the next occurrence of `hour`:00 UTC."""
now = datetime.now(timezone.utc)
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return (target - now).total_seconds()
def _scheduler_loop() -> None:
"""Background thread that triggers a daily scrape at SCHEDULE_HOUR UTC."""
log.info("Scheduler active — will run daily at %02d:00 UTC", SCHEDULE_HOUR)
while True:
wait = _seconds_until(SCHEDULE_HOUR)
log.info("Next scheduled scrape in %.0f seconds (%.1f hours)", wait, wait / 3600)
time.sleep(wait)
log.info("Scheduled scrape triggered")
if not _start_scrape():
log.warning("Scheduled scrape skipped — already running")
if RUN_ON_STARTUP:
log.info("RUN_ON_STARTUP=true — starting initial scrape")
_start_scrape()
if SCHEDULE_HOUR >= 0:
scheduler = threading.Thread(target=_scheduler_loop, daemon=True)
scheduler.start()
# ---------------------------------------------------------------------------
# Flask app
# ---------------------------------------------------------------------------
@ -55,14 +102,9 @@ app = Flask(__name__)
@app.route("/run", methods=["POST"])
def trigger_run():
with status_lock:
if status.state == "running":
return jsonify({"error": "Scrape already running"}), 409
status.state = "running"
thread = threading.Thread(target=run_scrape, args=(OUTCODES, PC_INDEX), daemon=True)
thread.start()
if _start_scrape():
return jsonify({"message": "Scrape started"}), 200
return jsonify({"error": "Scrape already running"}), 409
@app.route("/status")
@ -72,7 +114,7 @@ def get_status():
if status.started_at:
end = status.finished_at if status.finished_at else time.time()
elapsed = end - status.started_at
return jsonify({
resp = {
"state": status.state,
"channel": status.channel,
"outcode": status.outcode,
@ -82,7 +124,10 @@ def get_status():
"properties_rent": status.properties_rent,
"errors": status.errors[-20:], # last 20 errors
"elapsed_seconds": round(elapsed, 1),
})
}
if SCHEDULE_HOUR >= 0:
resp["next_scrape_in_seconds"] = round(_seconds_until(SCHEDULE_HOUR))
return jsonify(resp)
@app.route("/debug")

View file

@ -159,8 +159,8 @@ def run_scrape(outcodes: list[str], pc_index: PostcodeSpatialIndex) -> None:
# Write parquet
deduped = list(all_properties.values())
output_path = DATA_DIR / f"rightmove_{file_suffix}.parquet"
write_parquet(deduped, output_path)
output_path = DATA_DIR / f"online_listings_{file_suffix}.parquet"
write_parquet(deduped, output_path, channel=file_suffix)
with status_lock:
if channel_name == "BUY":

View file

@ -1,63 +1,94 @@
import logging
from datetime import datetime
from pathlib import Path
import polars as pl
from transform import normalize_price
log = logging.getLogger("rightmove")
def write_parquet(properties: list[dict], path: Path) -> None:
"""Write properties list to parquet using Polars."""
def write_parquet(properties: list[dict], path: Path, channel: str) -> None:
"""Write properties list to parquet with server-ready column names.
channel: "buy" or "rent"
"""
if not properties:
log.warning("No properties to write to %s", path)
return
# Parse first_visible_date to datetime
listing_dates = []
for p in properties:
fvd = p.get("first_visible_date", "")
if fvd:
try:
dt = datetime.fromisoformat(fvd.replace("Z", "+00:00"))
listing_dates.append(dt.replace(tzinfo=None))
except (ValueError, TypeError):
listing_dates.append(None)
else:
listing_dates.append(None)
# Derive asking price / asking rent based on channel
if channel == "buy":
asking_prices = [p["price"] for p in properties]
asking_rents = [None] * len(properties)
listing_statuses = ["For sale"] * len(properties)
else:
asking_prices = [None] * len(properties)
asking_rents = [
normalize_price(p["price"], p["price_frequency"]) for p in properties
]
listing_statuses = ["For rent"] * len(properties)
df = pl.DataFrame(
{
"id": [p["id"] for p in properties],
"bedrooms": [p["bedrooms"] for p in properties],
"bathrooms": [p["bathrooms"] for p in properties],
"total_rooms": [p["total_rooms"] for p in properties],
"longitude": [p["longitude"] for p in properties],
"latitude": [p["latitude"] for p in properties],
"postcode": [p["postcode"] for p in properties],
"address": [p["address"] for p in properties],
"tenure": [p["tenure"] for p in properties],
"property_type": [p["property_type"] for p in properties],
"property_sub_type": [p["property_sub_type"] for p in properties],
"price": [p["price"] for p in properties],
"price_frequency": [p["price_frequency"] for p in properties],
"price_qualifier": [p["price_qualifier"] for p in properties],
"floorspace_sqm": [p["floorspace_sqm"] for p in properties],
"url": [p["url"] for p in properties],
"features": [p["features"] for p in properties],
"first_visible_date": [p["first_visible_date"] for p in properties],
"update_date": [p["update_date"] for p in properties],
"outcode": [p["outcode"] for p in properties],
"house_share": [p["house_share"] for p in properties],
"Bedrooms": [p["Bedrooms"] for p in properties],
"Bathrooms": [p["Bathrooms"] for p in properties],
"Number of bedrooms & living rooms": [
p["Number of bedrooms & living rooms"] for p in properties
],
"lon": [p["lon"] for p in properties],
"lat": [p["lat"] for p in properties],
"Postcode": [p["Postcode"] for p in properties],
"Address per Property Register": [
p["Address per Property Register"] for p in properties
],
"Leashold/Freehold": [p["Leashold/Freehold"] for p in properties],
"Property type": [p["Property type"] for p in properties],
"Property sub-type": [p["Property sub-type"] for p in properties],
"Price qualifier": [p["Price qualifier"] for p in properties],
"Total floor area (sqm)": [
p["Total floor area (sqm)"] for p in properties
],
"Listing URL": [p["Listing URL"] for p in properties],
"Listing features": [p["Listing features"] for p in properties],
"Listing date": listing_dates,
"Listing status": listing_statuses,
"Asking price": asking_prices,
"Asking rent (monthly)": asking_rents,
},
schema={
"id": pl.Int64,
"bedrooms": pl.Int32,
"bathrooms": pl.Int32,
"total_rooms": pl.Int32,
"longitude": pl.Float64,
"latitude": pl.Float64,
"postcode": pl.Utf8,
"address": pl.Utf8,
"tenure": pl.Utf8,
"property_type": pl.Utf8,
"property_sub_type": pl.Utf8,
"price": pl.Int64,
"price_frequency": pl.Utf8,
"price_qualifier": pl.Utf8,
"floorspace_sqm": pl.Float64,
"url": pl.Utf8,
"features": pl.List(pl.Utf8),
"first_visible_date": pl.Utf8,
"update_date": pl.Utf8,
"outcode": pl.Utf8,
"house_share": pl.Boolean,
"Bedrooms": pl.Int32,
"Bathrooms": pl.Int32,
"Number of bedrooms & living rooms": pl.Int32,
"lon": pl.Float64,
"lat": pl.Float64,
"Postcode": pl.Utf8,
"Address per Property Register": pl.Utf8,
"Leashold/Freehold": pl.Utf8,
"Property type": pl.Utf8,
"Property sub-type": pl.Utf8,
"Price qualifier": pl.Utf8,
"Total floor area (sqm)": pl.Float64,
"Listing URL": pl.Utf8,
"Listing features": pl.List(pl.Utf8),
"Listing date": pl.Datetime("us"),
"Listing status": pl.Utf8,
"Asking price": pl.Int64,
"Asking rent (monthly)": pl.Int64,
},
)

View file

@ -98,27 +98,27 @@ def transform_property(prop: dict, outcode: str, pc_index: PostcodeSpatialIndex)
update_date = listing_update.get("listingUpdateDate", "")
postcode = pc_index.nearest(lat, lng)
if not postcode:
log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng)
return None
return {
"id": prop.get("id"),
"bedrooms": bedrooms,
"bathrooms": bathrooms,
"total_rooms": bedrooms + bathrooms,
"longitude": lng,
"latitude": lat,
"postcode": postcode,
"address": prop.get("displayAddress", ""),
"tenure": extract_tenure(prop.get("tenure")),
"property_type": map_property_type(sub_type),
"property_sub_type": sub_type or "Unknown",
"Bedrooms": bedrooms,
"Bathrooms": bathrooms,
"Number of bedrooms & living rooms": bedrooms + bathrooms,
"lon": lng,
"lat": lat,
"Postcode": postcode,
"Address per Property Register": prop.get("displayAddress", ""),
"Leashold/Freehold": extract_tenure(prop.get("tenure")),
"Property type": map_property_type(sub_type),
"Property sub-type": sub_type or "Unknown",
"price": price,
"price_frequency": frequency,
"price_qualifier": price_qualifier,
"floorspace_sqm": parse_display_size(prop.get("displaySize")),
"url": RIGHTMOVE_BASE + prop.get("propertyUrl", ""),
"features": key_features,
"Price qualifier": price_qualifier,
"Total floor area (sqm)": parse_display_size(prop.get("displaySize")),
"Listing URL": RIGHTMOVE_BASE + prop.get("propertyUrl", ""),
"Listing features": key_features,
"first_visible_date": prop.get("firstVisibleDate", ""),
"update_date": update_date,
"outcode": outcode,
"house_share": sub_type == "House Share",
}

View file

@ -2,11 +2,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import MapPage, { type ExportState } from './components/map/MapPage';
import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import LearnPage from './components/learn/LearnPage';
import AccountPage from './components/account/AccountPage';
import InvitePage from './components/invite/InvitePage';
import SupportPage from './components/support/SupportPage';
import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
@ -31,8 +29,6 @@ function pageToPath(page: Page, inviteCode?: string): string {
switch (page) {
case 'dashboard':
return '/dashboard';
case 'saved-searches':
return '/saved';
case 'learn':
return '/learn';
case 'pricing':
@ -41,8 +37,6 @@ function pageToPath(page: Page, inviteCode?: string): string {
return '/account';
case 'invite':
return `/invite/${inviteCode || ''}`;
case 'support':
return '/support';
default:
return '/';
}
@ -50,11 +44,11 @@ function pageToPath(page: Page, inviteCode?: string): string {
function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null {
if (pathname === '/dashboard') return { page: 'dashboard' };
if (pathname === '/saved') return { page: 'saved-searches' };
if (pathname === '/saved') return { page: 'account' };
if (pathname === '/learn') return { page: 'learn' };
if (pathname === '/pricing') return { page: 'pricing' };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'support' };
if (pathname === '/support') return { page: 'learn' };
if (pathname.startsWith('/invite/')) {
const code = pathname.slice('/invite/'.length);
return { page: 'invite', inviteCode: code };
@ -79,12 +73,9 @@ export default function App() {
return params.get('og') === '1';
}, []);
// Core data
const [features, setFeatures] = useState<FeatureMeta[]>([]);
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
const [initialLoading, setInitialLoading] = useState(true);
// UI state
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(null);
const [activePage, setActivePage] = useState<Page>(() => {
@ -100,7 +91,6 @@ export default function App() {
return 'home';
});
// Initialize invite code from URL
useEffect(() => {
const fromPath = pathToPage(window.location.pathname);
if (fromPath?.inviteCode) {
@ -128,7 +118,6 @@ export default function App() {
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
const [verificationDismissed, setVerificationDismissed] = useState(false);
// Handle license_success query param (redirect from Stripe)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('license_success') === '1') {
@ -145,7 +134,6 @@ export default function App() {
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
// Load features and POI categories on mount
useEffect(() => {
const controller = new AbortController();
let featuresLoaded = false;
@ -181,9 +169,6 @@ export default function App() {
return () => controller.abort();
}, []);
// Screenshot mode ready signal — MapPage sets __screenshot_ready once map data loads
// Navigation
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
@ -219,14 +204,25 @@ export default function App() {
return () => window.removeEventListener('popstate', handlePopState);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch saved searches when page becomes active
const { fetchSearches } = savedSearches;
useEffect(() => {
if (activePage === 'saved-searches') {
if (activePage === 'account') {
fetchSearches();
}
}, [activePage, fetchSearches]);
useEffect(() => {
if (authLoading) return;
if (activePage === 'account' && !user) {
setAuthModalTab('login');
setShowAuthModal(true);
navigateTo('home');
}
if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) {
navigateTo('dashboard');
}
}, [activePage, user, authLoading, navigateTo]);
const [exportState, setExportState] = useState<ExportState | null>(null);
if (isScreenshotMode) {
@ -241,8 +237,8 @@ export default function App() {
initialLoading={initialLoading}
theme={theme}
pendingInfoFeature={null}
onClearPendingInfoFeature={() => {}}
onNavigateTo={() => {}}
onClearPendingInfoFeature={() => { }}
onNavigateTo={() => { }}
screenshotMode
ogMode={isOgMode}
initialTravelTime={urlState.travelTime}
@ -273,7 +269,7 @@ export default function App() {
onLogout={logout}
isMobile={isMobile}
/>
{user && !user.verified && !verificationDismissed && (
{user && !user.verified && !verificationDismissed && activePage === 'account' && (
<VerificationBanner
email={user.email}
onRequestVerification={requestVerification}
@ -281,8 +277,8 @@ export default function App() {
/>
)}
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'pricing' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} hidePricing={user?.subscription === 'licensed' || user?.isAdmin} />
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
<PricingPage
onOpenDashboard={() => navigateTo('dashboard')}
user={user}
@ -298,9 +294,17 @@ export default function App() {
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'account' && user ? (
<AccountPage user={user} onRefreshAuth={refreshAuth} onRequestVerification={requestVerification} />
) : activePage === 'support' ? (
<SupportPage />
<AccountPage
user={user}
onRefreshAuth={refreshAuth}
onRequestVerification={requestVerification}
searches={savedSearches.searches}
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onOpenSearch={(params) => {
window.location.href = `/?${params}`;
}}
/>
) : activePage === 'invite' && inviteCode ? (
<InvitePage
code={inviteCode}
@ -318,15 +322,6 @@ export default function App() {
refreshAuth();
}}
/>
) : activePage === 'saved-searches' ? (
<SavedSearchesPage
searches={savedSearches.searches}
loading={savedSearches.loading}
onDelete={savedSearches.deleteSearch}
onOpen={(params) => {
window.location.href = `/?${params}`;
}}
/>
) : (
<MapPage
features={features}

View file

@ -1,9 +1,18 @@
import { useState } from 'react';
import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
import { formatRelativeTime } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { TrashIcon } from '../ui/icons/TrashIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TabButton } from '../ui/TabButton';
type AccountTab = 'saved' | 'settings';
const SUBSCRIPTION_OPTIONS = ['free', 'licensed'] as const;
@ -12,7 +21,180 @@ const SUBSCRIPTION_LABELS: Record<string, string> = {
licensed: 'Licensed',
};
export default function AccountPage({
function SavedSearchesContent({
searches,
loading,
onDelete,
onOpen,
}: {
searches: SavedSearch[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onOpen: (params: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return;
await onDelete(deleteConfirmId);
setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]);
const copyToClipboard = useCallback((text: string, id: string) => {
const onSuccess = () => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}, []);
const handleShare = useCallback(async (params: string, id: string) => {
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
copyToClipboard(shortUrl, id);
} catch {
copyToClipboard(`${window.location.origin}/?${params}`, id);
} finally {
setSharingId(null);
}
}, [copyToClipboard]);
return (
<>
{loading ? (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
) : searches.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
No saved searches yet
</p>
<p className="text-sm text-warm-500 dark:text-warm-500">
Save your dashboard filters and view to quickly return to them later.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{searches.map((search) => (
<div
key={search.id}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
>
{search.screenshotUrl ? (
<img
src={search.screenshotUrl}
alt={search.name}
className="w-full h-36 object-cover"
/>
) : (
<div className="w-full h-36 bg-gradient-to-br from-teal-600/20 to-navy-900/30 dark:from-teal-400/10 dark:to-navy-900/40 flex items-center justify-center">
<BookmarkIcon className="w-10 h-10 text-warm-300 dark:text-warm-600" />
</div>
)}
<div className="p-4">
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
{search.name}
</h3>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
{summarizeParams(search.params)}
</p>
<div className="flex gap-2">
<button
onClick={() => onOpen(search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
Open
</button>
<button
onClick={() => handleShare(search.params, search.id)}
disabled={sharingId === search.id}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-50 disabled:cursor-wait"
>
{sharingId === search.id ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : copiedId === search.id ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => setDeleteConfirmId(search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Delete confirmation dialog */}
{deleteConfirmId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => setDeleteConfirmId(null)}
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Delete search</h2>
<button
onClick={() => setDeleteConfirmId(null)}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">
Are you sure you want to delete this saved search? This cannot be undone.
</p>
<div className="flex gap-3 justify-end px-5 pb-5">
<button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</>
);
}
function SettingsContent({
user,
onRefreshAuth,
onRequestVerification,
@ -102,10 +284,7 @@ export default function AccountPage({
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-lg mx-auto px-6 py-16">
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-8">Account</h1>
<div className="max-w-lg mx-auto">
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
{/* Email */}
<div className="px-5 py-4 flex items-center justify-between">
@ -201,7 +380,7 @@ export default function AccountPage({
{isLicensed && (
<div className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{user.isAdmin ? 'Generate invite link (free license)' : 'Invite friends (30% off)'}
{user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'}
</p>
{inviteUrl ? (
<div className="flex items-center gap-2">
@ -277,6 +456,86 @@ export default function AccountPage({
)}
</div>
</div>
);
}
export default function AccountPage({
user,
onRefreshAuth,
onRequestVerification,
searches,
searchesLoading,
onDeleteSearch,
onOpenSearch,
}: {
user: AuthUser;
onRefreshAuth: () => Promise<void>;
onRequestVerification: (email: string) => Promise<void>;
searches: SavedSearch[];
searchesLoading: boolean;
onDeleteSearch: (id: string) => Promise<void>;
onOpenSearch: (params: string) => void;
}) {
const [activeTab, setActiveTab] = useState<AccountTab>(() => {
const hash = window.location.hash.slice(1);
return hash === 'settings' ? 'settings' : 'saved';
});
// Sync hash with tab
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash === 'settings') setActiveTab('settings');
else setActiveTab('saved');
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
const switchTab = (tab: AccountTab) => {
setActiveTab(tab);
window.history.replaceState(
window.history.state,
'',
`/account#${tab}`
);
};
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-warm-900">
<div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-6">Account</h1>
{/* Tabs */}
<div className="flex border-b border-warm-200 dark:border-warm-700 mb-6">
<TabButton
label="Saved Searches"
isActive={activeTab === 'saved'}
onClick={() => switchTab('saved')}
/>
<TabButton
label="Settings"
isActive={activeTab === 'settings'}
onClick={() => switchTab('settings')}
/>
</div>
{/* Tab content */}
{activeTab === 'saved' ? (
<SavedSearchesContent
searches={searches}
loading={searchesLoading}
onDelete={onDeleteSearch}
onOpen={onOpenSearch}
/>
) : (
<SettingsContent
user={user}
onRefreshAuth={onRefreshAuth}
onRequestVerification={onRequestVerification}
/>
)}
</div>
</div>
);
}

View file

@ -1,115 +0,0 @@
import { useState } from 'react';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
interface FAQItem {
question: string;
answer: string;
}
const FAQ_ITEMS: FAQItem[] = [
{
question: 'What is this application?',
answer:
'Perfect Postcode is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
},
{
question: 'Where does the data come from?',
answer:
'All data comes from open government and community sources. Property prices are from HM Land Registry, energy certificates from MHCLG, transport times from TfL, deprivation scores from the English Indices of Deprivation 2025, crime data from data.police.uk, school ratings from Ofsted, broadband from Ofcom, noise from Defra, ethnicity from the 2021 Census, and points of interest from OpenStreetMap. See the Data Sources page for full details and links.',
},
{
question: 'What are the coloured hexagons on the map?',
answer:
'The map uses H3 hexagons to aggregate property data at different zoom levels. Each hexagon summarises the properties within it. The colour represents the value of whichever feature you have pinned or are actively filtering — for example, average price or energy rating. Zoom in to see smaller, more detailed hexagons; zoom out for a broader overview.',
},
{
question: 'How do filters work?',
answer:
'Use the Filters panel on the left to narrow down properties. Add a filter by clicking a feature name, then drag the range slider to set minimum and maximum values. For categorical features like property type, select or deselect individual values. Only hexagons containing properties that match all active filters are shown. Filters are combined with AND logic — every property must satisfy every filter.',
},
{
question: 'What does the eye icon do on a filter?',
answer:
"The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.",
},
{
question: 'How fresh is the data?',
answer:
'Property prices cover all Land Registry transactions up to the most recent quarterly release. EPC data includes certificates issued up to the latest available download. Crime data spans 20232025 as yearly averages. TfL journey times are computed from current timetables. Deprivation indices are from the 2025 release. School ratings reflect the latest Ofsted inspections as at April 2025. Broadband data is from Ofcom Connected Nations 2025.',
},
{
question: 'How are EPC records matched to Land Registry sales?',
answer:
"EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.",
},
{
question: 'What are Points of Interest (POIs)?',
answer:
'POIs are places like cafes, schools, supermarkets, GP surgeries, parks, and train stations extracted from OpenStreetMap and the NaPTAN public transport dataset. Use the POI panel on the right to toggle categories on and off. POIs appear as markers on the map when you are zoomed in far enough.',
},
{
question: 'Can I share a specific view with someone?',
answer:
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL to your clipboard. Anyone who opens that link will see the same view, filters, and active POI categories.',
},
{
question: 'How do I see individual properties?',
answer:
'Click on a hexagon to open the Properties panel on the right. It lists all matching properties within that hexagon, showing address, price, and key features. Use "Load more" at the bottom to paginate through large hexagons.',
},
{
question: 'Why are some hexagons grey?',
answer:
'Grey hexagons contain properties that have data but fall outside the range of your currently pinned or active feature. This gives you a sense of where properties exist even when their values are outside your selected range.',
},
{
question: 'Does this work on mobile?',
answer:
'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.',
},
];
function FAQItemCard({ item }: { item: FAQItem }) {
const [open, setOpen] = useState(false);
return (
<div className="bg-white dark:bg-navy-800 rounded-lg border border-warm-200 dark:border-navy-700">
<button
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
onClick={() => setOpen(!open)}
>
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
<ChevronIcon
direction="down"
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="px-5 pb-4">
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">{item.answer}</p>
</div>
)}
</div>
);
}
export default function FAQPage() {
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-8">
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
Frequently Asked Questions
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
Common questions about how Perfect Postcode works, where the data comes from, and how to use the
map.
</p>
<div className="space-y-3">
{FAQ_ITEMS.map((item, index) => (
<FAQItemCard key={index} item={item} />
))}
</div>
</div>
</div>
);
}

View file

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

View file

@ -16,13 +16,13 @@ interface HexConfig {
function generateHexes(): HexConfig[] {
const hexes: HexConfig[] = [];
for (let i = 0; i < HEX_COUNT; i++) {
const driftDuration = 18 + Math.random() * 35;
const driftDuration = 40 + Math.random() * 60;
hexes.push({
size: 10 + Math.random() * 32,
opacity: 0.06 + Math.random() * 0.18,
top: Math.random() * 100,
driftDuration,
bobDuration: 3 + Math.random() * 5,
bobDuration: 6 + Math.random() * 8,
bobAmount: 8 + Math.random() * 30,
delay: -Math.random() * driftDuration,
reverse: Math.random() < 0.3,
@ -49,7 +49,7 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
className="bg-teal-500"
style={{
width: hex.size,
height: hex.size,
height: hex.size * 2 / Math.sqrt(3),
opacity: hex.opacity * (isDark ? 0.6 : 1),
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,

View file

@ -1,275 +0,0 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { Slider } from '../ui/Slider';
import { apiUrl, assertOk, authHeaders, logNonAbortError } 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 { SpinnerIcon } from '../ui/icons/SpinnerIcon';
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 [loading, setLoading] = useState(true);
const [fetching, setFetching] = useState(false);
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();
setFetching(true);
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
.then((res) => {
assertOk(res, 'hexagons');
return res.json();
})
.then((data: { features: HexagonData[] }) => {
setHexData(data.features);
setLoading(false);
setFetching(false);
})
.catch((err) => {
logNonAbortError('Failed to fetch demo hexagons', err);
setFetching(false);
});
}, [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) => {
assertOk(res, 'hexagons');
return res.json();
})
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
.catch((err) => logNonAbortError('Failed to fetch demo drag data', err));
},
[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>
{loading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
{!loading && fetching && (
<div className="absolute top-3 left-3 z-50 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
Loading...
</div>
)}
{/* Colour spectrum legend */}
<div className="absolute bottom-3 left-3 right-3 z-50 pointer-events-none">
<div className="bg-white/90 dark:bg-warm-800/90 rounded-lg px-3 py-2 backdrop-blur-sm text-xs">
<div className="font-semibold text-navy-950 dark:text-warm-100 mb-1 truncate">
{activeFeature ? viewMeta?.name || activeFeature : 'Property density'}
</div>
<div
className="h-2.5 rounded-full"
style={{
background: activeFeature
? featureGradientStyle
: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT),
}}
/>
{colorRange && (
<div className="flex justify-between mt-0.5 text-warm-500 dark:text-warm-400">
<TickerValue text={formatValue(colorRange[0], viewMeta ?? undefined)} />
<TickerValue text={formatValue(colorRange[1], viewMeta ?? undefined)} />
</div>
)}
</div>
</div>
</div>
{/* Sliders */}
<div className="md:w-2/5 flex flex-col justify-center space-y-6">
{demoFeatures.map((feature) => {
const value = sliderValues[feature.name];
if (!value || feature.min == null || feature.max == null) return null;
const isActive = activeFeature === feature.name;
return (
<div
key={feature.name}
className={`rounded-lg p-3 ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : ''}`}
>
<div className="flex justify-between mb-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
{feature.name}
</span>
<span className="text-sm text-warm-500 dark:text-warm-400">
{formatValue(value[0], feature)} &ndash; {formatValue(value[1], feature)}
</span>
</div>
<Slider
min={feature.min}
max={feature.max}
step={feature.step || 1}
value={[value[0], value[1]]}
onValueChange={([min, max]) => handleSliderChange(feature.name, [min, max])}
onPointerDown={() => handleDragStart(feature.name)}
onPointerUp={() => handleDragEnd()}
/>
</div>
);
})}
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import HexCanvas from './HexCanvas';
import ScrollStory from './ScrollStory';
import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import type { FeatureMeta } from '../../types';
export default function HomePage({
@ -11,11 +12,13 @@ export default function HomePage({
onOpenPricing,
theme = 'light',
features = [],
hidePricing,
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
theme?: 'light' | 'dark';
features?: FeatureMeta[];
hidePricing?: boolean;
}) {
const [statsActive, setStatsActive] = useState(false);
useEffect(() => {
@ -31,10 +34,12 @@ export default function HomePage({
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="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)]">
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
<HexCanvas isDark={theme === 'dark'} />
<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 md:px-10 py-6 backdrop-blur-sm bg-navy-950/30 rounded-2xl">
<div className="absolute top-1/3 left-1/4 w-[500px] h-[500px] bg-teal-500/[0.04] rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[400px] h-[300px] bg-teal-600/[0.03] rounded-full blur-[100px] pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
<div>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
Get more <span className="text-teal-400">home</span> for your money.
</h1>
@ -53,12 +58,18 @@ export default function HomePage({
>
Explore the map
</button>
{hidePricing ? (
<span className="px-[26px] py-[12px] border-2 border-teal-400/50 text-teal-400 rounded-lg font-semibold text-base">
You have lifetime access!
</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
Get lifetime access
</button>
)}
</div>
<div className="flex gap-12 pt-6 border-t border-white/10">
<div>
@ -79,6 +90,14 @@ export default function HomePage({
</div>
</div>
</div>
<div className="flex-1" />
<div className="flex flex-col items-center pb-8 animate-[bounce_3s_ease-in-out_infinite]">
<p className="text-lg md:text-xl font-semibold text-warm-300 mb-2">
Let&apos;s look at an example
</p>
<ChevronIcon direction="down" className="w-6 h-6 text-warm-400" />
</div>
</div>
</div>
{/* Scrollytelling: Problem + Solution + Demo map */}

View file

@ -1,19 +1,23 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import MapComponent from '../map/Map';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { zoomToResolution } from '../../lib/map-utils';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_VIEW_START = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_VIEW_END = { longitude: -1.9, latitude: 52.2, zoom: 12, pitch: 0 };
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
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 noop = () => { };
// Filter fractions per stage: featureName -> [minFrac, maxFrac]
// 0 = feature.min, 1 = feature.max
@ -148,6 +152,9 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
const abortRef = useRef<AbortController>();
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const sectionRef = useRef<HTMLElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
const rafRef = useRef<number>(0);
const demoFeatures = useMemo(
() =>
@ -188,12 +195,82 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
return () => observers.forEach((o) => o.disconnect());
}, [demoFeatures.length]);
// Fetch hex data when filters change
// Track scroll progress through the section for zoom interpolation
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
let scrollParent: HTMLElement | null = section.parentElement;
while (scrollParent) {
const { overflow, overflowY } = getComputedStyle(scrollParent);
if (['auto', 'scroll'].includes(overflow) || ['auto', 'scroll'].includes(overflowY)) break;
scrollParent = scrollParent.parentElement;
}
if (!scrollParent) return;
const handleScroll = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const totalTravel = rect.height - viewportHeight;
if (totalTravel <= 0) return;
const scrolled = -rect.top;
const progress = Math.max(0, Math.min(1, scrolled / totalTravel));
setScrollProgress(progress);
});
};
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => {
scrollParent.removeEventListener('scroll', handleScroll);
cancelAnimationFrame(rafRef.current);
};
}, []);
const demoView = useMemo(() => {
const t = easeOutCubic(scrollProgress);
return {
longitude: DEMO_VIEW_START.longitude + (DEMO_VIEW_END.longitude - DEMO_VIEW_START.longitude) * t,
latitude: DEMO_VIEW_START.latitude + (DEMO_VIEW_END.latitude - DEMO_VIEW_START.latitude) * t,
zoom: DEMO_VIEW_START.zoom + (DEMO_VIEW_END.zoom - DEMO_VIEW_START.zoom) * t,
pitch: 0,
};
}, [scrollProgress]);
// Derive H3 resolution from current zoom (discrete — only changes at thresholds)
const resolution = zoomToResolution(demoView.zoom);
// Compute bounds string from current view, rounded to 0.5° to avoid refetching on every scroll tick
const demoBounds = useMemo(() => {
const { longitude, latitude, zoom } = demoView;
const scale = Math.pow(2, zoom);
const degreesPerPixelLng = 360 / (512 * scale);
const halfW = (1200 / 2) * degreesPerPixelLng * 1.3;
const latRad = (latitude * Math.PI) / 180;
const mercY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const worldSize = 512 * scale;
const halfH = (800 / 2) * 1.3;
const topY = mercY * worldSize - halfH;
const botY = mercY * worldSize + halfH;
const toLat = (py: number) => {
const my = Math.max(0.001, Math.min(0.999, py / worldSize));
return (Math.atan(Math.sinh(Math.PI * (1 - 2 * my))) * 180) / Math.PI;
};
const snap = (v: number) => Math.round(v * 2) / 2;
const south = snap(Math.max(-85, toLat(botY)));
const west = snap(Math.max(-180, longitude - halfW));
const north = snap(Math.min(85, toLat(topY)));
const east = snap(Math.min(180, longitude + halfW));
return `${south},${west},${north},${east}`;
}, [demoView]);
// Fetch hex data when resolution, filters, or bounds change
useEffect(() => {
if (features.length === 0) return;
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
resolution: String(resolution),
bounds: demoBounds,
});
const filterParts: string[] = [];
for (const [name, [min, max]] of Object.entries(stageFilters)) {
@ -219,7 +296,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
.catch((err) => logNonAbortError('Failed to fetch story hexagons', err));
}, 300);
return () => clearTimeout(fetchTimeoutRef.current);
}, [features, stageFilters, stage]);
}, [features, stageFilters, stage, resolution, demoBounds]);
useEffect(() => {
return () => {
@ -234,13 +311,16 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const colorRange: [number, number] | null =
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
// Defer hex data so scroll zoom stays smooth while layer rebuilds happen in the background
const deferredHexData = useDeferredValue(hexData);
return (
<section className="relative">
<section ref={sectionRef} className="relative">
{/* Sticky map background */}
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
<div className="absolute inset-0">
<MapComponent
data={hexData}
data={deferredHexData}
postcodeData={[]}
usePostcodeView={false}
pois={[]}
@ -255,7 +335,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
hoveredHexagonId={null}
onHexagonClick={noop}
onHexagonHover={noop}
initialViewState={DEMO_VIEW}
initialViewState={demoView}
theme={theme}
screenshotMode={true}
hideLegend={true}
@ -272,9 +352,12 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
</div>
)}
{/* Filter indicators */}
<div className="absolute bottom-4 left-4 z-40 pointer-events-none w-[200px] md:w-[240px]">
<div className="bg-white/85 dark:bg-warm-800/85 rounded-lg p-3 backdrop-blur-sm shadow-lg space-y-2.5">
{/* Filter indicators — left sidebar */}
<div className="absolute top-0 left-0 bottom-0 z-40 pointer-events-none w-[280px] md:w-[340px] flex items-center">
<div className="bg-white/85 dark:bg-warm-800/85 rounded-r-xl p-5 md:p-6 backdrop-blur-sm shadow-lg space-y-5 w-full">
<div className="text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-1">
Filters
</div>
{demoFeatures.map((feature) => {
const filterVal = stageFilters[feature.name];
const isActive = !!filterVal;
@ -288,20 +371,20 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
key={feature.name}
className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`}
>
<div className="flex justify-between items-baseline text-[11px] mb-1 gap-2">
<div className="flex justify-between items-baseline text-sm mb-1.5 gap-2">
<span
className={`font-medium truncate ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
>
{feature.name}
</span>
{isActive && filterVal && (
<span className="text-teal-600 dark:text-teal-400 font-medium whitespace-nowrap">
<span className="text-teal-600 dark:text-teal-400 font-semibold whitespace-nowrap">
{formatValue(filterVal[0], feature)}&ndash;
{formatValue(filterVal[1], feature)}
</span>
)}
</div>
<div className="relative h-1.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
<div className="relative h-2.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
<div
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}

View file

@ -151,15 +151,15 @@ export default function InvitePage({
</h2>
<p className="text-warm-300 text-sm">
{isAdminInvite
? 'You have been invited to get a free lifetime license.'
: 'A friend has shared a 30% discount on the lifetime license.'}
? 'You have been invited to get free lifetime access.'
: 'A friend has shared a 30% discount on lifetime access.'}
</p>
</div>
<div className="px-6 py-6">
{isAdminInvite && (
<div className="text-center mb-4">
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">Free</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime license</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime access</span>
</div>
)}
{!isAdminInvite && pricePence !== null && pricePence > 0 && (
@ -168,7 +168,7 @@ export default function InvitePage({
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${Math.round(pricePence * 0.7) / 100}`}
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">/once</span>
</div>

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useRef } from 'react';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
type LearnTab = 'data-sources' | 'faq';
type LearnTab = 'data-sources' | 'faq' | 'support';
const DATA_SOURCES = [
{
@ -206,7 +206,27 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'How often is the data updated?',
answer:
'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source.',
'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source. All updates are included with your access at no extra cost.',
},
{
question: 'What data is included?',
answer:
'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.',
},
{
question: 'What can I access on the free tier?',
answer:
'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need lifetime access.',
},
{
question: 'What does "lifetime" mean?',
answer:
'Your access never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.',
},
{
question: 'Can I get a refund?',
answer:
'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@propertymap.co.uk within 30 days of purchase for a full refund.',
},
];
@ -214,7 +234,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
const [open, setOpen] = useState(false);
return (
<div className="bg-white dark:bg-navy-800 rounded-lg border border-warm-200 dark:border-navy-700">
<div className="bg-white dark:bg-warm-800 rounded-lg border border-warm-200 dark:border-warm-700">
<button
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
onClick={() => setOpen(!open)}
@ -246,6 +266,9 @@ export default function LearnPage() {
if (hash === 'faq') {
setTab('faq');
setHighlightedId(null);
} else if (hash === 'support') {
setTab('support');
setHighlightedId(null);
} else if (hash && DATA_SOURCES.some((s) => s.id === hash)) {
setTab('data-sources');
setHighlightedId(hash);
@ -261,7 +284,6 @@ export default function LearnPage() {
return () => window.removeEventListener('hashchange', handleHash);
}, []);
// Scroll to top when switching tabs
useEffect(() => {
scrollContainerRef.current?.scrollTo(0, 0);
}, [tab]);
@ -275,19 +297,20 @@ export default function LearnPage() {
return (
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
{/* Tab bar */}
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
<div className="flex gap-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
Data Sources
</button>
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
FAQ
</button>
<button className={tabClass('support')} onClick={() => setTab('support')}>
Support
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
{tab === 'data-sources' ? (
<>
@ -308,10 +331,10 @@ export default function LearnPage() {
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-navy-700'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
@ -400,7 +423,7 @@ export default function LearnPage() {
</div>
</footer>
</>
) : (
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
Frequently Asked Questions
@ -415,6 +438,27 @@ export default function LearnPage() {
))}
</div>
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
Support
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
Have a question? Check our FAQ or reach out to us directly.
</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
<a
href="mailto:support@propertymap.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@propertymap.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
We typically respond within 24 hours.
</p>
</div>
</div>
)}
</div>
</div>

View file

@ -1,58 +0,0 @@
import { ChevronIcon } from '../ui/icons';
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
interface AISummaryCardProps {
summary?: string;
loading?: boolean;
error?: string | null;
expanded: boolean;
onToggleExpanded: () => void;
}
export default function AISummaryCard({
summary,
loading,
error,
expanded,
onToggleExpanded,
}: AISummaryCardProps) {
if (!summary && !loading && !error) return null;
return (
<div className="px-3 pt-3 pb-1">
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
<button
onClick={onToggleExpanded}
className="w-full flex items-center justify-between gap-1.5 mb-1.5"
>
<div className="flex items-center gap-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
AI Summary
</span>
</div>
<ChevronIcon
direction={expanded ? 'down' : 'right'}
className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400"
/>
</button>
{expanded && (
<>
{error ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
Failed to generate summary.
</div>
) : loading ? (
<div className="space-y-1.5">
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
</div>
) : (
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">{summary}</p>
)}
</>
)}
</div>
</div>
);
}

View file

@ -22,7 +22,6 @@ import { IconButton } from '../ui/IconButton';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import AISummaryCard from './AISummaryCard';
import StreetViewEmbed from './StreetViewEmbed';
import HistogramLegend from './HistogramLegend';
@ -38,9 +37,6 @@ interface AreaPaneProps {
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
onNavigateToSource?: (slug: string, featureName: string) => void;
aiSummary?: string;
aiSummaryLoading?: boolean;
aiSummaryError?: string | null;
}
export default function AreaPane({
@ -55,16 +51,11 @@ export default function AreaPane({
hexagonLocation,
filters,
onNavigateToSource,
aiSummary,
aiSummaryLoading,
aiSummaryError,
}: AreaPaneProps) {
// For postcodes, use local data for count
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true);
const numericByName = useMemo(() => {
if (!stats) return new Map();
@ -94,7 +85,7 @@ export default function AreaPane({
return (
<div className="flex flex-col h-full">
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="p-3 border-b border-warm-200 dark:border-warm-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div>
@ -133,13 +124,6 @@ export default function AreaPane({
)}
<div className="flex-1 overflow-y-auto">
<AISummaryCard
summary={aiSummary}
loading={aiSummaryLoading}
error={aiSummaryError}
expanded={aiSummaryExpanded}
onToggleExpanded={() => setAiSummaryExpanded(!aiSummaryExpanded)}
/>
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
@ -154,7 +138,6 @@ export default function AreaPane({
const stackedCharts = STACKED_GROUPS[group.name];
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
// Features that are part of a stacked enum config (rendered as compact charts)
const stackedEnumFeatureNames = new Set<string>(
stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
@ -173,11 +156,9 @@ export default function AreaPane({
/>
{isExpanded && (
<div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' &&
stats.price_history &&
(() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(
stats.price_history.map((p) => Math.floor(p.year))
);
@ -191,8 +172,7 @@ export default function AreaPane({
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
? stackedCharts.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
@ -200,7 +180,6 @@ export default function AreaPane({
}))
.filter((s) => s.value > 0);
// Use aggregate feature stats if available, otherwise sum components
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
@ -240,8 +219,7 @@ export default function AreaPane({
</div>
);
})
: // Default: render each feature individually (skip stacked enum features)
group.features
: group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
@ -306,13 +284,11 @@ export default function AreaPane({
return null;
})}
{/* Stacked enum charts */}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
// Single component: render as a stacked bar (like crime charts)
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
@ -355,7 +331,6 @@ export default function AreaPane({
);
}
// Multi-component: render as compact multi-row chart (like risk features)
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);

View file

@ -131,7 +131,7 @@ export function DualHistogram({
);
}
export function SkeletonHistogram() {
function SkeletonHistogram() {
return (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
<div className="flex justify-between items-baseline">

View file

@ -9,9 +9,9 @@ import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { RouteIcon, PlusIcon } from '../ui/icons';
import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
@ -71,26 +71,58 @@ export default function FeatureBrowser({
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{showTravelModes && TRANSPORT_MODES.map((mode) => (
<div key={mode} className="shrink-0 border-b border-warm-200 dark:border-warm-700">
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
{showTravelModes && (
<div className="shrink-0">
<CollapsibleGroupHeader
name="Travel Time"
expanded={isSearching || expandedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{TRANSPORT_MODES.length}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
return (
<div
key={mode}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time ({MODE_LABELS[mode]})
{MODE_LABELS[mode]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Filter by journey time to a destination
</span>
</div>
</div>
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
<PlusIcon className="w-3.5 h-3.5" />
<div className="flex items-center gap-0.5 shrink-0">
{fieldKey && (
<IconButton
onClick={() => onTogglePin(fieldKey)}
active={isPinned}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
size="md"
>
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
)}
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
</div>
</div>
))}
);
})}
</div>
)}
{grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (

View file

@ -20,8 +20,20 @@ import { TravelTimeCard } from './TravelTimeCard';
import {
type TransportMode,
type TravelTimeEntry,
travelFieldKey,
} from '../../hooks/useTravelTime';
type ListingType = 'historical' | 'buy' | 'rent';
const MODE_RESTRICTED_FEATURES: Record<string, Set<ListingType>> = {
'Bathrooms': new Set(['buy', 'rent']),
};
function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean {
const allowed = MODE_RESTRICTED_FEATURES[featureName];
return !allowed || allowed.has(mode);
}
function SliderLabels({
min,
max,
@ -33,7 +45,6 @@ function SliderLabels({
max: number;
value: [number, number];
displayValues?: [number, number];
/** When true and slider is at max, append "+" to indicate unrestricted upper bound */
absoluteMax?: boolean;
}) {
const range = max - min || 1;
@ -72,7 +83,6 @@ interface FiltersProps {
onDragEnd: () => void;
pinnedFeature: string | null;
onTogglePin: (name: string) => void;
onCancelPin: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
@ -102,7 +112,6 @@ export default memo(function Filters({
onDragEnd,
pinnedFeature,
onTogglePin,
onCancelPin: _onCancelPin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
@ -117,37 +126,43 @@ export default memo(function Filters({
aiFilterNotes,
onAiFilterSubmit,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter(
(f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'
);
const listingToggles = useMemo(() => {
const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined;
if (!val) return { historical: true, buy: true, rent: true };
return {
historical: val.includes('Historical sale'),
buy: val.includes('For sale'),
rent: val.includes('For rent'),
};
if (!val || val.length === 0) return 'historical';
if (val.includes('For sale')) return 'buy';
if (val.includes('For rent')) return 'rent';
return 'historical';
}, [filters]);
const handleListingToggle = useCallback(
(key: 'historical' | 'buy' | 'rent') => {
const next = { ...listingToggles, [key]: !listingToggles[key] };
const allOn = next.historical && next.buy && next.rent;
const allOff = !next.historical && !next.buy && !next.rent;
if (allOn || allOff) {
onRemoveFilter('Listing status');
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
[features, enabledFeatures, activeListingType]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
[features, enabledFeatures]
);
const handleListingSelect = useCallback(
(type: ListingType) => {
if (type === activeListingType && !filters['Listing status']) return;
for (const name of Object.keys(filters)) {
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
onRemoveFilter(name);
}
}
if (type === 'historical' && !filters['Listing status']) {
onFilterChange('Listing status', ['Historical sale']);
return;
}
const values: string[] = [];
if (next.historical) values.push('Historical sale');
if (next.buy) values.push('For sale');
if (next.rent) values.push('For rent');
onFilterChange('Listing status', values);
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
rent: 'For rent',
};
onFilterChange('Listing status', [valueMap[type]]);
},
[listingToggles, onFilterChange, onRemoveFilter]
[activeListingType, filters, onFilterChange, onRemoveFilter]
);
const containerRef = useRef<HTMLDivElement>(null);
@ -181,8 +196,7 @@ export default memo(function Filters({
return scales;
}, [features]);
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
const badgeCount = enabledFeatureList.length + activeEntryCount + (hasListingFilter ? 1 : 0);
const badgeCount = enabledFeatureList.length + activeEntryCount;
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
@ -198,16 +212,26 @@ export default memo(function Filters({
</button>
</div>
</div>
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-xs font-medium text-warm-500 dark:text-warm-400">Show</span>
<PillGroup>
<PillToggle label="Historical" active={listingToggles.historical}
onClick={() => handleListingToggle('historical')} size="xs" />
<PillToggle label="Buy" active={listingToggles.buy}
onClick={() => handleListingToggle('buy')} size="xs" />
<PillToggle label="Rent" active={listingToggles.rent}
onClick={() => handleListingToggle('rent')} size="xs" />
</PillGroup>
<div className="shrink-0 px-3 py-2.5 border-b border-warm-200 dark:border-navy-700">
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => {
const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' };
const isActive = activeListingType === type;
return (
<button
key={type}
onClick={() => handleListingSelect(type)}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
isActive
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
>
{labels[type]}
</button>
);
})}
</div>
</div>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
@ -224,20 +248,39 @@ export default memo(function Filters({
</div>
<div className="md:flex-1 md:overflow-y-auto">
{travelTimeEntries.length > 0 && (
<div>
<CollapsibleGroupHeader
name="Travel Time"
expanded={!collapsedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{travelTimeEntries.length}
</span>
</CollapsibleGroupHeader>
{!collapsedGroups.has('Travel Time') && (
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} className="px-2 py-1">
<TravelTimeCard
key={index}
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
dataRange={travelTimeDataRanges.get(index) ?? null}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
</div>
)}
</div>
)}
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">

View file

@ -29,9 +29,11 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
export default function LocationSearch({
onFlyTo,
onLocationSearched,
onMouseEnter,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onMouseEnter?: () => void;
}) {
const search = useLocationSearch();
const [error, setError] = useState<string | null>(null);
@ -118,8 +120,8 @@ export default function LocationSearch({
}
return (
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col">
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col" onMouseEnter={onMouseEnter}>
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
<PlaceSearchInput
search={search}

View file

@ -1,6 +1,5 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
@ -21,7 +20,7 @@ import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import type { FeatureFilters } from '../../types';
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } from '../../hooks/useTravelTime';
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
interface MapProps {
data: HexagonData[];
@ -40,6 +39,7 @@ interface MapProps {
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
@ -49,11 +49,9 @@ interface MapProps {
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_TRAVEL_RANGES = new globalThis.Map<number, [number, number]>();
interface Dimensions {
width: number;
@ -97,6 +95,7 @@ export default memo(function Map({
onHexagonClick,
onHexagonHover,
initialViewState,
flyToRef,
theme = 'light',
screenshotMode = false,
ogMode = false,
@ -106,12 +105,17 @@ export default memo(function Map({
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
travelTimeColorRanges = EMPTY_TRAVEL_RANGES,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
const [internalViewState, setInternalViewState] = useState<ViewState>(
initialViewState || INITIAL_VIEW_STATE
);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// In screenshot mode, use the prop directly for instant updates (no async lag)
const viewState =
screenshotMode && initialViewState ? initialViewState : internalViewState;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@ -130,8 +134,6 @@ export default memo(function Map({
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
// Send exact viewport bounds - server will filter to only return
// hexagons/postcodes that intersect this precise AABB
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
@ -145,19 +147,14 @@ export default memo(function Map({
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setViewState(evt.viewState);
setInternalViewState(evt.viewState);
}, []);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const handleMapLoad = useCallback(
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
// Road opacity is set in getMapStyle
},
[]
);
if (flyToRef) flyToRef.current = handleFlyTo;
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
@ -169,7 +166,6 @@ export default memo(function Map({
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
primaryTravelIndex,
} = useDeckLayers({
data,
postcodeData,
@ -187,7 +183,6 @@ export default memo(function Map({
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEntries,
travelTimeColorRanges,
});
return (
@ -195,7 +190,7 @@ export default memo(function Map({
<MapGL
{...viewState}
onMove={handleMove}
onLoad={handleMapLoad as never}
onLoad={undefined}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
@ -222,19 +217,20 @@ export default memo(function Map({
) : null
) : (
<>
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} onMouseEnter={handleMouseLeave} />
{!hideLegend &&
(primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? (
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[travelTimeEntries[primaryTravelIndex].mode]})`}
range={travelTimeColorRanges.get(primaryTravelIndex)!}
showCancel={false}
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
theme={theme}
suffix=" min"
/>
) : viewFeature && colorRange && colorFeatureMeta ? (
) : colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
@ -248,9 +244,10 @@ export default memo(function Map({
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
) : null
) : (
<MapLegend
featureLabel="Property density"
featureLabel="Number of properties"
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
@ -16,7 +16,6 @@ import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
@ -28,6 +27,7 @@ import {
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -87,13 +87,9 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
// Mobile state
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
// POI floating panel state
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
// Initialize filters first
const {
filters,
activeFeature,
@ -112,6 +108,7 @@ export default function MapPage({
handleDragChange,
handleDragEnd,
handleTogglePin,
handleSetPin,
handleCancelPin,
updateBoundsInfo,
} = useFilters({
@ -119,7 +116,6 @@ export default function MapPage({
features,
});
// AI filters hook
const aiFilters = useAiFilters();
const handleAiFilterSubmit = useCallback(
async (query: string) => {
@ -129,13 +125,34 @@ export default function MapPage({
[aiFilters.fetchAiFilters, handleSetFilters]
);
// Travel time hook
const travelTime = useTravelTime(initialTravelTime);
// License hook
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string) => {
travelTime.handleSetDestination(index, slug, label);
const entry = travelTime.entries[index];
if (entry) {
handleSetPin(`tt_${entry.mode}_${slug}`);
}
},
[travelTime.handleSetDestination, travelTime.entries, handleSetPin]
);
const handleTravelTimeRemoveEntry = useCallback(
(index: number) => {
const entry = travelTime.entries[index];
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
handleCancelPin();
}
travelTime.handleRemoveEntry(index);
},
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
);
const license = useLicense();
// Map data hook
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapData = useMapData({
filters,
features,
@ -146,19 +163,16 @@ export default function MapPage({
travelTimeEntries: travelTime.entries,
});
// Keep filter bounds in sync with map data
useEffect(() => {
updateBoundsInfo(mapData.bounds, mapData.resolution);
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
// Hexagon selection hook
const selection = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
});
// Location search handler — selects postcode + shows stats
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
@ -171,10 +185,16 @@ export default function MapPage({
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
);
// POI data
const handleZoomToFreeZone = useCallback(() => {
mapFlyToRef.current?.(
INITIAL_VIEW_STATE.latitude,
INITIAL_VIEW_STATE.longitude,
INITIAL_VIEW_STATE.zoom
);
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Compute data range for travel time slider per entry index (full min/max for slider bounds)
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
const ranges = new globalThis.Map<number, [number, number]>();
for (let i = 0; i < travelTime.entries.length; i++) {
@ -193,16 +213,13 @@ export default function MapPage({
return ranges;
}, [travelTime.entries, mapData.data]);
// Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
// Set initial view and tab from URL state
useEffect(() => {
mapData.setInitialView(initialViewState);
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// On mobile, open drawer and switch tab when hexagon is clicked
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
@ -214,7 +231,6 @@ export default function MapPage({
[handleHexagonClick]
);
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const isPostcode = selection.selectedHexagon?.type === 'postcode';
@ -239,19 +255,8 @@ export default function MapPage({
mapData.resolution,
]);
// Tutorial
const tutorial = useTutorial(initialLoading, isMobile);
// AI area summary
const aiSummary = useAreaSummary({
stats: selection.areaStats,
hexagonId: selection.selectedHexagon?.id || null,
isPostcode: selection.selectedHexagon?.type === 'postcode',
filters,
features,
});
// Export to Excel
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(() => {
if (!mapData.bounds || exporting) return;
@ -280,12 +285,10 @@ export default function MapPage({
.finally(() => setExporting(false));
}, [mapData.bounds, filters, features, exporting]);
// Report export state to parent (Header)
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
// Mobile legend data (computed from API-fetched data, which is already viewport-scoped)
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
@ -305,7 +308,6 @@ export default function MapPage({
return [min, max];
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
// Signal screenshot readiness once map data has loaded
useEffect(() => {
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
window.__screenshot_ready = true;
@ -337,13 +339,11 @@ export default function MapPage({
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
</div>
);
}
// Shared pane content renderers
const renderAreaPane = () => (
<AreaPane
stats={selection.areaStats}
@ -362,9 +362,6 @@ export default function MapPage({
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
/>
);
@ -375,7 +372,6 @@ export default function MapPage({
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection}
/>
);
@ -403,14 +399,13 @@ export default function MapPage({
onDragEnd={handleDragEnd}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={travelTime.handleRemoveEntry}
onTravelTimeSetDestination={travelTime.handleSetDestination}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
@ -419,7 +414,6 @@ export default function MapPage({
/>
);
// Mobile layout
if (isMobile) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
@ -434,7 +428,6 @@ export default function MapPage({
</div>
)}
{/* Map — 45% */}
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
<Map
data={mapData.data}
@ -453,6 +446,7 @@ export default function MapPage({
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
@ -460,21 +454,21 @@ export default function MapPage({
bounds={mapData.bounds}
hideLegend
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
{mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
Loading...
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
</div>
</div>
)}
{/* Floating POI button */}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
</button>
{/* Floating POI panel */}
{poiPaneOpen && (
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
@ -482,32 +476,23 @@ export default function MapPage({
)}
</div>
{/* Bottom panel — 55% */}
<div
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
style={{ flex: '55 0 0' }}
>
{/* Legend */}
{(() => {
const primaryIdx = travelTime.entries.findIndex(
(e, i) => e.slug && mapData.travelTimeColorRanges.get(i)
);
if (primaryIdx >= 0) {
return (
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[travelTime.entries[primaryIdx].mode]})`}
range={mapData.travelTimeColorRanges.get(primaryIdx)!}
showCancel={false}
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
);
}
if (viewFeature && mapData.colorRange && mobileLegendMeta) {
return (
) : mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
@ -522,11 +507,10 @@ export default function MapPage({
theme={theme}
inline
/>
);
}
return (
) : null
) : (
<MapLegend
featureLabel="Property density"
featureLabel="Number of properties"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
@ -534,15 +518,12 @@ export default function MapPage({
theme={theme}
inline
/>
);
})()}
{/* Filters content */}
)}
<div className="flex-1 min-h-0">
{renderFilters()}
</div>
</div>
{/* Mobile drawer for full-screen hexagon details */}
{mobileDrawerOpen && selection.selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
@ -557,14 +538,13 @@ export default function MapPage({
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
onZoomToFreeZone={handleZoomToFreeZone}
/>
)}
</div>
);
}
// Desktop layout (unchanged)
return (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
@ -589,7 +569,6 @@ export default function MapPage({
disableScrolling
/>
{/* Left Pane */}
<div
data-tutorial="filters"
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
@ -604,7 +583,6 @@ export default function MapPage({
</div>
</div>
{/* Map */}
<div data-tutorial="map" className="flex-1 relative">
<Map
data={mapData.data}
@ -623,17 +601,20 @@ export default function MapPage({
onHexagonClick={selection.handleHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
{mapData.loading && (
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
Loading...
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
</div>
</div>
)}
{/* Floating POI button */}
@ -652,7 +633,6 @@ export default function MapPage({
)}
</div>
{/* Right Pane */}
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
@ -692,7 +672,7 @@ export default function MapPage({
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
onZoomToFreeZone={handleZoomToFreeZone}
/>
)}
</div>

View file

@ -49,15 +49,30 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
if (arr) arr.push(p.price);
else byYear.set(yr, [p.price]);
}
const meds = Array.from(byYear.entries())
const yearlyMedians = Array.from(byYear.entries())
.map(([yr, prices]) => {
prices.sort((a, b) => a - b);
const mid = Math.floor(prices.length / 2);
const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
return { year: yr + 0.5, price: median };
return { year: yr, price: median };
})
.sort((a, b) => a.year - b.year);
// 3-year rolling average
const meds = yearlyMedians.map((pt, i) => {
let sum = pt.price;
let count = 1;
for (let j = i - 1; j >= 0 && pt.year - yearlyMedians[j].year <= 1; j--) {
sum += yearlyMedians[j].price;
count++;
}
for (let j = i + 1; j < yearlyMedians.length && yearlyMedians[j].year - pt.year <= 1; j++) {
sum += yearlyMedians[j].price;
count++;
}
return { year: pt.year + 0.5, price: sum / count };
});
const ticks = niceTicksForRange(pMin, pMax, 4);
return {

View file

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
@ -13,7 +13,6 @@ interface PropertiesPaneProps {
loading: boolean;
hexagonId: string | null;
onLoadMore: () => void;
onClose: () => void;
onNavigateToSource?: (slug: string) => void;
}
@ -23,7 +22,6 @@ export function PropertiesPane({
loading,
hexagonId,
onLoadMore,
onClose: _onClose,
onNavigateToSource,
}: PropertiesPaneProps) {
const [search, setSearch] = useState('');
@ -123,13 +121,9 @@ function PropertyLoadingSkeleton() {
<div className="space-y-0">
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
{/* Address */}
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
{/* Postcode */}
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Price */}
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Property details grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
@ -141,27 +135,79 @@ function PropertyLoadingSkeleton() {
);
}
const LISTING_STATUS_STYLES: Record<string, string> = {
'For sale': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
'For rent': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
'Historical sale': 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300',
};
function ListingStatusBadge({ status }: { status: string }) {
const style = LISTING_STATUS_STYLES[status] ?? LISTING_STATUS_STYLES['Historical sale'];
return <span className={`text-xs font-medium px-1.5 py-0.5 rounded ${style}`}>{status}</span>;
}
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm');
const estPricePerSqm = getNum(property, 'Est. price per sqm');
const floorArea = getNum(property, 'Total floor area (sqm)');
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)');
const age = getNum(property, 'Approximate construction age');
const rooms = getNum(property, 'Number of bedrooms & living rooms');
const age = getNum(property, 'Construction age');
const transactionDate = getNum(property, 'Date of last transaction');
const councilTax = getNum(property, 'Council tax (£/yr)');
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
const askingPrice = getNum(property, 'Asking price');
const askingRent = getNum(property, 'Asking rent (monthly)');
const bedrooms = getNum(property, 'Bedrooms');
const bathrooms = getNum(property, 'Bathrooms');
const listingDate = getNum(property, 'Listing date');
const listingStatus = property.listing_status;
return (
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
</div>
{listingStatus && <ListingStatusBadge status={listingStatus} />}
</div>
{property.property_sub_type && (
<div className="text-sm text-warm-600 dark:text-warm-400 mt-1">
{property.property_sub_type}
</div>
)}
{askingPrice !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
{property.price_qualifier && (
<span className="text-sm font-normal text-warm-500 dark:text-warm-400">
{property.price_qualifier}{' '}
</span>
)}
£{formatNumber(askingPrice)}
</div>
)}
{askingRent !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{formatNumber(askingRent)}
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">/mo</span>
</div>
)}
{price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
{askingPrice !== undefined || askingRent !== undefined ? (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
Last sold: £{formatNumber(price)}
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
</span>
) : (
<>
£{formatNumber(price)}
{transactionDate !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
@ -175,6 +221,8 @@ function PropertyCard({ property }: { property: Property }) {
£{formatNumber(pricePerSqm)}/m²
</span>
)}
</>
)}
</div>
)}
{estimatedPrice !== undefined && (
@ -213,6 +261,18 @@ function PropertyCard({ property }: { property: Property }) {
{formatNumber(floorArea)}m²
</div>
)}
{bedrooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Bedrooms:</span>{' '}
{formatNumber(bedrooms)}
</div>
)}
{bathrooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Bathrooms:</span>{' '}
{formatNumber(bathrooms)}
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
@ -236,18 +296,29 @@ function PropertyCard({ property }: { property: Property }) {
{property.potential_energy_rating}
</div>
)}
{councilTax !== undefined ? (
{listingDate !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
{formatNumber(councilTax)}/yr
<span className="text-warm-500 dark:text-warm-400">Listed:</span>{' '}
{formatTransactionDate(listingDate)}
</div>
) : councilTaxD !== undefined ? (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
{formatNumber(councilTaxD)}/yr
)}
</div>
) : null}
{property.listing_features && property.listing_features.length > 0 && (
<div className="mt-2">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Key features</div>
<div className="flex flex-wrap gap-1">
{property.listing_features.map((feature, idx) => (
<span
key={idx}
className="text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
>
{feature}
</span>
))}
</div>
</div>
)}
{property.renovation_history && property.renovation_history.length > 0 && (
<div className="mt-2">
@ -265,6 +336,19 @@ function PropertyCard({ property }: { property: Property }) {
</div>
</div>
)}
{property.listing_url && (
<div className="mt-2">
<a
href={property.listing_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
View external listing &rarr;
</a>
</div>
)}
</div>
);
}

View file

@ -3,6 +3,7 @@ import { Slider } from '../ui/Slider';
import { IconButton } from '../ui/IconButton';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { formatFilterValue } from '../../lib/format';
@ -15,6 +16,8 @@ interface TravelTimeCardProps {
label: string;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
isPinned: boolean;
onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void;
onRemove: () => void;
@ -26,6 +29,8 @@ export function TravelTimeCard({
label,
timeRange,
dataRange,
isPinned,
onTogglePin,
onSetDestination,
onTimeRangeChange,
onRemove,
@ -59,7 +64,7 @@ export function TravelTimeCard({
const displayRange = timeRange ?? [sliderMin, sliderMax];
return (
<div className="space-y-2 px-2 py-2 rounded ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20">
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
@ -68,10 +73,17 @@ export function TravelTimeCard({
Travel Time ({MODE_LABELS[mode]})
</span>
</div>
<div className="flex items-center gap-0.5">
{slug && (
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title="Remove travel time">
<CloseIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
</div>
{/* Destination search */}
<div ref={containerRef} className="relative">
@ -81,6 +93,7 @@ export function TravelTimeCard({
placeholder={slug ? 'Change destination...' : 'Search destination...'}
size="xs"
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
portal
/>
{slug && label && (

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { AuthUser } from '../../hooks/useAuth';
@ -9,7 +9,7 @@ const FEATURES = [
'56 data layers across England',
'Every postcode scored and filterable',
'Unlimited map exploration and exports',
'Historical price data back to 1995',
'Multiple decades of historical price data',
'Crime, schools, transport, broadband & more',
'All future data updates included',
];
@ -50,6 +50,23 @@ export default function PricingPage({
}) {
const license = useLicense();
const [pricing, setPricing] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true);
const [scrolledLeft, setScrolledLeft] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const activeCardRef = useRef<HTMLDivElement>(null);
const onScroll = useCallback(() => {
if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0);
}, []);
useEffect(() => {
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
useEffect(() => {
fetch(apiUrl('pricing'))
@ -58,7 +75,8 @@ export default function PricingPage({
return res.json();
})
.then(setPricing)
.catch((err) => console.error('Failed to load pricing:', err));
.catch((err) => console.error('Failed to load pricing:', err))
.finally(() => setLoading(false));
}, []);
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
@ -80,53 +98,112 @@ export default function PricingPage({
}
}
const ctaButton = isLicensed ? (
<button
onClick={onOpenDashboard}
className="w-full mt-auto px-5 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors"
>
Open dashboard
</button>
) : user ? (
<button
onClick={() => license.startCheckout()}
disabled={license.checkingOut}
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{license.checkingOut && (
<SpinnerIcon className="w-5 h-5 animate-spin" />
)}
{license.checkingOut
? 'Redirecting...'
: isFree
? 'Claim free access'
: `Get started — ${formatPrice(currentPrice)}`}
</button>
) : (
<button
onClick={onRegisterClick}
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
>
{isFree ? 'Claim free access' : 'Get started'}
</button>
);
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">
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
{/* Aurora — sized divs with oklch gradients, no blur */}
<div className="fixed inset-0 pointer-events-none overflow-hidden">
{/* Green curtain — top left */}
<div
className="absolute w-[90vw] h-[80vh] -top-[10%] -left-[15%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.72 0.19 145 / 0.18) 0%, oklch(0.55 0.15 160 / 0.08) 50%, transparent 100%)',
animation: 'aurora-1 20s ease-in-out infinite',
}}
/>
{/* Teal sweep — center */}
<div
className="absolute w-[80vw] h-[70vh] top-[5%] left-[15%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.13 175 / 0.15) 0%, oklch(0.55 0.10 195 / 0.06) 50%, transparent 100%)',
animation: 'aurora-2 18s ease-in-out infinite',
}}
/>
{/* Purple curtain — right */}
<div
className="absolute w-[85vw] h-[90vh] -top-[5%] -right-[15%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.55 0.20 290 / 0.16) 0%, oklch(0.45 0.22 275 / 0.06) 50%, transparent 100%)',
animation: 'aurora-4 25s ease-in-out infinite',
}}
/>
{/* Deep violet — bottom right */}
<div
className="absolute w-[75vw] h-[70vh] -bottom-[5%] right-[5%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.22 300 / 0.13) 0%, oklch(0.50 0.20 285 / 0.05) 50%, transparent 100%)',
animation: 'aurora-3 22s ease-in-out infinite',
}}
/>
{/* Emerald — bottom left */}
<div
className="absolute w-[80vw] h-[75vh] -bottom-[10%] -left-[10%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.65 0.17 155 / 0.14) 0%, oklch(0.55 0.14 165 / 0.05) 50%, transparent 100%)',
animation: 'aurora-5 24s ease-in-out infinite',
}}
/>
{/* Cyan accent — upper center */}
<div
className="absolute w-[70vw] h-[60vh] top-[20%] left-[20%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.12 200 / 0.10) 0%, oklch(0.52 0.10 185 / 0.04) 50%, transparent 100%)',
animation: 'aurora-1 16s ease-in-out infinite reverse',
}}
/>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
Early access pricing
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400 max-w-lg mx-auto">
<p className="text-lg text-warm-300 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime
access to every feature. The earlier you join, the less you pay.
</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 className="relative z-10 max-w-5xl mx-auto px-6 pb-16">
{/* Tier cards — full viewport width carousel */}
{loading ? (
<div className="flex justify-center py-16">
<SpinnerIcon className="w-8 h-8 animate-spin text-teal-400" />
</div>
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl font-extrabold text-white">
{pricing ? formatPrice(currentPrice) : '...'}
</span>
{!isFree && (
<span className="text-warm-400 text-lg">/once</span>
)}
</div>
{spotsRemaining > 0 && pricing && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining} spot{spotsRemaining !== 1 ? 's' : ''}{' '}
remaining at this price
</p>
)}
<p className="text-warm-300 text-sm mt-1">
{isFree
? 'Free for early adopters'
: 'One-time payment, no subscription'}
</p>
</div>
<div className="px-8 py-8">
{/* Tier breakdown */}
{pricing && (
<div className="mb-8 space-y-1.5">
<p className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
Pricing tiers
</p>
) : pricing ? (
<div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}>
{scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />}
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} />
<div ref={scrollRef} onScroll={onScroll} className="flex gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
{pricing.tiers.map((tier, i) => {
const isCurrent = i === currentTierIndex;
const isFilled =
@ -146,54 +223,88 @@ export default function PricingPage({
return (
<div
key={i}
className={`relative flex items-center justify-between px-3 py-2 rounded-lg text-sm ${
ref={isCurrent ? activeCardRef : undefined}
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent
? 'bg-teal-50 dark:bg-teal-900/30 ring-1 ring-teal-400'
: isFilled
? 'opacity-50'
: ''
}`}
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'border-warm-700 shadow-md'
} ${isFilled ? 'opacity-60' : ''}`}
>
<span
className={`${isCurrent ? 'text-navy-950 dark:text-warm-100 font-medium' : 'text-warm-600 dark:text-warm-400'}`}
>
{tierLabel(tier, i)}
</span>
<div className="flex items-center gap-2">
{isCurrent && tierSlots > 0 && (
<div className="w-16 h-1.5 rounded-full bg-warm-200 dark:bg-warm-700 overflow-hidden">
<div
className="h-full rounded-full bg-teal-500"
style={{ width: `${fillPercent}%` }}
/>
{isCurrent && (
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
Current tier
</div>
)}
<span
className={`font-semibold ${
<div
className={`px-6 py-8 text-center ${
isCurrent
? 'text-teal-700 dark:text-teal-400'
? 'bg-gradient-to-br from-navy-950 to-teal-900'
: 'bg-white dark:bg-warm-800'
}`}
>
<p
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
isCurrent
? 'text-teal-300'
: 'text-warm-500 dark:text-warm-400'
}`}
>
{tierLabel(tier, i)}
</p>
<div className="flex items-baseline justify-center gap-1">
<span
className={`text-4xl font-extrabold ${
isCurrent
? 'text-white'
: isFilled
? 'text-warm-400 dark:text-warm-500 line-through'
: 'text-warm-600 dark:text-warm-400'
: 'text-navy-950 dark:text-warm-100'
}`}
>
{formatPrice(tier.price_pence)}
</span>
{isFilled && (
<CheckIcon className="w-4 h-4 text-warm-400 dark:text-warm-500" />
{tier.price_pence > 0 && (
<span
className={`text-lg ${
isCurrent
? 'text-warm-400'
: 'text-warm-400 dark:text-warm-500'
}`}
>
/lifetime
</span>
)}
</div>
{isCurrent && spotsRemaining > 0 && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining} spot
{spotsRemaining !== 1 ? 's' : ''} remaining
</p>
)}
{isFilled && (
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
<CheckIcon className="w-4 h-4" /> Filled
</p>
)}
</div>
);
})}
{/* Progress bar for current tier */}
{isCurrent && tierSlots > 0 && (
<div className="h-1.5 bg-warm-200 dark:bg-warm-700">
<div
className="h-full bg-teal-500"
style={{ width: `${fillPercent}%` }}
/>
</div>
)}
{/* Features list */}
<ul className="space-y-4">
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
<ul className="space-y-3 mb-6 flex-1">
{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" />
<li key={feature} className="flex items-start gap-2.5 text-sm">
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">
{feature}
</span>
@ -201,49 +312,40 @@ export default function PricingPage({
))}
</ul>
{isLicensed ? (
<button
onClick={onOpenDashboard}
className="w-full mt-8 px-6 py-4 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
>
Open dashboard
</button>
) : user ? (
<button
onClick={() => license.startCheckout()}
disabled={license.checkingOut}
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 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{license.checkingOut && (
<SpinnerIcon className="w-5 h-5 animate-spin" />
)}
{license.checkingOut
? 'Redirecting...'
: isFree
? 'Claim free license'
: `Get started \u2014 ${formatPrice(currentPrice)}`}
</button>
) : (
<button
onClick={onRegisterClick}
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"
>
{isFree ? 'Claim free license' : 'Get started'}
</button>
)}
{isCurrent ? (
<>
{ctaButton}
{license.error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
{license.error}
</p>
)}
<p className="text-center text-sm text-warm-400 dark:text-warm-500 mt-3">
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{isFree
? 'No credit card required'
: '30-day money-back guarantee'}
</p>
</>
) : isFilled ? (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
Sold out
</div>
) : (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
Upcoming
</div>
)}
</div>
</div>
);
})}
</div>
</div>
) : (
<p className="text-center text-warm-400 py-16">
Failed to load pricing. Please try again later.
</p>
)}
</div>
</div>
);

View file

@ -1,186 +0,0 @@
import { useState, useCallback } from 'react';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import { shortenUrl } from '../../lib/api';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { TrashIcon } from '../ui/icons/TrashIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { formatRelativeTime } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
export default function SavedSearchesPage({
searches,
loading,
onDelete,
onOpen,
}: {
searches: SavedSearch[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onOpen: (params: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return;
await onDelete(deleteConfirmId);
setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]);
const copyToClipboard = useCallback((text: string, id: string) => {
const onSuccess = () => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}, []);
const handleShare = useCallback(async (params: string, id: string) => {
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
copyToClipboard(shortUrl, id);
} catch {
copyToClipboard(`${window.location.origin}/?${params}`, id);
} finally {
setSharingId(null);
}
}, [copyToClipboard]);
return (
<div className="flex-1 overflow-auto bg-warm-50 dark:bg-warm-900">
<div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-6">Saved Searches</h1>
{loading ? (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
) : searches.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
No saved searches yet
</p>
<p className="text-sm text-warm-500 dark:text-warm-500">
Save your dashboard filters and view to quickly return to them later.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{searches.map((search) => (
<div
key={search.id}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
>
{search.screenshotUrl ? (
<img
src={search.screenshotUrl}
alt={search.name}
className="w-full h-36 object-cover"
/>
) : (
<div className="w-full h-36 bg-gradient-to-br from-teal-600/20 to-navy-900/30 dark:from-teal-400/10 dark:to-navy-900/40 flex items-center justify-center">
<BookmarkIcon className="w-10 h-10 text-warm-300 dark:text-warm-600" />
</div>
)}
<div className="p-4">
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
{search.name}
</h3>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
{summarizeParams(search.params)}
</p>
<div className="flex gap-2">
<button
onClick={() => onOpen(search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
Open
</button>
<button
onClick={() => handleShare(search.params, search.id)}
disabled={sharingId === search.id}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-50 disabled:cursor-wait"
>
{sharingId === search.id ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : copiedId === search.id ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => setDeleteConfirmId(search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Delete confirmation dialog */}
{deleteConfirmId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => setDeleteConfirmId(null)}
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Delete search</h2>
<button
onClick={() => setDeleteConfirmId(null)}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">
Are you sure you want to delete this saved search? This cannot be undone.
</p>
<div className="flex gap-3 justify-end px-5 pb-5">
<button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,99 +0,0 @@
import { useState, useCallback } from 'react';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
const FAQ_ITEMS = [
{
question: 'What data is included?',
answer:
'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.',
},
{
question: 'What can I access on the free tier?',
answer:
'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need a lifetime license.',
},
{
question: 'What does "lifetime" mean?',
answer:
'Your license never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.',
},
{
question: 'Can I get a refund?',
answer:
'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@propertymap.co.uk within 30 days of purchase for a full refund.',
},
{
question: 'How often is the data updated?',
answer:
'We update the data regularly as new Land Registry, EPC, crime, and other government datasets are published. Updates are typically quarterly. All updates are included with your license at no extra cost.',
},
];
function FAQItem({ question, answer }: { question: string; answer: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-warm-200 dark:border-warm-700 last:border-b-0">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between px-5 py-4 text-left"
>
<span className="text-navy-950 dark:text-warm-100 font-medium pr-4">{question}</span>
<ChevronIcon
direction="down"
className={`w-5 h-5 text-warm-400 dark:text-warm-500 shrink-0 transition-transform ${
open ? 'rotate-180' : ''
}`}
/>
</button>
{open && (
<div className="px-5 pb-4">
<p className="text-warm-600 dark:text-warm-300 text-sm leading-relaxed">{answer}</p>
</div>
)}
</div>
);
}
export default function SupportPage() {
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-2xl mx-auto px-6 py-16">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
Support & FAQ
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400">
Have a question? Check below or reach out to us directly.
</p>
</div>
{/* Contact */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 mb-8 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
<a
href="mailto:support@propertymap.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@propertymap.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
We typically respond within 24 hours.
</p>
</div>
{/* FAQ */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
<div className="px-5 py-4 border-b border-warm-200 dark:border-warm-700">
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
Frequently Asked Questions
</h2>
</div>
{FAQ_ITEMS.map((item) => (
<FAQItem key={item.question} question={item.question} answer={item.answer} />
))}
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,6 @@
import { useState, useCallback } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { AppleIcon } from './icons/AppleIcon';
type View = 'login' | 'register' | 'forgot';
@ -134,15 +133,6 @@ export default function AuthModal({
<GoogleIcon className="w-4 h-4" />
Continue with Google
</button>
<button
type="button"
onClick={() => handleOAuth('apple')}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded bg-navy-950 dark:bg-white text-white dark:text-navy-950 text-sm font-medium hover:bg-navy-900 dark:hover:bg-warm-100 disabled:opacity-50 disabled:cursor-wait"
>
<AppleIcon className="w-4 h-4" />
Continue with Apple
</button>
</div>
{/* Divider */}

View file

@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account' | 'invite' | 'support';
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'invite';
export default function Header({
activePage,
@ -127,21 +127,20 @@ export default function Header({
</button>
{user && (
<button
className={tabClass('saved-searches')}
onClick={() => onPageChange('saved-searches')}
className={tabClass('account')}
onClick={() => onPageChange('account')}
>
Saved
Account
</button>
)}
<button className={tabClass('learn')} onClick={() => onPageChange('learn')}>
Learn
</button>
{user?.subscription !== 'licensed' && !user?.isAdmin && (
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
<button className={tabClass('support')} onClick={() => onPageChange('support')}>
Support
</button>
)}
</nav>
)}
</div>
@ -203,7 +202,7 @@ export default function Header({
{!isMobile && (
<>
{user ? (
<UserMenu user={user} onLogout={onLogout} onPageChange={onPageChange} />
<UserMenu user={user} onLogout={onLogout} />
) : (
<>
<button

View file

@ -1,14 +0,0 @@
import type { ReactNode } from 'react';
interface LabelProps {
children: ReactNode;
className?: string;
}
export function Label({ children, className }: LabelProps) {
return (
<label className={`text-sm font-medium text-warm-700 dark:text-warm-300 ${className || ''}`}>
{children}
</label>
);
}

View file

@ -5,7 +5,6 @@ interface LicenseSuccessModalProps {
}
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
// Generate confetti particles once
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
@ -17,11 +16,11 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
Math.floor(Math.random() * 6)
],
size: 6 + Math.random() * 6,
isCircle: Math.random() > 0.5,
})),
[]
);
// Auto-dismiss after 8 seconds
useEffect(() => {
const timer = setTimeout(onClose, 8000);
return () => clearTimeout(timer);
@ -29,7 +28,6 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
{/* Confetti */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<div
@ -41,7 +39,7 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
width: `${p.size}px`,
height: `${p.size}px`,
backgroundColor: p.color,
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
borderRadius: p.isCircle ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
@ -49,13 +47,12 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
))}
</div>
{/* Card */}
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
<div className="text-5xl mb-3">🎉</div>
<h2 className="text-2xl font-bold text-white">Welcome aboard!</h2>
<p className="text-warm-300 text-sm mt-2">
Your lifetime license is now active.
Your lifetime access is now active.
</p>
</div>
<div className="px-6 py-6">
@ -71,7 +68,6 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
</div>
</div>
{/* CSS animation for confetti */}
<style>{`
@keyframes confetti-fall {
0% {

View file

@ -80,10 +80,8 @@ export default function MobileMenu({
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
{mobileNavItem('home', 'Home')}
{mobileNavItem('dashboard', 'Dashboard')}
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('pricing', 'Pricing')}
{mobileNavItem('support', 'Support')}
{user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('account', 'Account')}
{/* Dashboard actions */}

View file

@ -1,3 +1,5 @@
import { useRef, useCallback, useLayoutEffect, useState as useStateR } from 'react';
import { createPortal } from 'react-dom';
import type React from 'react';
import type { SearchResult } from '../../hooks/useLocationSearch';
import { SearchIcon } from './icons/SearchIcon';
@ -26,6 +28,33 @@ interface PlaceSearchInputProps {
inputClassName?: string;
inputRef?: React.Ref<HTMLInputElement>;
onInputChange?: () => void;
portal?: boolean;
}
function useDropdownPosition(
anchorRef: React.RefObject<HTMLElement | null>,
open: boolean,
) {
const [pos, setPos] = useStateR<{ top: number; left: number; width: number } | null>(null);
const update = useCallback(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, [anchorRef]);
useLayoutEffect(() => {
if (!open) return;
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [open, update]);
return pos;
}
export function PlaceSearchInput({
@ -37,38 +66,24 @@ export function PlaceSearchInput({
inputClassName,
inputRef,
onInputChange,
portal,
}: PlaceSearchInputProps) {
const sm = size === 'sm';
const iconSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const spinnerSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const wrapperRef = useRef<HTMLDivElement>(null);
const dropdownPos = useDropdownPosition(wrapperRef, portal ? search.open : false);
return (
<div className="relative flex-1 min-w-0">
<input
ref={inputRef}
type="text"
value={search.query}
onChange={(e) => {
search.handleInputChange(e.target.value);
onInputChange?.();
}}
onFocus={() => {
if (search.results.length > 0) search.setOpen(true);
}}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
placeholder={placeholder}
className={inputClassName}
/>
const showDropdown = search.open && search.results.length > 0;
{loading && (
const dropdown = showDropdown && (
<div
className={`absolute right-2 top-1/2 -translate-y-1/2 ${spinnerSize} border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin`}
/>
)}
{search.open && search.results.length > 0 && (
<div
className={`absolute top-full left-0 right-0 mt-1 bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto z-20`}
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
style={
portal && dropdownPos
? { position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width, zIndex: 50 }
: undefined
}
>
{search.results.map((result, idx) => (
<button
@ -117,6 +132,35 @@ export function PlaceSearchInput({
</button>
))}
</div>
);
return (
<div ref={wrapperRef} className="relative flex-1 min-w-0">
<input
ref={inputRef}
type="text"
value={search.query}
onChange={(e) => {
search.handleInputChange(e.target.value);
onInputChange?.();
}}
onFocus={() => {
if (search.results.length > 0) search.setOpen(true);
}}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
placeholder={placeholder}
className={inputClassName}
/>
{loading && (
<div
className={`absolute right-2 top-1/2 -translate-y-1/2 ${spinnerSize} border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin`}
/>
)}
{showDropdown && (portal
? createPortal(dropdown, document.body)
: <div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</div>
)}
</div>
);

View file

@ -8,7 +8,7 @@ interface UpgradeModalProps {
onLoginClick: () => void;
onRegisterClick: () => void;
onStartCheckout: () => Promise<void>;
onDismiss: () => void;
onZoomToFreeZone: () => void;
}
export default function UpgradeModal({
@ -16,7 +16,7 @@ export default function UpgradeModal({
onLoginClick,
onRegisterClick,
onStartCheckout,
onDismiss,
onZoomToFreeZone,
}: UpgradeModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -56,7 +56,7 @@ export default function UpgradeModal({
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
{/* Close button */}
<button
onClick={onDismiss}
onClick={onZoomToFreeZone}
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
@ -96,7 +96,7 @@ export default function UpgradeModal({
{loading
? 'Redirecting...'
: isFree
? 'Claim free license'
? 'Claim free access'
: `Upgrade for ${priceLabel}`}
</button>
) : (
@ -121,10 +121,10 @@ export default function UpgradeModal({
)}
<button
onClick={onDismiss}
onClick={onZoomToFreeZone}
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
>
Or zoom back into London
Or zoom back to demo area
</button>
</div>
</div>

View file

@ -1,15 +1,12 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { Page } from './Header';
export default function UserMenu({
user,
onLogout,
onPageChange,
}: {
user: AuthUser;
onLogout: () => void;
onPageChange: (page: Page) => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -46,15 +43,6 @@ export default function UserMenu({
</p>
</div>
<div className="p-1">
<button
onClick={() => {
setOpen(false);
onPageChange('account');
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
</button>
<button
onClick={() => {
setOpen(false);

View file

@ -1,11 +0,0 @@
interface IconProps {
className?: string;
}
export function AppleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
);
}

View file

@ -1,123 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, HexagonStatsResponse } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
interface UseAreaSummaryOptions {
stats: HexagonStatsResponse | null;
hexagonId: string | null;
isPostcode: boolean;
filters: FeatureFilters;
features: FeatureMeta[];
}
interface UseAreaSummaryResult {
summary: string;
loading: boolean;
error: string | null;
}
const FORBIDDEN_FEATURES = [
'% White',
'% Black',
'% Asian',
'% Mixed',
'% Other',
'Environmental risk',
'Collapsible deposits risk',
'Compressible ground risk',
'Landslide risk',
'Running sand risk',
'Shrink-swell risk',
'Soluble rocks risk',
];
export function useAreaSummary({
stats,
hexagonId,
isPostcode,
filters,
features,
}: UseAreaSummaryOptions): UseAreaSummaryResult {
const [summary, setSummary] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchSummary = useCallback(async () => {
if (!stats || !hexagonId) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setSummary('');
setLoading(true);
setError(null);
try {
const filterDescriptions: string[] = [];
for (const [name, value] of Object.entries(filters)) {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
filterDescriptions.push(`${name}: ${(value as string[]).join(', ')}`);
} else {
const [min, max] = value as [number, number];
filterDescriptions.push(`${name}: ${min}${max}`);
}
}
const body = {
count: stats.count,
location: hexagonId,
is_postcode: isPostcode,
filters: filterDescriptions,
numeric_stats: stats.numeric_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
counts: f.counts,
})),
};
const url = apiUrl('area-summary');
const response = await fetch(
url,
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
const json = await response.json();
setSummary(json.summary || '');
setLoading(false);
} catch (err) {
if (controller.signal.aborted) return;
logNonAbortError('area-summary', err);
setError(err instanceof Error ? err.message : 'Failed to generate summary');
setLoading(false);
}
}, [stats, hexagonId, isPostcode, filters, features]);
useEffect(() => {
fetchSummary();
return () => {
abortRef.current?.abort();
};
}, [fetchSummary]);
return { summary, loading, error };
}

View file

@ -46,10 +46,9 @@ interface UseDeckLayersProps {
selectedPostcodeGeometry?: PostcodeGeometry | null;
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
export interface PopupInfo {
interface PopupInfo {
x: number;
y: number;
name: string;
@ -57,17 +56,6 @@ export interface PopupInfo {
id: string;
}
/** Find the primary travel time entry: first entry with a slug and color range. */
function getPrimaryTravelIndex(
entries: TravelTimeEntry[],
colorRanges: Map<number, [number, number]>
): number {
for (let i = 0; i < entries.length; i++) {
if (entries[i].slug && colorRanges.has(i)) return i;
}
return -1;
}
export function useDeckLayers({
data,
postcodeData,
@ -85,7 +73,6 @@ export function useDeckLayers({
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEntries = [],
travelTimeColorRanges = new Map(),
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -124,15 +111,6 @@ export function useDeckLayers({
const travelTimeEntriesRef = useRef(travelTimeEntries);
travelTimeEntriesRef.current = travelTimeEntries;
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
travelTimeColorRangesRef.current = travelTimeColorRanges;
const primaryTravelIndex = useMemo(
() => getPrimaryTravelIndex(travelTimeEntries, travelTimeColorRanges),
[travelTimeEntries, travelTimeColorRanges]
);
const primaryTravelIndexRef = useRef(primaryTravelIndex);
primaryTravelIndexRef.current = primaryTravelIndex;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -263,11 +241,10 @@ export function useDeckLayers({
const parts: string[] = [];
for (let i = 0; i < travelTimeEntries.length; i++) {
const entry = travelTimeEntries[i];
const cr = travelTimeColorRanges.get(i);
parts.push(`${i}:${entry.slug}|${cr?.[0]}|${cr?.[1]}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
parts.push(`${i}:${entry.slug}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
}
return parts.join(';');
}, [travelTimeEntries, travelTimeColorRanges]);
}, [travelTimeEntries]);
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
@ -281,21 +258,9 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const pti = primaryTravelIndexRef.current;
const entries = travelTimeEntriesRef.current;
const colorRanges = travelTimeColorRangesRef.current;
// Travel time coloring: primary entry colors, others dim-filter
if (pti >= 0) {
const primaryEntry = entries[pti];
const fieldKey = travelFieldKey(primaryEntry);
const ttVal = d[`avg_${fieldKey}`];
const ttClr = colorRanges.get(pti);
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
// Check all entries with time ranges as filters
// Dim-filter: all travel entries with timeRange dim hexagons outside range
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
@ -306,12 +271,23 @@ export function useDeckLayers({
}
}
if (ttClr) {
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr) {
// Travel time feature: dim hexagons with no data
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
return getFeatureFillColor(
ttVal as number,
ttVal as number,
ttVal as number,
ttClr,
clr,
null,
0,
densityGradientRef.current,
@ -319,12 +295,9 @@ export function useDeckLayers({
255
);
}
}
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && clr && cfm) {
// Regular feature
if (cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined;
const maxVal = d[`max_${vf}`] as number | undefined;
@ -340,6 +313,9 @@ export function useDeckLayers({
255
);
}
}
// Density fallback
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
@ -560,6 +536,5 @@ export function useDeckLayers({
colorFeatureMeta,
handleMouseLeave,
hoveredPostcode,
primaryTravelIndex,
};
}

View file

@ -131,6 +131,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);
const handleSetPin = useCallback((name: string) => {
setPinnedFeature(name);
}, []);
const handleCancelPin = useCallback(() => {
setPinnedFeature(null);
}, []);
@ -158,6 +162,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
handleDragChange,
handleDragEnd,
handleTogglePin,
handleSetPin,
handleCancelPin,
updateBoundsInfo,
};

View file

@ -4,7 +4,7 @@ import { authHeaders, logNonAbortError } from '../lib/api';
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
export function looksLikePostcode(s: string) {
function looksLikePostcode(s: string) {
return POSTCODE_RE.test(s.trim());
}

View file

@ -11,7 +11,7 @@ import type {
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
import { type TravelTimeEntry } from './useTravelTime';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -107,7 +107,7 @@ export function useMapData({
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || '');
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
const res = await fetch(
apiUrl('postcodes', params),
authHeaders({
@ -133,7 +133,7 @@ export function useMapData({
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || '');
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
if (travelParam) {
params.set('travel', travelParam);
}
@ -176,14 +176,18 @@ export function useMapData({
// Compute p5/p95 from visible data for the viewed feature
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const isTravelTime = viewFeature.startsWith('tt_');
if (!isTravelTime) {
const meta = features.find((f) => f.name === viewFeature);
if (!meta || meta.type === 'enum') return null;
if (activeFeature && !dragData) return null;
}
const vals: number[] = [];
if (usePostcodeView) {
if (usePostcodeView && !isTravelTime) {
if (postcodeData.length === 0) return null;
for (const feat of postcodeData) {
if (bounds) {
@ -218,6 +222,12 @@ export function useMapData({
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
// Travel time keys: use dataRange directly (no FeatureMeta)
if (viewFeature.startsWith('tt_')) {
return dataRange;
}
const meta = features.find((f) => f.name === viewFeature);
if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
@ -229,33 +239,6 @@ export function useMapData({
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
// Color ranges for travel time per entry (computed from response data)
const travelTimeColorRanges = useMemo((): Map<number, [number, number]> => {
const ranges = new Map<number, [number, number]>();
for (let i = 0; i < travelTimeEntries.length; i++) {
const entry = travelTimeEntries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges.set(i, [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
]);
}
return ranges;
}, [travelTimeEntries, data, bounds]);
const handleViewChange = useCallback(
({
resolution: newRes,
@ -295,7 +278,6 @@ export function useMapData({
currentView,
usePostcodeView,
colorRange,
travelTimeColorRanges,
handleViewChange,
setInitialView,
licenseRequired,

View file

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { apiUrl } from '../lib/api';
import { apiUrl, authHeaders } from '../lib/api';
export interface SavedSearch {
id: string;
@ -53,7 +53,7 @@ export function useSavedSearches(userId: string | null) {
// Capture a screenshot via the screenshot endpoint
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
const screenshotRes = await fetch(screenshotUrl);
const screenshotRes = await fetch(screenshotUrl, authHeaders());
if (!screenshotRes.ok) {
throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`);
}

View file

@ -18,11 +18,6 @@ export interface TravelTimeEntry {
timeRange: [number, number] | null;
}
/** Unique key for a travel time entry */
export function travelEntryKey(entry: TravelTimeEntry): string {
return `${entry.mode}:${entry.slug}`;
}
/** Field key matching the backend response: tt_{mode}_{slug} */
export function travelFieldKey(entry: TravelTimeEntry): string {
return `tt_${entry.mode}_${entry.slug}`;

View file

@ -124,6 +124,36 @@ h3 {
transition-delay: 0.2s, 0s;
}
/* Aurora gradient animation for pricing hero */
@keyframes aurora-1 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(30px, -20px) scale(1.1); }
66% { transform: translate(-20px, 15px) scale(0.9); }
}
@keyframes aurora-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(-40px, 20px) scale(1.15); }
66% { transform: translate(25px, -30px) scale(0.95); }
}
@keyframes aurora-3 {
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
50% { transform: translate(20px, 25px) scale(1.1) rotate(3deg); }
}
@keyframes aurora-4 {
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
40% { transform: translate(-35px, -15px) scale(1.2) rotate(-2deg); }
70% { transform: translate(15px, 20px) scale(0.9) rotate(1deg); }
}
@keyframes aurora-5 {
0%, 100% { transform: translate(0, 0) scale(1); }
30% { transform: translate(25px, 30px) scale(1.15); }
60% { transform: translate(-30px, -10px) scale(0.95); }
}
/* Hide scrollbar for pill groups on mobile */
.scrollbar-hide {
-ms-overflow-style: none;

View file

@ -13,10 +13,13 @@ export const MAP_MIN_ZOOM = 5.5;
export const BUFFER_MULTIPLIER = 1.5;
/** Inner London free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */
export const FREE_ZONE_BOUNDS = { south: 51.48, west: -0.18, north: 51.54, east: -0.02 };
export const INITIAL_VIEW_STATE: ViewState = {
longitude: -1.5,
latitude: 53.5,
zoom: 6,
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
zoom: 14,
pitch: 0,
};
@ -42,7 +45,7 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
{ t: 1, color: [142, 68, 173] },
];
/** Property density gradient — light mode (cream → orange) */
/** Number of properties gradient — light mode (cream → orange) */
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [255, 255, 255] },
{ t: 0.1, color: [248, 233, 211] },
@ -51,7 +54,7 @@ export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[]
{ t: 1, color: [255, 162, 31] },
];
/** Property density gradient — dark mode (dark warm → bright amber) */
/** Number of properties gradient — dark mode (dark warm → bright amber) */
export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [55, 45, 35] },
{ t: 0.1, color: [85, 65, 40] },

View file

@ -1,4 +1,4 @@
export interface ValueFormat {
interface ValueFormat {
prefix?: string;
suffix?: string;
/** Show full integer (no k/M abbreviation) */

View file

@ -63,7 +63,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
} as StyleSpecification;
}
export type GradientStop = { t: number; color: [number, number, number] };
type GradientStop = { t: number; color: [number, number, number] };
// Oklab color space for perceptually uniform interpolation
function srgbToLinear(c: number): number {
@ -130,11 +130,11 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
return gradient[gradient.length - 1].color;
}
export function normalizedToColor(t: number): [number, number, number] {
function normalizedToColor(t: number): [number, number, number] {
return interpolateGradient(t, FEATURE_GRADIENT);
}
export function countToColor(
function countToColor(
t: number,
gradient: GradientStop[] = DENSITY_GRADIENT
): [number, number, number] {

View file

@ -123,6 +123,10 @@ export interface Property {
duration?: string;
current_energy_rating?: string;
potential_energy_rating?: string;
listing_status?: string;
listing_url?: string;
property_sub_type?: string;
price_qualifier?: string;
// Numeric fields
lat: number;
@ -130,9 +134,10 @@ export interface Property {
is_construction_date_approximate?: boolean;
renovation_history?: RenovationEvent[];
listing_features?: string[];
// All other numeric features (dynamic, including construction_age_band)
[key: string]: string | number | boolean | RenovationEvent[] | undefined;
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
}
export interface HexagonPropertiesResponse {

View file

@ -93,7 +93,7 @@ module.exports = (env, argv) => {
},
proxy: [
{
context: ['/api'],
context: ['/api', '/s'],
target: process.env.API_PROXY_TARGET || 'http://localhost:8001',
},
{

View file

@ -1 +0,0 @@
IS_SANDBOX=1 claude --dangerously-skip-permissions

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View file

@ -1,417 +0,0 @@
"""Add online buy/rent listings to wide.parquet as new rows.
Matches online listings to existing historical rows by postcode + fuzzy address,
carrying over historical prices and area-level data for matched properties.
Unmatched listings get area data from any same-postcode row in wide.
Modifies wide.parquet in-place, adding:
- A `Listing status` column to all rows ("Historical sale" / "For sale" / "For rent")
- New columns: Asking price, Asking rent (monthly), Bedrooms, Bathrooms,
Listing date, Property sub-type, Listing URL, Price qualifier
"""
import argparse
import re
from concurrent.futures import ProcessPoolExecutor
from os import cpu_count
from pathlib import Path
import polars as pl
from thefuzz import fuzz
from tqdm import tqdm
from pipeline.utils.fuzzy_join import _numbers_compatible
_NORMALIZE_RE = re.compile(r"[,.\-]")
_WHITESPACE_RE = re.compile(r"\s+")
# Columns that are property-specific (carried from matched historical row only)
_PROPERTY_COLUMNS = [
"Last known price",
"Date of last transaction",
"historical_prices",
"renovation_history",
"Construction age",
"Is construction date approximate",
"Current energy rating",
"Potential energy rating",
"Address per EPC",
"Interior height (m)",
"Number of bedrooms & living rooms",
"Price per sqm",
"Estimated current price",
"Est. price per sqm",
]
# Columns that are area-level (carried from matched row, or any same-postcode row)
_AREA_COLUMNS = [
"Public transport to Bank (mins)",
"Cycling to Bank (mins)",
"Public transport to Fitzrovia (mins)",
"Cycling to Fitzrovia (mins)",
"Income Score (rate)",
"Employment Score (rate)",
"Education, Skills and Training Score",
"Health Deprivation and Disability Score",
"Living Environment Score",
"Indoors Sub-domain Score",
"Outdoors Sub-domain Score",
"% Asian",
"% Black",
"% Mixed",
"% White",
"% Other",
"Estimated monthly rent",
"Criminal damage and arson (avg/yr)",
"Violence and sexual offences (avg/yr)",
"Drugs (avg/yr)",
"Anti-social behaviour (avg/yr)",
"Public order (avg/yr)",
"Other crime (avg/yr)",
"Burglary (avg/yr)",
"Vehicle crime (avg/yr)",
"Theft from the person (avg/yr)",
"Possession of weapons (avg/yr)",
"Other theft (avg/yr)",
"Shoplifting (avg/yr)",
"Bicycle theft (avg/yr)",
"Robbery (avg/yr)",
"Serious crime (avg/yr)",
"Minor crime (avg/yr)",
"Number of restaurants within 2km",
"Number of grocery shops and supermarkets within 2km",
"Number of parks within 2km",
"Number of public transport stations within 2km",
"Noise (dB)",
"Good+ primary schools within 5km",
"Good+ secondary schools within 5km",
"Max available download speed (Mbps)",
"Collapsible deposits risk",
"Compressible ground risk",
"Landslide risk",
"Running sand risk",
"Shrink-swell risk",
"Soluble rocks risk",
"Environmental risk",
]
def _normalize(s: str) -> str:
return _WHITESPACE_RE.sub(" ", _NORMALIZE_RE.sub(" ", s.upper())).strip()
def _score_bucket(
args: tuple[list[tuple[int, str]], list[tuple[int, str]]],
) -> list[tuple[int, int, int]]:
"""Score all address pairs within a single postcode bucket."""
wide_entries, online_entries = args
pairs = []
for wide_idx, wide_address in wide_entries:
for online_idx, online_address in online_entries:
if not _numbers_compatible(wide_address, online_address):
continue
score = fuzz.token_sort_ratio(wide_address, online_address)
pairs.append((score, online_idx, wide_idx))
return pairs
def _load_online(buy_path: Path, rent_path: Path) -> pl.DataFrame:
"""Load buy + rent parquets, tag with channel, normalize rent to monthly."""
buy = pl.scan_parquet(buy_path).with_columns(
pl.lit("For sale").alias("_channel"),
)
rent = pl.scan_parquet(rent_path).with_columns(
pl.lit("For rent").alias("_channel"),
)
online = pl.concat([buy, rent]).collect()
# Normalize rent prices to monthly
freq = online["price_frequency"]
price = online["price"].cast(pl.Float64)
monthly_price = (
pl.when(freq == "weekly")
.then(price * 52.0 / 12.0)
.when(freq == "yearly")
.then(price / 12.0)
.when(freq == "daily")
.then(price * 365.25 / 12.0)
.when(freq == "quarterly")
.then(price / 3.0)
.otherwise(price) # monthly, not specified
.round(0)
.cast(pl.Int64)
)
online = online.with_columns(
pl.when(pl.col("_channel") == "For sale")
.then(pl.col("price"))
.otherwise(None)
.alias("Asking price"),
pl.when(pl.col("_channel") == "For rent")
.then(monthly_price)
.otherwise(None)
.alias("Asking rent (monthly)"),
)
return online
def _match_online_to_wide(
wide: pl.DataFrame,
online: pl.DataFrame,
) -> dict[int, int]:
"""Match online listings to wide rows by postcode + fuzzy address.
Returns dict mapping online row index wide row index.
"""
# Build postcode → [(row_idx, normalized_address)] for wide
wide_postcodes = wide["Postcode"]
wide_addresses = wide["Address per Property Register"]
wide_by_postcode: dict[str, list[tuple[int, str]]] = {}
for i in range(wide.height):
pc = wide_postcodes[i]
addr = wide_addresses[i]
if pc is not None and addr is not None:
pc_upper = pc.strip().upper()
wide_by_postcode.setdefault(pc_upper, []).append((i, _normalize(addr)))
# Build postcode → [(row_idx, normalized_address)] for online
online_postcodes = online["postcode"]
online_addresses = online["address"]
online_by_postcode: dict[str, list[tuple[int, str]]] = {}
for i in range(online.height):
pc = online_postcodes[i]
addr = online_addresses[i]
if pc is not None and addr is not None:
pc_upper = pc.strip().upper()
online_by_postcode.setdefault(pc_upper, []).append((i, _normalize(addr)))
# Build tasks: only postcodes present in both
tasks = [
(wide_by_postcode[pc], online_entries)
for pc, online_entries in online_by_postcode.items()
if pc in wide_by_postcode
]
# Score in parallel
all_pairs: list[tuple[int, int, int]] = []
with ProcessPoolExecutor(max_workers=cpu_count()) as executor:
for pairs in tqdm(
executor.map(_score_bucket, tasks, chunksize=64),
total=len(tasks),
desc="Matching online listings",
):
all_pairs.extend(pairs)
del tasks, wide_by_postcode, online_by_postcode
# Greedy assignment: best score first, one-to-one
all_pairs.sort(key=lambda t: (t[0], -t[1]), reverse=True)
matches: dict[int, int] = {} # online_idx → wide_idx
matched_wide: set[int] = set()
for _score, online_idx, wide_idx in all_pairs:
if online_idx in matches or wide_idx in matched_wide:
continue
matches[online_idx] = wide_idx
matched_wide.add(wide_idx)
return matches
def _build_postcode_area_lookup(wide: pl.DataFrame) -> dict[str, int]:
"""Build postcode → first row index for area data fallback."""
postcodes = wide["Postcode"]
lookup: dict[str, int] = {}
for i in range(wide.height):
pc = postcodes[i]
if pc is not None:
pc_upper = pc.strip().upper()
if pc_upper not in lookup:
lookup[pc_upper] = i
return lookup
def _build_online_rows(
wide: pl.DataFrame,
online: pl.DataFrame,
matches: dict[int, int],
postcode_lookup: dict[str, int],
) -> pl.DataFrame:
"""Build a DataFrame of online listing rows with all wide.parquet columns."""
wide_schema = wide.schema
n = online.height
# Initialize all columns as null lists
columns: dict[str, list] = {col: [None] * n for col in wide_schema}
# Add new columns
columns["Listing status"] = [None] * n
columns["Asking price"] = [None] * n
columns["Asking rent (monthly)"] = [None] * n
columns["Bedrooms"] = [None] * n
columns["Bathrooms"] = [None] * n
columns["Listing date"] = [None] * n
columns["Property sub-type"] = [None] * n
columns["Listing URL"] = [None] * n
columns["Price qualifier"] = [None] * n
for i in range(n):
# Direct mappings from online listing
columns["Address per Property Register"][i] = online["address"][i]
columns["Postcode"][i] = online["postcode"][i]
columns["lat"][i] = online["latitude"][i]
columns["lon"][i] = online["longitude"][i]
columns["Property type"][i] = online["property_type"][i]
columns["Leashold/Freehold"][i] = online["tenure"][i]
columns["Total floor area (sqm)"][i] = online["floorspace_sqm"][i]
# New columns
columns["Listing status"][i] = online["_channel"][i]
columns["Asking price"][i] = online["Asking price"][i]
columns["Asking rent (monthly)"][i] = online["Asking rent (monthly)"][i]
columns["Bedrooms"][i] = online["bedrooms"][i]
columns["Bathrooms"][i] = online["bathrooms"][i]
columns["Property sub-type"][i] = online["property_sub_type"][i]
columns["Listing URL"][i] = online["url"][i]
columns["Price qualifier"][i] = online["price_qualifier"][i]
# Parse listing date
fvd = online["first_visible_date"][i]
if fvd is not None:
try:
from datetime import datetime
dt = datetime.fromisoformat(fvd.replace("Z", "+00:00"))
columns["Listing date"][i] = dt.replace(tzinfo=None)
except (ValueError, TypeError):
pass
# Determine source row for carried data
matched_wide_idx = matches.get(i)
postcode = online["postcode"][i]
pc_upper = postcode.strip().upper() if postcode else None
area_source_idx = matched_wide_idx
if area_source_idx is None and pc_upper is not None:
area_source_idx = postcode_lookup.get(pc_upper)
# Copy property-specific columns from matched row only
if matched_wide_idx is not None:
for col in _PROPERTY_COLUMNS:
if col in wide_schema:
columns[col][i] = wide[col][matched_wide_idx]
# Copy area columns from matched row or same-postcode fallback
if area_source_idx is not None:
for col in _AREA_COLUMNS:
if col in wide_schema:
columns[col][i] = wide[col][area_source_idx]
# Build DataFrame with correct types
series_list = []
for col_name, dtype in wide_schema.items():
series_list.append(pl.Series(col_name, columns[col_name], dtype=dtype))
# New columns with their types
series_list.append(
pl.Series("Listing status", columns["Listing status"], dtype=pl.String)
)
series_list.append(
pl.Series("Asking price", columns["Asking price"], dtype=pl.Int64)
)
series_list.append(
pl.Series(
"Asking rent (monthly)", columns["Asking rent (monthly)"], dtype=pl.Int64
)
)
series_list.append(pl.Series("Bedrooms", columns["Bedrooms"], dtype=pl.Int32))
series_list.append(pl.Series("Bathrooms", columns["Bathrooms"], dtype=pl.Int32))
series_list.append(
pl.Series("Listing date", columns["Listing date"], dtype=pl.Datetime("us"))
)
series_list.append(
pl.Series("Property sub-type", columns["Property sub-type"], dtype=pl.String)
)
series_list.append(
pl.Series("Listing URL", columns["Listing URL"], dtype=pl.String)
)
series_list.append(
pl.Series("Price qualifier", columns["Price qualifier"], dtype=pl.String)
)
return pl.DataFrame(series_list)
def main():
parser = argparse.ArgumentParser(
description="Add online buy/rent listings to wide.parquet"
)
parser.add_argument(
"--input",
type=Path,
required=True,
help="wide.parquet path (modified in-place)",
)
parser.add_argument(
"--buy", type=Path, required=True, help="rightmove_buy.parquet path"
)
parser.add_argument(
"--rent", type=Path, required=True, help="rightmove_rent.parquet path"
)
args = parser.parse_args()
print("Loading wide.parquet...")
wide = pl.read_parquet(args.input)
print(f" {wide.height} rows, {wide.width} columns")
print("Loading online listings...")
online = _load_online(args.buy, args.rent)
print(
f" {online.height} online listings ({online.filter(pl.col('_channel') == 'For sale').height} buy, {online.filter(pl.col('_channel') == 'For rent').height} rent)"
)
print("Matching online listings to historical rows...")
matches = _match_online_to_wide(wide, online)
print(f" {len(matches)} online listings matched to historical rows")
print("Building postcode area lookup...")
postcode_lookup = _build_postcode_area_lookup(wide)
print("Building online listing rows...")
online_rows = _build_online_rows(wide, online, matches, postcode_lookup)
print(f" {online_rows.height} online rows built")
# Add Listing status + new columns to existing wide rows
wide = wide.with_columns(
pl.lit("Historical sale").alias("Listing status"),
pl.lit(None, dtype=pl.Int64).alias("Asking price"),
pl.lit(None, dtype=pl.Int64).alias("Asking rent (monthly)"),
pl.lit(None, dtype=pl.Int32).alias("Bedrooms"),
pl.lit(None, dtype=pl.Int32).alias("Bathrooms"),
pl.lit(None, dtype=pl.Datetime("us")).alias("Listing date"),
pl.lit(None, dtype=pl.String).alias("Property sub-type"),
pl.lit(None, dtype=pl.String).alias("Listing URL"),
pl.lit(None, dtype=pl.String).alias("Price qualifier"),
)
# Concat
result = pl.concat([wide, online_rows], how="diagonal_relaxed")
print(f"Final: {result.height} rows, {result.width} columns")
# Verify
status_counts = (
result["Listing status"].value_counts().sort("count", descending=True)
)
print(f"Listing status distribution:\n{status_counts}")
result.write_parquet(args.input)
size_mb = args.input.stat().st_size / (1024 * 1024)
print(f"Wrote {args.input} ({size_mb:.1f} MB)")
if __name__ == "__main__":
main()

View file

@ -30,7 +30,69 @@ def _join_journey_times(
return wide.join(journey_times, on="postcode", how="left")
def _build_wide(
_AREA_COLUMNS = [
"Postcode",
"lat",
"lon",
# Transport
"Public transport to Bank (mins)",
"Cycling to Bank (mins)",
"Public transport to Fitzrovia (mins)",
"Cycling to Fitzrovia (mins)",
# Deprivation
"Income Score (rate)",
"Employment Score (rate)",
"Education, Skills and Training Score",
"Health Deprivation and Disability Score",
"Living Environment Score",
"Indoors Sub-domain Score",
"Outdoors Sub-domain Score",
# Ethnicity
"% Asian",
"% Black",
"% Mixed",
"% White",
"% Other",
# Crime
"Anti-social behaviour (avg/yr)",
"Violence and sexual offences (avg/yr)",
"Criminal damage and arson (avg/yr)",
"Burglary (avg/yr)",
"Vehicle crime (avg/yr)",
"Robbery (avg/yr)",
"Other theft (avg/yr)",
"Shoplifting (avg/yr)",
"Drugs (avg/yr)",
"Possession of weapons (avg/yr)",
"Public order (avg/yr)",
"Bicycle theft (avg/yr)",
"Theft from the person (avg/yr)",
"Other crime (avg/yr)",
"Serious crime (avg/yr)",
"Minor crime (avg/yr)",
# Amenities
"Number of restaurants within 2km",
"Number of grocery shops and supermarkets within 2km",
"Number of parks within 2km",
"Number of public transport stations within 2km",
# Environment
"Noise (dB)",
"Max available download speed (Mbps)",
# Schools
"Good+ primary schools within 5km",
"Good+ secondary schools within 5km",
# GeoSure
"Environmental risk",
"Collapsible deposits risk",
"Compressible ground risk",
"Landslide risk",
"Running sand risk",
"Shrink-swell risk",
"Soluble rocks risk",
]
def _build(
epc_pp_path: Path,
arcgis_path: Path,
iod_path: Path,
@ -44,8 +106,11 @@ def _build_wide(
broadband_path: Path,
geosure_path: Path,
rental_prices_path: Path,
) -> pl.DataFrame:
"""Build the wide dataframe by joining epc_pp with all auxiliary data."""
) -> tuple[pl.DataFrame, pl.DataFrame]:
"""Build postcode and properties dataframes from epc_pp + auxiliary data.
Returns (postcode_df, properties_df).
"""
wide = pl.scan_parquet(epc_pp_path).filter(
pl.col("total_floor_area").is_null()
| (pl.col("total_floor_area") > MIN_FLOOR_AREA_M2)
@ -180,7 +245,7 @@ def _build_wide(
.group_by("bb_postcode")
.agg(pl.col("max_download_speed").max())
)
wide = wide.join(broadband, left_on="postcode", right_on="bb_postcode", how="left").drop("bb_postcode")
wide = wide.join(broadband, left_on="postcode", right_on="bb_postcode", how="left")
geosure = pl.scan_parquet(geosure_path)
wide = wide.join(geosure, on="postcode", how="left")
@ -280,7 +345,18 @@ def _build_wide(
)
print("Collecting with streaming engine...")
return wide.collect(engine="streaming")
df = wide.collect(engine="streaming")
# Split into postcode-level and property-level dataframes
area_cols = [c for c in _AREA_COLUMNS if c in df.columns]
postcode_df = df.select(area_cols).group_by("Postcode").first()
print(f"Postcode rows: {postcode_df.height} (unique postcodes)")
property_cols = [c for c in df.columns if c not in _AREA_COLUMNS or c == "Postcode"]
properties_df = df.select(property_cols)
print(f"Property rows: {properties_df.height}")
return postcode_df, properties_df
def main():
@ -356,11 +432,14 @@ def main():
help="ONS rental prices by LA and bedroom count parquet file",
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
"--output-postcodes", type=Path, required=True, help="Output postcode parquet file path"
)
parser.add_argument(
"--output-properties", type=Path, required=True, help="Output properties parquet file path"
)
args = parser.parse_args()
wide = _build_wide(
postcode_df, properties_df = _build(
epc_pp_path=args.epc_pp,
arcgis_path=args.arcgis,
iod_path=args.iod,
@ -376,13 +455,17 @@ def main():
rental_prices_path=args.rental_prices,
)
print(f"Columns: {wide.columns}")
print(f"Rows: {wide.height}")
print(f"\nPostcode columns: {postcode_df.columns}")
print(f"Postcode rows: {postcode_df.height}")
postcode_df.write_parquet(args.output_postcodes)
size_mb = args.output_postcodes.stat().st_size / (1024 * 1024)
print(f"Wrote {args.output_postcodes} ({size_mb:.1f} MB)")
wide.write_parquet(args.output)
size_mb = args.output.stat().st_size / (1024 * 1024)
print(f"Wrote {args.output} ({size_mb:.1f} MB)")
print(f"\nProperty columns: {properties_df.columns}")
print(f"Property rows: {properties_df.height}")
properties_df.write_parquet(args.output_properties)
size_mb = args.output_properties.stat().st_size / (1024 * 1024)
print(f"Wrote {args.output_properties} ({size_mb:.1f} MB)")
if __name__ == "__main__":

View file

@ -20,7 +20,6 @@ from pipeline.transform.price_estimation.knn import (
from pipeline.transform.price_estimation.utils import (
CURRENT_YEAR,
MAX_LOG_ADJUSTMENT,
compute_seasonal_factors,
interpolate_log_index,
sector_expr,
type_group_expr,
@ -91,7 +90,7 @@ def extract_test_set(input_path: Path) -> pl.DataFrame:
def predict(test: pl.DataFrame, index: pl.DataFrame) -> pl.DataFrame:
"""Index-based prediction with interpolation, capping, and seasonal adjustment."""
"""Index-based prediction with interpolation and capping."""
test = interpolate_log_index(
index, test, "sector", "type_group", "input_frac_year", "log_index_input"
)
@ -105,7 +104,6 @@ def predict(test: pl.DataFrame, index: pl.DataFrame) -> pl.DataFrame:
* (pl.col("log_index_actual") - pl.col("log_index_input"))
.clip(-MAX_LOG_ADJUSTMENT, MAX_LOG_ADJUSTMENT)
.exp()
* pl.col("_seasonal_adj")
)
.fill_null(pl.col("input_price").cast(pl.Float64))
.alias("predicted"),
@ -175,7 +173,10 @@ def print_metrics_table(metrics_by_stage: dict):
def main():
parser = argparse.ArgumentParser(description="Backtest price estimation model")
parser.add_argument(
"--input", type=Path, required=True, help="Path to wide.parquet"
"--input", type=Path, required=True, help="Path to properties.parquet"
)
parser.add_argument(
"--postcodes", type=Path, required=True, help="Path to postcode.parquet (for lat/lon)"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output backtest_results.parquet"
@ -184,38 +185,28 @@ def main():
# Build index from pre-test data only (temporal holdout)
print(f"Building price index (pairs with year2 < {TEST_YEAR_MIN})...")
index = build_index(args.input, max_pair_year=TEST_YEAR_MIN)
index = build_index(args.input, max_pair_year=TEST_YEAR_MIN, postcodes_path=args.postcodes)
print(
f"\nHoldout index: {len(index):,} rows, {index['sector'].n_unique():,} sectors, "
f"{index['type_group'].n_unique()} type groups"
)
# Compute seasonal factors from pre-test data only
seasonal = compute_seasonal_factors(args.input, max_sale_year=TEST_YEAR_MIN)
months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
print(
f"Seasonal factors: {', '.join(f'{m}={f:.3f}' for m, f in zip(months, seasonal))}"
)
test = extract_test_set(args.input)
# Compute seasonal adjustment for each test pair
input_months = test["input_month"].fill_null(6).to_numpy().astype(np.int32)
actual_months = test["actual_month"].fill_null(6).to_numpy().astype(np.int32)
seasonal_adj = seasonal[actual_months - 1] / seasonal[input_months - 1]
test = test.with_columns(
pl.Series("_seasonal_adj", seasonal_adj, dtype=pl.Float64),
)
# Join lat/lon from postcode.parquet (properties.parquet no longer has them)
postcodes = pl.read_parquet(args.postcodes).select("Postcode", "lat", "lon")
test = test.join(postcodes, on="Postcode", how="left")
print("\nPredicting with price index...")
test = predict(test, index)
# --- kNN ---
ref_fy = float(TEST_YEAR_MIN)
trees = build_knn_pool(args.input, index, ref_fy, max_sale_year=TEST_YEAR_MIN)
# Pass joined LazyFrame (with lat/lon) instead of raw properties path
pool_lf = pl.scan_parquet(args.input).join(
postcodes.lazy(), on="Postcode", how="left"
)
trees = build_knn_pool(pool_lf, index, ref_fy, max_sale_year=TEST_YEAR_MIN)
# Interpolate log_index at reference year for temporal adjustment
test = test.with_columns(pl.lit(ref_fy).alias("_ref_fy"))

View file

@ -1,19 +1,18 @@
"""Augment wide.parquet with estimated current prices.
"""Augment properties.parquet with estimated current prices.
For properties with a known prior sale, applies the repeat-sales price index
to adjust the last known price to the current date, then blends with kNN
estimates from nearby recently-sold properties. Includes:
- Capping extreme index adjustments
- Seasonal month-of-sale adjustment
- kNN spatial blending
Modifies wide.parquet in-place.
Modifies properties.parquet in-place. Temporarily joins postcode.parquet
for lat/lon needed by kNN, then drops those columns before writing.
"""
import argparse
from pathlib import Path
import numpy as np
import polars as pl
from pipeline.transform.price_estimation.knn import (
@ -23,9 +22,7 @@ from pipeline.transform.price_estimation.knn import (
)
from pipeline.transform.price_estimation.utils import (
CURRENT_FRAC_YEAR,
CURRENT_MONTH,
MAX_LOG_ADJUSTMENT,
compute_seasonal_factors,
interpolate_log_index,
sector_expr,
type_group_expr,
@ -34,48 +31,39 @@ from pipeline.transform.price_estimation.utils import (
def main():
parser = argparse.ArgumentParser(
description="Augment wide.parquet with estimated current prices"
description="Augment properties.parquet with estimated current prices"
)
parser.add_argument(
"--input",
"--properties",
type=Path,
required=True,
help="Path to wide.parquet (modified in-place)",
help="Path to properties.parquet (modified in-place)",
)
parser.add_argument(
"--postcodes",
type=Path,
required=True,
help="Path to postcode.parquet (for lat/lon needed by kNN)",
)
parser.add_argument(
"--index", type=Path, required=True, help="Path to price_index.parquet"
)
args = parser.parse_args()
print("Loading wide.parquet...")
df = pl.read_parquet(args.input)
print("Loading properties.parquet...")
df = pl.read_parquet(args.properties)
print(f" {len(df):,} rows, {len(df.columns)} columns")
# Join lat/lon from postcode.parquet for kNN spatial queries
postcodes = pl.read_parquet(args.postcodes).select("Postcode", "lat", "lon")
df = df.join(postcodes, on="Postcode", how="left")
print(f" Joined lat/lon from {len(postcodes):,} postcodes")
# Drop existing estimated columns if re-running
for col in ["Estimated current price", "Est. price per sqm"]:
if col in df.columns:
df = df.drop(col)
# Compute seasonal factors
seasonal = compute_seasonal_factors(args.input)
months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
print(
f" Seasonal factors: {', '.join(f'{m}={f:.3f}' for m, f in zip(months, seasonal))}"
)
# Build seasonal adjustment: seasonal[current_month] / seasonal[sale_month]
sale_month = (
df["Date of last transaction"]
.dt.month()
.fill_null(6)
.to_numpy()
.astype(np.int32)
)
seasonal_adj = seasonal[CURRENT_MONTH - 1] / seasonal[sale_month - 1]
# Derive helper columns
df = df.with_columns(
sector_expr().alias("_sector"),
@ -86,7 +74,6 @@ def main():
).alias("_sale_frac_year"),
type_group_expr().alias("_type_group"),
pl.lit(CURRENT_FRAC_YEAR).alias("_current_frac_year"),
pl.Series("_seasonal_adj", seasonal_adj, dtype=pl.Float64),
)
index = pl.read_parquet(args.index)
@ -109,7 +96,7 @@ def main():
"_log_index_current_interp",
)
# Compute index-adjusted estimate with cap and seasonal adjustment
# Compute index-adjusted estimate with cap
has_price = (
pl.col("Last known price").is_not_null()
& pl.col("Postcode").is_not_null()
@ -125,7 +112,6 @@ def main():
)
.clip(-MAX_LOG_ADJUSTMENT, MAX_LOG_ADJUSTMENT)
.exp()
* pl.col("_seasonal_adj")
)
.otherwise(pl.lit(None))
.alias("Estimated current price"),
@ -140,7 +126,7 @@ def main():
# --- kNN blending ---
print("\nBuilding kNN estimates...")
trees = build_knn_pool(args.input, index, CURRENT_FRAC_YEAR)
trees = build_knn_pool(df.lazy(), index, CURRENT_FRAC_YEAR)
lat = df["lat"].cast(pl.Float64).to_numpy()
lon = df["lon"].cast(pl.Float64).to_numpy()
@ -188,13 +174,13 @@ def main():
.alias("Est. price per sqm"),
)
# Drop all temporary columns
# Drop all temporary columns and joined lat/lon (those belong in postcode.parquet)
temp_cols = [c for c in df.columns if c.startswith("_") or c.startswith("log_idx_")]
df = df.drop(temp_cols)
df = df.drop(temp_cols).drop("lat", "lon")
df.write_parquet(args.input)
size_mb = args.input.stat().st_size / (1024 * 1024)
print(f"\nWrote {args.input} ({size_mb:.1f} MB)")
df.write_parquet(args.properties)
size_mb = args.properties.stat().st_size / (1024 * 1024)
print(f"\nWrote {args.properties} ({size_mb:.1f} MB)")
print(
f" {len(df):,} rows, {len(df.columns)} columns (including 'Estimated current price')"
)

View file

@ -328,14 +328,19 @@ def forward_fill(index: dict, min_year: int, max_year: int) -> dict:
return filled
def build_index(input_path: Path, max_pair_year: int | None = None) -> pl.DataFrame:
def build_index(
input_path: Path,
max_pair_year: int | None = None,
postcodes_path: Path | None = None,
) -> pl.DataFrame:
"""Build the full price index from raw data.
If max_pair_year is set, only pairs before that year are used (backtesting holdout).
The index is still forward-filled to CURRENT_YEAR.
postcodes_path: if provided, lat/lon are read from this file instead of input_path.
"""
pairs = extract_pairs(input_path, max_year2=max_pair_year)
centroids = extract_centroids(input_path)
centroids = extract_centroids(postcodes_path or input_path)
min_year = int(pairs["year1"].min())
max_year = CURRENT_YEAR
@ -448,10 +453,12 @@ def main():
description="Build improved repeat-sales price index"
)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--postcodes", type=Path, required=True,
help="Path to postcode.parquet (for lat/lon centroids)")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
result = build_index(args.input)
result = build_index(args.input, postcodes_path=args.postcodes)
result.write_parquet(args.output)
size_mb = args.output.stat().st_size / (1024 * 1024)

View file

@ -29,7 +29,7 @@ def _scale_coords(lat: np.ndarray, lon: np.ndarray) -> np.ndarray:
def build_knn_pool(
input_path: Path,
source: Path | pl.LazyFrame,
index: pl.DataFrame,
ref_frac_year: float,
max_sale_year: int | None = None,
@ -42,8 +42,9 @@ def build_knn_pool(
Returns dict mapping type_group -> (KDTree over scaled lat/lon, adjusted_psm array).
"""
print("Building kNN pool...")
lf = pl.scan_parquet(source) if isinstance(source, Path) else source
query = (
pl.scan_parquet(input_path)
lf
.select(
"Postcode",
"Property type",

View file

@ -1,7 +1,6 @@
"""Shared utilities for price estimation modules."""
from datetime import date
from pathlib import Path
import numpy as np
import polars as pl
@ -9,7 +8,6 @@ import polars as pl
CURRENT_YEAR = 2026
_today = date.today()
CURRENT_FRAC_YEAR = _today.year + (_today.month - 1) / 12
CURRENT_MONTH = _today.month
# Cap on log(index_ratio) to prevent wild estimates from thin sectors
MAX_LOG_ADJUSTMENT = 3.0 # ~20x max price change
@ -181,53 +179,3 @@ def join_type_stratified_index(
).drop(_typed, _all)
return df
def compute_seasonal_factors(
input_path: Path, max_sale_year: int | None = None
) -> np.ndarray:
"""Compute 12 multiplicative monthly price factors from price-per-sqm.
Detrends by normalizing median £/sqm within each year, then averages
across years. Returns array of 12 factors (index 0 = January).
Normalized so mean = 1.0.
"""
query = (
pl.scan_parquet(input_path)
.select("Last known price", "Total floor area (sqm)", "Date of last transaction")
.filter(
pl.col("Last known price").is_not_null(),
pl.col("Last known price") > 0,
pl.col("Total floor area (sqm)").is_not_null(),
pl.col("Total floor area (sqm)") > 0,
pl.col("Date of last transaction").is_not_null(),
)
.with_columns(
(
pl.col("Last known price").cast(pl.Float64)
/ pl.col("Total floor area (sqm)").cast(pl.Float64)
).alias("psm"),
pl.col("Date of last transaction").dt.month().alias("month"),
pl.col("Date of last transaction").dt.year().alias("year"),
)
)
if max_sale_year is not None:
query = query.filter(pl.col("year") < max_sale_year)
monthly = (
query.group_by("year", "month")
.agg(pl.col("psm").median().alias("median_psm"))
.with_columns(
pl.col("median_psm").mean().over("year").alias("year_mean"),
)
.with_columns(
(pl.col("median_psm") / pl.col("year_mean")).alias("ratio"),
)
.group_by("month")
.agg(pl.col("ratio").mean().alias("factor"))
.sort("month")
.collect()
)
factors = monthly["factor"].to_numpy().astype(np.float64)
return factors / factors.mean()

View file

@ -28,7 +28,7 @@ import java.util.concurrent.atomic.AtomicInteger;
*/
public class App {
private static final String[] MODES = {"bicycle", "walking", "transit", "car"};
private static final String[] MODES = {"bicycle", "transit", "walking", "car"};
private static final int MAX_RETRIES = 2;
public static void main(String[] args) throws Exception {

65
server-rs/Cargo.lock generated
View file

@ -679,6 +679,15 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85d3cef41d236720ed453e102153a53e4cc3d2fde848c0078a50cf249e8e3e5b"
[[package]]
name = "deranged"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@ -1697,6 +1706,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-traits"
version = "0.2.19"
@ -2400,6 +2415,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -2446,6 +2467,7 @@ dependencies = [
"tower",
"tower-http",
"tracing",
"tracing-appender",
"tracing-subscriber",
"urlencoding",
]
@ -3314,6 +3336,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@ -3479,6 +3532,18 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
dependencies = [
"crossbeam-channel",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"

View file

@ -19,6 +19,7 @@ lasso = "0.7"
rustc-hash = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tracing-appender = "0.2"
metrics = "0.24"
metrics-exporter-prometheus = "0.16"
reqwest = { version = "0.12", features = ["rustls-tls", "json"] }

View file

@ -14,13 +14,15 @@ pub const MAX_PROPERTIES_LIMIT: usize = 500;
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
pub const AREA_SUMMARY_SYSTEM_PROMPT: &str = "You are an experienced estate agent with an expertise in area analysis. Help the user find his/her dream area or perfect postcode to settle in. The user is looking to buy a property based on the filters they provide. Given area statistics, write at most a single concise sentences summarising the key characteristics of the area. Be factual and highlight notable values. Do not use bullet points or headers — just flowing prose. Do not use markdown formatting. Highlight unusual facts that stand out from the average, but do not exaggerate. If there are no notable characteristics, say so. Always write at most a single sentence! Reason about the relation of different statistics to each other.";
pub const AREA_SUMMARY_MAX_TOKENS: usize = 300;
pub const AREA_SUMMARY_TEMPERATURE: f32 = 0.3;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
/// Inner London free zone bounds (south, west, north, east) — roughly zones 12.
/// Users without a license can only query data within these bounds.
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.48, -0.18, 51.54, -0.02);
/// Homepage demo center (lat, lng). Unlicensed hexagon requests are allowed
/// when the center of the requested bounds is within DEMO_CENTER_TOLERANCE of this point.
/// Must match DEMO_VIEW_START in ScrollStory.tsx.
pub const DEMO_CENTER: (f64, f64) = (52.2, -1.9);
pub const DEMO_CENTER_TOLERANCE: f64 = 1.0;

View file

@ -8,7 +8,7 @@ use std::path::Path;
use rustc_hash::FxHashMap;
use crate::consts::{H3_PRECOMPUTE_MAX, HISTOGRAM_BINS};
use crate::features::{self, Bounds, IGNORED_COLUMNS};
use crate::features::{self, Bounds};
fn is_numeric_dtype(dtype: &DataType) -> bool {
matches!(
@ -122,6 +122,13 @@ pub struct PropertyData {
/// Per-row renovation events. Keyed by (permuted) row index.
/// Only rows with events are present in the map.
renovation_history: FxHashMap<u32, Vec<RenovationEvent>>,
/// Per-row listing features (key feature bullet points from online listings).
/// Only rows with features are present in the map.
listing_features: FxHashMap<u32, Vec<String>>,
/// Per-row optional string columns from online listings.
listing_url: Vec<Option<String>>,
property_sub_type: Vec<Option<String>>,
price_qualifier: Vec<Option<String>>,
}
impl PropertyData {
@ -155,6 +162,29 @@ impl PropertyData {
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Get listing features for a given row (empty slice if none).
pub fn listing_features(&self, row: usize) -> &[String] {
self.listing_features
.get(&(row as u32))
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Get listing URL for a given row.
pub fn listing_url(&self, row: usize) -> Option<&str> {
self.listing_url[row].as_deref()
}
/// Get property sub-type for a given row.
pub fn property_sub_type(&self, row: usize) -> Option<&str> {
self.property_sub_type[row].as_deref()
}
/// Get price qualifier for a given row.
pub fn price_qualifier(&self, row: usize) -> Option<&str> {
self.price_qualifier[row].as_deref()
}
}
/// Compute a percentile from a uniformly-binned histogram.
@ -375,73 +405,226 @@ pub fn precompute_h3(lat: &[f32], lon: &[f32]) -> anyhow::Result<Vec<u64>> {
}
impl PropertyData {
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
tracing::info!("Loading parquet from {:?}", parquet_path);
pub fn load(
properties_path: &Path,
postcode_features_path: &Path,
listings_buy_path: &Path,
listings_rent_path: &Path,
) -> anyhow::Result<Self> {
// Load postcode.parquet
tracing::info!("Loading postcode features from {:?}", postcode_features_path);
let postcode_df = LazyFrame::scan_parquet(postcode_features_path, Default::default())
.context("Failed to scan postcode parquet")?
.collect()
.context("Failed to read postcode parquet")?;
tracing::info!(rows = postcode_df.height(), "Postcode features loaded");
let mut lf = LazyFrame::scan_parquet(parquet_path, Default::default())
.context("Failed to scan parquet")?;
let schema = lf.collect_schema().context("Failed to read schema")?;
// Load properties.parquet and join with postcode data for lat/lon + area features
tracing::info!("Loading properties from {:?}", properties_path);
let properties_lf = LazyFrame::scan_parquet(properties_path, Default::default())
.context("Failed to scan properties parquet")?
.with_columns([lit("Historical sale").alias("Listing status")]);
let properties_joined = properties_lf
.join(
postcode_df.clone().lazy(),
[col("Postcode")],
[col("Postcode")],
JoinArgs::new(JoinType::Left),
)
.collect()
.context("Failed to join properties with postcodes")?;
let prop_count = properties_joined.height();
tracing::info!(rows = prop_count, "Properties joined with postcodes");
// Load online listings (buy + rent) — these have their own lat/lon.
// Normalize column names from finder output to server-expected names.
// strict=false: columns already using the new name are silently skipped.
let load_listings = |path: &Path, label: &str| -> anyhow::Result<DataFrame> {
tracing::info!("Loading {} listings from {:?}", label, path);
let mut lf = LazyFrame::scan_parquet(path, Default::default())
.with_context(|| format!("Failed to scan {label} listings parquet"))?;
let schema = lf
.collect_schema()
.with_context(|| format!("Failed to read {label} listings schema"))?;
// Rename raw finder columns → server-expected names (no-op if already renamed)
let lf = lf.rename(
[
"postcode",
"address",
"latitude",
"longitude",
"bedrooms",
"bathrooms",
"total_rooms",
"tenure",
"property_type",
"property_sub_type",
"price_qualifier",
"floorspace_sqm",
"url",
"features",
],
[
"Postcode",
"Address per Property Register",
"lat",
"lon",
"Bedrooms",
"Bathrooms",
"Number of bedrooms & living rooms",
"Leashold/Freehold",
"Property type",
"Property sub-type",
"Price qualifier",
"Total floor area (sqm)",
"Listing URL",
"Listing features",
],
false,
);
// Derive missing columns for raw finder output that doesn't have them
let listing_status = if label == "buy" {
"For sale"
} else {
"For rent"
};
let lf = if schema.get("Listing status").is_none() {
lf.with_column(lit(listing_status).alias("Listing status"))
} else {
lf
};
let lf = if schema.get("Asking price").is_none() && schema.get("price").is_some() {
if label == "buy" {
lf.with_column(col("price").alias("Asking price"))
} else {
// Normalize rent to monthly: weekly×52/12, yearly÷12
lf.with_column(
when(col("price_frequency").eq(lit("weekly")))
.then(col("price").cast(DataType::Float64) * lit(52.0 / 12.0))
.when(col("price_frequency").eq(lit("yearly")))
.then(col("price").cast(DataType::Float64) / lit(12.0))
.otherwise(col("price").cast(DataType::Float64))
.cast(DataType::Int64)
.alias("Asking rent (monthly)"),
)
}
} else {
lf
};
// Join with postcodes for area features (listings have their own lat/lon)
let pc_no_coords = postcode_df.clone().lazy().drop(["lat", "lon"]);
let joined = lf
.join(
pc_no_coords,
[col("Postcode")],
[col("Postcode")],
JoinArgs::new(JoinType::Left),
)
.collect()
.with_context(|| format!("Failed to join {label} listings with postcodes"))?;
tracing::info!(rows = joined.height(), "{} listings joined", label);
Ok(joined)
};
let listings_buy = load_listings(listings_buy_path, "buy")?;
let listings_rent = load_listings(listings_rent_path, "rent")?;
// Concatenate all rows into a single DataFrame
tracing::info!("Concatenating all data sources");
let buy_count = listings_buy.height();
let rent_count = listings_rent.height();
let mut combined = concat(
[
properties_joined.lazy(),
listings_buy.lazy(),
listings_rent.lazy(),
],
UnionArgs {
parallel: false,
rechunk: true,
to_supertypes: true,
diagonal: true,
..Default::default()
},
)
.context("Failed to concat data sources")?
.collect()
.context("Failed to collect combined data")?;
let total_rows = combined.height();
tracing::info!(
properties = prop_count,
buy_listings = buy_count,
rent_listings = rent_count,
total = total_rows,
"All data sources combined"
);
// Get configured feature/enum names in config order
let numeric_names = features::all_numeric_feature_names();
let enum_names = features::all_enum_feature_names();
// Validate: every configured numeric feature must exist in parquet as numeric
// Fill in NaN/empty placeholder columns for features that don't exist in all
// sources (e.g. Listing date only comes from listings, Estimated current price
// only from properties). Without this, diagonal concat leaves them absent.
{
let schema = combined.schema();
let mut fill_exprs: Vec<Expr> = Vec::new();
for &name in &numeric_names {
if schema.get(name).is_none() {
tracing::info!(feature = %name, "Adding NaN placeholder for missing numeric feature");
fill_exprs.push(lit(f32::NAN).alias(name));
}
}
for &name in &enum_names {
if schema.get(name).is_none() {
tracing::info!(feature = %name, "Adding empty placeholder for missing enum feature");
fill_exprs.push(lit("").alias(name));
}
}
if !fill_exprs.is_empty() {
combined = combined
.lazy()
.with_columns(fill_exprs)
.collect()
.context("Failed to add placeholder columns for missing features")?;
}
}
let schema = combined.schema();
// Validate: every configured feature exists in combined schema
for name in &numeric_names {
match schema.get(name) {
Some(dtype) if is_numeric_dtype(dtype) => {}
Some(dtype) => bail!(
"Configured numeric feature '{}' has non-numeric type {:?} in parquet",
"Configured numeric feature '{}' has non-numeric type {:?}",
name,
dtype
),
None => bail!(
"Configured numeric feature '{}' not found in parquet schema",
"Configured numeric feature '{}' not found in combined schema",
name
),
}
}
// Validate: every configured enum feature must exist in parquet as string
for name in &enum_names {
match schema.get(name) {
Some(dtype) if matches!(dtype, DataType::String) || dtype.is_categorical() => {}
Some(dtype) => bail!(
"Configured enum feature '{}' has unexpected type {:?} in parquet",
"Configured enum feature '{}' has unexpected type {:?}",
name,
dtype
),
None => bail!(
"Configured enum feature '{}' not found in parquet schema",
"Configured enum feature '{}' not found in combined schema",
name
),
}
}
// Validate: every parquet column must be accounted for
let all_known: std::collections::HashSet<&str> = numeric_names
.iter()
.chain(enum_names.iter())
.copied()
.chain(IGNORED_COLUMNS.iter().copied())
.collect();
for (col_name, dtype) in schema.iter() {
let name = col_name.as_str();
if all_known.contains(name) {
continue;
}
// Skip non-simple types (List, Struct, etc.)
if matches!(dtype, DataType::List(_) | DataType::Struct(_)) {
tracing::debug!(column = %name, dtype = ?dtype, "Skipping complex-type column");
continue;
}
bail!(
"Unknown column '{}' (type {:?}) in parquet — add it to features.rs config or IGNORED_COLUMNS",
name, dtype
);
}
// Combine numeric and enum feature names (numeric first, then enum)
let feature_names: Vec<String> = numeric_names
.iter()
@ -457,7 +640,7 @@ impl PropertyData {
"Feature columns from config"
);
// Build select expressions
// Build select expressions for the combined DataFrame
let mut select_exprs: Vec<polars::prelude::Expr> = vec![];
select_exprs.push(col("lat").cast(DataType::Float32));
select_exprs.push(col("lon").cast(DataType::Float32));
@ -465,7 +648,6 @@ impl PropertyData {
// Select numeric features as Float32 (datetime columns → fractional year)
for &name in &numeric_names {
if is_datetime_dtype(schema.get(name).unwrap()) {
// Convert datetime to fractional year: year + (month - 1) / 12
select_exprs.push(
(col(name).dt().year().cast(DataType::Float32)
+ (col(name).dt().month().cast(DataType::Float32) - lit(1.0f32))
@ -477,42 +659,47 @@ impl PropertyData {
}
}
// String columns for address/postcode
// String columns for address/postcode and online listing metadata
for &string_col_name in &[
"Address per Property Register",
"Address per EPC",
"Postcode",
"Listing URL",
"Property sub-type",
"Price qualifier",
] {
if schema.get(string_col_name).is_some() {
select_exprs.push(col(string_col_name).cast(DataType::String));
}
}
// Enum features as String (will be encoded to f32 indices later)
// Enum features as String
for &name in &enum_names {
select_exprs.push(col(name).cast(DataType::String));
}
// Optional boolean column for construction date approximation
// Optional columns
let has_approx_col = schema.get("Is construction date approximate").is_some();
if has_approx_col {
select_exprs.push(col("Is construction date approximate").cast(DataType::Float32));
}
// Optional renovation history (List<Struct{year, event}>)
let has_renovation_history = schema.get("renovation_history").is_some();
if has_renovation_history {
select_exprs.push(col("renovation_history"));
}
let has_listing_features = schema.get("Listing features").is_some();
if has_listing_features {
select_exprs.push(col("Listing features"));
}
let df = LazyFrame::scan_parquet(parquet_path, Default::default())
.context("Failed to scan parquet")?
let df = combined
.lazy()
.select(select_exprs)
.collect()
.context("Failed to read parquet")?;
.context("Failed to select columns from combined data")?;
let row_count = df.height();
tracing::info!(rows = row_count, "Parquet loaded");
tracing::info!(rows = row_count, "Combined data selected");
let lat_series = df
.column("lat")
@ -586,6 +773,35 @@ impl PropertyData {
let address_raw = extract_string_col(&df, "Address per Property Register")?;
let postcode_raw = extract_string_col(&df, "Postcode")?;
// Extract optional string columns for online listing metadata
let extract_optional_string_col =
|df: &DataFrame, name: &str| -> anyhow::Result<Vec<Option<String>>> {
if let Ok(column) = df.column(name) {
let string_column = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(string_column
.into_iter()
.map(|value| {
value.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
})
.collect())
} else {
Ok(vec![None; row_count])
}
};
let listing_url_raw = extract_optional_string_col(&df, "Listing URL")?;
let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?;
let price_qualifier_raw = extract_optional_string_col(&df, "Price qualifier")?;
tracing::info!("Building enum features");
// enum_col_major: Vec<(values_list, encoded_as_f32)>
let mut enum_col_major: Vec<(Vec<String>, Vec<f32>)> = Vec::new();
@ -689,7 +905,7 @@ impl PropertyData {
let mut history: FxHashMap<u32, Vec<RenovationEvent>> = FxHashMap::default();
for old_row in 0..row_count {
if let Some(inner) = list_ca.get_as_series(old_row) {
if inner.len() == 0 {
if inner.is_empty() {
continue;
}
let structs = inner
@ -727,6 +943,44 @@ impl PropertyData {
FxHashMap::default()
};
// Extract listing features: List<String>
let mut listing_features_raw: FxHashMap<u32, Vec<String>> = if has_listing_features {
tracing::info!("Extracting listing features");
let feat_col = df
.column("Listing features")
.context("Missing Listing features column")?;
let list_ca = feat_col
.list()
.context("Listing features is not a list column")?;
let mut features_map: FxHashMap<u32, Vec<String>> = FxHashMap::default();
for old_row in 0..row_count {
if let Some(inner) = list_ca.get_as_series(old_row) {
if inner.is_empty() {
continue;
}
let str_ca = inner
.str()
.context("Listing features inner is not a string series")?;
let items: Vec<String> = str_ca
.into_iter()
.filter_map(|v| v.map(|s| s.to_string()))
.filter(|s| !s.is_empty())
.collect();
if !items.is_empty() {
features_map.insert(old_row as u32, items);
}
}
}
tracing::info!(
properties_with_features = features_map.len(),
"Listing features extracted"
);
features_map
} else {
FxHashMap::default()
};
// Sort all rows by spatial locality so that grid queries access
// contiguous memory (sequential reads instead of random DRAM accesses).
tracing::info!("Sorting rows by spatial locality");
@ -796,6 +1050,32 @@ impl PropertyData {
map
};
// Re-key listing_features by permuted row index
let listing_features: FxHashMap<u32, Vec<String>> = {
let mut map =
FxHashMap::with_capacity_and_hasher(listing_features_raw.len(), Default::default());
for (new_row, &old_row) in perm.iter().enumerate() {
if let Some(items) = listing_features_raw.remove(&old_row) {
map.insert(new_row as u32, items);
}
}
map
};
// Permute optional string columns
let listing_url: Vec<Option<String>> = perm
.iter()
.map(|&old_row| listing_url_raw[old_row as usize].clone())
.collect();
let property_sub_type: Vec<Option<String>> = perm
.iter()
.map(|&old_row| property_sub_type_raw[old_row as usize].clone())
.collect();
let price_qualifier: Vec<Option<String>> = perm
.iter()
.map(|&old_row| price_qualifier_raw[old_row as usize].clone())
.collect();
// Build enum_values map: feature_index -> list of string values
let mut enum_values: rustc_hash::FxHashMap<usize, Vec<String>> =
rustc_hash::FxHashMap::default();
@ -857,6 +1137,10 @@ impl PropertyData {
enum_values,
approx_build_date_bits,
renovation_history,
listing_features,
listing_url,
property_sub_type,
price_qualifier,
})
}
}

View file

@ -58,21 +58,6 @@ pub struct EnumFeatureGroup {
pub features: &'static [EnumFeatureConfig],
}
/// Columns in parquet that are not filterable
pub const IGNORED_COLUMNS: &[&str] = &[
"lat",
"lon",
"Address per Property Register",
"Address per EPC",
"Postcode",
"historical_prices",
"Is construction date approximate",
"Current energy rating",
"Potential energy rating",
"Property sub-type",
"Listing URL",
"Price qualifier",
];
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup {
@ -964,6 +949,20 @@ pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
detail: "From HM Land Registry Price Paid data. The broad property type classification: Detached, Semi-Detached, Terraced, or Flat/Maisonette.",
source: "price-paid",
},
EnumFeatureConfig {
name: "Current energy rating",
order: Some(&["A", "B", "C", "D", "E", "F", "G"]),
description: "Current EPC energy efficiency rating (A = best, G = worst)",
detail: "The current energy efficiency rating from the Energy Performance Certificate. Ranges from A (most efficient) to G (least efficient). Based on the property's energy use per square metre of floor area.",
source: "epc",
},
EnumFeatureConfig {
name: "Potential energy rating",
order: Some(&["A", "B", "C", "D", "E", "F", "G"]),
description: "Potential EPC rating if all recommended improvements were made",
detail: "The potential energy efficiency rating from the Energy Performance Certificate if all cost-effective improvements recommended in the EPC report were carried out. Ranges from A (most efficient) to G (least efficient).",
source: "epc",
},
],
},
EnumFeatureGroup {

View file

@ -28,6 +28,8 @@ use tower_http::cors::{AllowHeaders, AllowMethods, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing::info;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use state::AppState;
@ -35,9 +37,21 @@ use state::AppState;
#[derive(Parser)]
#[command(name = "perfect-postcode", about = "Perfect Postcode property map server")]
struct Cli {
/// Path to the wide property parquet file
/// Path to properties.parquet (one row per historical property)
#[arg(long)]
data: PathBuf,
properties: PathBuf,
/// Path to postcode.parquet (one row per postcode with area-level data)
#[arg(long)]
postcode_features: PathBuf,
/// Path to online_listings_buy.parquet
#[arg(long)]
listings_buy: PathBuf,
/// Path to online_listings_rent.parquet
#[arg(long)]
listings_rent: PathBuf,
/// Path to the POI parquet file
#[arg(long)]
@ -79,11 +93,11 @@ struct Cli {
#[arg(long, env = "POCKETBASE_ADMIN_PASSWORD")]
pocketbase_admin_password: String,
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
/// Ollama server URL (e.g. http://ollama:11434)
#[arg(long, env = "OLLAMA_URL")]
ollama_url: String,
/// Ollama model name for area summaries
/// Ollama model name
#[arg(long, env = "OLLAMA_MODEL")]
ollama_model: String,
@ -115,22 +129,24 @@ struct Cli {
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")]
google_oauth_client_secret: String,
/// Apple OAuth client ID for PocketBase SSO
#[arg(long, env = "APPLE_OAUTH_CLIENT_ID")]
apple_oauth_client_id: String,
/// Apple OAuth client secret for PocketBase SSO
#[arg(long, env = "APPLE_OAUTH_CLIENT_SECRET")]
apple_oauth_client_secret: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
let file_appender = tracing_appender::rolling::daily("logs", "server.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_ansi(true))
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(non_blocking),
)
.with_ansi(true)
.init();
// Initialize Prometheus metrics
@ -139,16 +155,30 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let parquet_path = &cli.data;
if !parquet_path.exists() {
bail!(
"Property parquet file not found: {}",
parquet_path.display()
);
for (label, path) in [
("Properties", &cli.properties),
("Postcode features", &cli.postcode_features),
("Listings buy", &cli.listings_buy),
("Listings rent", &cli.listings_rent),
] {
if !path.exists() {
bail!("{} parquet file not found: {}", label, path.display());
}
}
info!("Loading property data from {}", parquet_path.display());
let property_data = data::PropertyData::load(parquet_path)?;
info!(
"Loading property data from {}, {}, {}, {}",
cli.properties.display(),
cli.postcode_features.display(),
cli.listings_buy.display(),
cli.listings_rent.display(),
);
let property_data = data::PropertyData::load(
&cli.properties,
&cli.postcode_features,
&cli.listings_buy,
&cli.listings_rent,
)?;
info!(
rows = property_data.lat.len(),
features = property_data.num_features,
@ -297,8 +327,6 @@ async fn main() -> anyhow::Result<()> {
&cli.public_url,
&cli.google_oauth_client_id,
&cli.google_oauth_client_secret,
&cli.apple_oauth_client_id,
&cli.apple_oauth_client_secret,
)
.await?;
@ -382,7 +410,6 @@ async fn main() -> anyhow::Result<()> {
let state_crawler = state.clone();
let state_pb = state.clone();
let state_postcode_stats = state.clone();
let state_area_summary = state.clone();
let state_places = state.clone();
let state_shorten = state.clone();
let state_short_url = state.clone();
@ -447,7 +474,7 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/screenshot",
get(move |query| routes::get_screenshot(state_screenshot.clone(), query)),
get(move |headers, query| routes::get_screenshot(state_screenshot.clone(), headers, query)),
)
.route(
"/api/export",
@ -455,11 +482,6 @@ async fn main() -> anyhow::Result<()> {
.layer(ConcurrencyLimitLayer::new(3)),
)
.route("/api/me", get(routes::get_me))
.route(
"/api/area-summary",
post(move |body| routes::post_area_summary(state_area_summary.clone(), body))
.layer(ConcurrencyLimitLayer::new(5)),
)
.route(
"/api/shorten",
post(move |body| routes::post_shorten(state_shorten.clone(), body)),

View file

@ -18,10 +18,21 @@ struct CollectionItem {
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateCollection {
name: String,
r#type: String,
fields: Vec<Field>,
#[serde(skip_serializing_if = "Option::is_none")]
list_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
view_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
create_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
update_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
delete_rule: Option<String>,
}
#[derive(Serialize)]
@ -251,6 +262,37 @@ async fn ensure_user_fields(
Ok(())
}
/// Ensure the `saved_searches` collection has API rules allowing users to manage their own records.
async fn ensure_saved_searches_rules(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/saved_searches");
let user_only = "user = @request.auth.id";
let resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"listRule": user_only,
"viewRule": user_only,
"createRule": user_only,
"updateRule": user_only,
"deleteRule": user_only,
}))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to update saved_searches API rules ({status}): {text}");
}
info!("PocketBase collection 'saved_searches' API rules updated");
Ok(())
}
/// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase,
/// and that the `users` collection has `is_admin` and `subscription` fields.
/// Authenticates as superuser, checks existing collections, and creates any that are missing.
@ -269,6 +311,7 @@ pub async fn ensure_collections(
if !existing.iter().any(|n| n == "saved_searches") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
let user_only = Some("user = @request.auth.id".to_string());
create_collection(
client,
base_url,
@ -282,11 +325,16 @@ pub async fn ensure_collections(
Field::text("params", true),
Field::file("screenshot", vec!["image/png", "image/jpeg", "image/webp"]),
],
list_rule: user_only.clone(),
view_rule: user_only.clone(),
create_rule: user_only.clone(),
update_rule: user_only.clone(),
delete_rule: user_only,
},
)
.await?;
} else {
info!("PocketBase collection 'saved_searches' already exists");
ensure_saved_searches_rules(client, base_url, &token).await?;
}
if !existing.iter().any(|n| n == "invites") {
@ -304,6 +352,11 @@ pub async fn ensure_collections(
Field::text("used_by_id", false),
Field::text("used_at", false),
],
list_rule: None,
view_rule: None,
create_rule: None,
update_rule: None,
delete_rule: None,
},
)
.await?;
@ -323,6 +376,11 @@ pub async fn ensure_collections(
Field::text("code", true),
Field::text("params", true),
],
list_rule: None,
view_rule: None,
create_rule: None,
update_rule: None,
delete_rule: None,
},
)
.await?;
@ -333,7 +391,7 @@ pub async fn ensure_collections(
Ok(())
}
/// Configure Google and Apple OAuth2 providers in PocketBase settings.
/// Configure Google OAuth2 provider in PocketBase settings.
/// Also sets `meta.appUrl` so OAuth callbacks route to `{public_url}/pb`.
pub async fn ensure_oauth_providers(
client: &Client,
@ -343,8 +401,6 @@ pub async fn ensure_oauth_providers(
public_url: &str,
google_client_id: &str,
google_client_secret: &str,
apple_client_id: &str,
apple_client_secret: &str,
) -> anyhow::Result<()> {
let base_url = base_url.trim_end_matches('/');
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
@ -392,12 +448,6 @@ pub async fn ensure_oauth_providers(
provider["enabled"] = serde_json::json!(true);
info!("Configured Google OAuth provider");
}
"apple" => {
provider["clientId"] = serde_json::json!(apple_client_id);
provider["clientSecret"] = serde_json::json!(apple_client_secret);
provider["enabled"] = serde_json::json!(true);
info!("Configured Apple OAuth provider");
}
_ => {}
}
}

View file

@ -1,5 +1,4 @@
mod ai_filters;
mod area_summary;
mod checkout;
mod export;
mod features;
@ -26,7 +25,6 @@ pub(crate) mod travel_time;
mod travel_modes;
pub use ai_filters::{build_ollama_schema, build_system_prompt, post_ai_filters};
pub use area_summary::post_area_summary;
pub use checkout::post_checkout;
pub use export::get_export;
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};

View file

@ -1,118 +0,0 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::consts::{
AREA_SUMMARY_MAX_TOKENS, AREA_SUMMARY_SYSTEM_PROMPT, AREA_SUMMARY_TEMPERATURE,
};
use crate::state::AppState;
use crate::utils::{extract_openai_content, ollama_chat, strip_think_blocks};
#[derive(Deserialize)]
pub struct NumericStat {
name: String,
mean: f64,
}
#[derive(Deserialize)]
pub struct EnumStat {
name: String,
counts: std::collections::HashMap<String, u64>,
}
#[derive(Deserialize)]
pub struct AreaSummaryRequest {
count: usize,
location: String,
is_postcode: bool,
#[serde(default)]
filters: Vec<String>,
#[serde(default)]
numeric_stats: Vec<NumericStat>,
#[serde(default)]
enum_stats: Vec<EnumStat>,
}
#[derive(Serialize)]
pub struct AreaSummaryResponse {
summary: String,
}
fn build_prompt(req: &AreaSummaryRequest) -> String {
let mut parts = Vec::new();
let area_type = if req.is_postcode { "postcode" } else { "area" };
parts.push(format!(
"Summarise this {} of England which contains {} properties matching my requirements.\n",
area_type, req.count
));
if !req.filters.is_empty() {
parts.push(format!("Active filters: {}.\n", req.filters.join(", ")));
}
if !req.numeric_stats.is_empty() {
let stats: Vec<String> = req
.numeric_stats
.iter()
.map(|stat| format!("{}: {:.1}", stat.name, stat.mean))
.collect();
parts.push(format!(
"Average values of the {}: {}.",
if req.is_postcode { "postcode" } else { "area" },
stats.join(", ")
));
}
for es in &req.enum_stats {
let total: u64 = es.counts.values().sum();
if total == 0 {
continue;
}
let mut sorted: Vec<_> = es.counts.iter().collect();
sorted.sort_by(|lhs, rhs| rhs.1.cmp(lhs.1));
let top: Vec<String> = sorted
.iter()
.take(3)
.map(|(val, count)| {
let pct = **count as f64 / total as f64 * 100.0;
format!("{} ({:.0}%)", val, pct)
})
.collect();
parts.push(format!("{}: {}.", es.name, top.join(", ")));
}
let result = parts.join(" ");
info!(prompt = %result, "Built prompt for area summary");
result
}
pub async fn post_area_summary(
state: Arc<AppState>,
Json(req): Json<AreaSummaryRequest>,
) -> Result<Json<AreaSummaryResponse>, (StatusCode, String)> {
let prompt = build_prompt(&req);
info!(location = %req.location, count = req.count, "POST /api/area-summary");
let url = format!("{}/v1/chat/completions", state.ollama_url);
let body = serde_json::json!({
"model": state.ollama_model,
"messages": [
{ "role": "system", "content": AREA_SUMMARY_SYSTEM_PROMPT },
{ "role": "user", "content": prompt }
],
"stream": false,
"temperature": AREA_SUMMARY_TEMPERATURE,
"max_tokens": AREA_SUMMARY_MAX_TOKENS,
});
let json = ollama_chat(&state.http_client, &url, &body).await?;
let content = extract_openai_content(&json)?;
let summary = strip_think_blocks(content).trim().to_string();
Ok(Json(AreaSummaryResponse { summary }))
}

View file

@ -11,7 +11,7 @@ use tracing::info;
use crate::aggregation::Aggregator;
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::consts::{DEMO_CENTER, DEMO_CENTER_TOLERANCE, MAX_CELLS_PER_REQUEST};
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::parsing::{
@ -190,9 +190,14 @@ pub async fn get_hexagons(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
// Skip license check at low resolutions (≤5) — data is too aggregated to be
// commercially useful, and the homepage demo needs country-wide access.
if resolution > 5 {
// Allow the homepage demo: check if the center of the requested bounds
// is near the demo view center (52.2, -1.9).
let center_lat = (south + north) / 2.0;
let center_lng = (west + east) / 2.0;
let is_demo_view = (center_lat - DEMO_CENTER.0).abs() <= DEMO_CENTER_TOLERANCE
&& (center_lng - DEMO_CENTER.1).abs() <= DEMO_CENTER_TOLERANCE;
if !is_demo_view {
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
}

View file

@ -38,6 +38,10 @@ pub struct Property {
pub duration: Option<String>,
pub current_energy_rating: Option<String>,
pub potential_energy_rating: Option<String>,
pub listing_status: Option<String>,
pub listing_url: Option<String>,
pub property_sub_type: Option<String>,
pub price_qualifier: Option<String>,
// Numeric fields
pub lat: f32,
@ -48,6 +52,9 @@ pub struct Property {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub renovation_history: Vec<RenovationEvent>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub listing_features: Vec<String>,
#[serde(flatten)]
pub features: FxHashMap<String, f32>,
}
@ -231,6 +238,18 @@ pub async fn get_hexagon_properties(
lat: state.data.lat[row],
lon: state.data.lon[row],
renovation_history: state.data.renovation_history(row).to_vec(),
listing_features: state.data.listing_features(row).to_vec(),
listing_status: lookup_enum_value(
feature_name_to_index,
feature_data,
num_features,
enum_values,
row,
"Listing status",
),
listing_url: state.data.listing_url(row).map(String::from),
property_sub_type: state.data.property_sub_type(row).map(String::from),
price_qualifier: state.data.price_qualifier(row).map(String::from),
features,
}
})

View file

@ -1,12 +1,16 @@
use std::sync::Arc;
use axum::http::{header, StatusCode, Uri};
use axum::http::{header, HeaderMap, StatusCode, Uri};
use axum::response::IntoResponse;
use tracing::{info, warn};
use crate::state::AppState;
pub async fn get_screenshot(state: Arc<AppState>, uri: Uri) -> impl IntoResponse {
pub async fn get_screenshot(
state: Arc<AppState>,
headers: HeaderMap,
uri: Uri,
) -> impl IntoResponse {
let screenshot_base = &state.screenshot_url;
let qs = uri
@ -16,7 +20,12 @@ pub async fn get_screenshot(state: Arc<AppState>, uri: Uri) -> impl IntoResponse
let url = format!("{screenshot_base}/screenshot{qs}");
info!("Proxying screenshot request to: {}", url);
match state.http_client.get(&url).send().await {
let mut req = state.http_client.get(&url);
if let Some(auth) = headers.get(header::AUTHORIZATION) {
req = req.header(header::AUTHORIZATION, auth);
}
match req.send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
Ok(bytes) => (
StatusCode::OK,

View file

@ -8,6 +8,7 @@ use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
const CODE_LEN: usize = 8;
@ -39,6 +40,22 @@ struct PbRecord {
pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>) -> Response {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let code = generate_code();
let record = PbRecord {
@ -51,6 +68,7 @@ pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>)
.post(format!(
"{pb_url}/api/collections/short_urls/records"
))
.header("Authorization", format!("Bearer {token}"))
.json(&record)
.send()
.await;
@ -79,13 +97,33 @@ pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>)
pub async fn get_short_url(state: Arc<AppState>, Path(code): Path<String>) -> Response {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let filter = format!("code=\"{code}\"");
let url = format!(
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = state.http_client.get(&url).send().await;
let res = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {

View file

@ -6,4 +6,4 @@ mod llm;
pub use grid_index::GridIndex;
pub use hash::{generate_priorities, splitmix64_hash};
pub use interned_column::InternedColumn;
pub use llm::{extract_ollama_content, extract_openai_content, ollama_chat, strip_think_blocks};
pub use llm::{extract_ollama_content, ollama_chat, strip_think_blocks};

View file

@ -40,22 +40,6 @@ pub async fn ollama_chat(
})
}
/// Extract content from OpenAI-compatible response (`choices[0].message.content`)
pub fn extract_openai_content(json: &Value) -> Result<&str, LlmError> {
json.get("choices")
.and_then(|ch| ch.get(0))
.and_then(|ch| ch.get("message"))
.and_then(|msg| msg.get("content"))
.and_then(|ct| ct.as_str())
.ok_or_else(|| {
warn!("Malformed OpenAI response: missing choices[0].message.content");
(
StatusCode::BAD_GATEWAY,
"Malformed LLM response: missing choices[0].message.content".into(),
)
})
}
/// Extract content from Ollama native response (`message.content`)
pub fn extract_ollama_content(json: &Value) -> Result<&str, LlmError> {
json.get("message")