diff --git a/docker-compose.yml b/docker-compose.yml
index af992c4..0b0f525 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -163,7 +163,6 @@ services:
# - ./finder:/app
# environment:
# FLARESOLVERR_URL: http://flaresolverr:8191
- # RELOAD_URL: http://server:8001/api/reload
# depends_on:
# gluetun:
# condition: service_healthy
diff --git a/finder/constants.py b/finder/constants.py
index 9aee415..d9ab10c 100644
--- a/finder/constants.py
+++ b/finder/constants.py
@@ -55,7 +55,6 @@ RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
HOMECOUK_BASE = "https://home.co.uk"
HOMECOUK_API_BASE = f"{HOMECOUK_BASE}/api"
HOMECOUK_PER_PAGE = 30 # max supported by the API
-HOMECOUK_CONCURRENCY = int(os.environ.get("HOMECOUK_CONCURRENCY", "4"))
# OpenRent
OPENRENT_BASE = "https://www.openrent.co.uk"
@@ -105,18 +104,6 @@ PROPERTY_TYPE_MAP = {
"Garages": "Other",
"Mews": "Terraced",
"Property": "Other",
- "Flat Share": "Other",
- "Block of Apartments": "Flats/Maisonettes",
- "Private Halls": "Flats/Maisonettes",
- "Terraced Bungalow": "Terraced",
- "Equestrian Facility": "Other",
- "Ground Maisonette": "Flats/Maisonettes",
- "Country House": "Detached",
- "Village House": "Detached",
- "Farm Land": "Other",
- "House Boat": "Other",
- "Barn": "Other",
- "Serviced Apartments": "Flats/Maisonettes",
# Lowercase variants (from home.co.uk / Rightmove APIs)
"house": "Detached",
"bungalow": "Other",
@@ -126,19 +113,6 @@ PROPERTY_TYPE_MAP = {
"not-specified": "Other",
"retirement-property": "Flats/Maisonettes",
"equestrian-facility": "Other",
- "flat": "Flats/Maisonettes",
- "detached": "Detached",
- "semi-detached": "Semi-Detached",
- "terraced": "Terraced",
- "maisonette": "Flats/Maisonettes",
- "apartment": "Flats/Maisonettes",
- "studio": "Flats/Maisonettes",
- "penthouse": "Flats/Maisonettes",
- "cottage": "Other",
- "chalet": "Other",
- "farm_house": "Detached",
- "country house": "Detached",
- "village house": "Detached",
}
CHANNELS = [
diff --git a/finder/homecouk.py b/finder/homecouk.py
index bace56d..f9e290f 100644
--- a/finder/homecouk.py
+++ b/finder/homecouk.py
@@ -363,7 +363,7 @@ def transform_property(
"Address per Property Register": address,
"Leasehold/Freehold": parse_tenure(prop),
"Property type": map_property_type(listing_type),
- "Property sub-type": listing_type.title() if listing_type else "Unknown",
+ "Property sub-type": listing_type or "Unknown",
"price": int(price),
"price_frequency": "" if channel == "BUY" else "monthly",
"Price qualifier": price_qualifier,
diff --git a/finder/openrent.py b/finder/openrent.py
index f08a3cd..ce27fe8 100644
--- a/finder/openrent.py
+++ b/finder/openrent.py
@@ -289,15 +289,10 @@ def _extract_beds_baths_from_features(
def _extract_postcode(text: str) -> str | None:
- """Extract full UK postcode from text like '2 Bed Flat, Pimlico, SW1V 2AA'.
- Normalizes to include a space before the 3-char incode."""
+ """Extract full UK postcode from text like '2 Bed Flat, Pimlico, SW1V 2AA'."""
match = re.search(r"([A-Z]{1,2}\d[A-Z0-9]?\s*\d[A-Z]{2})", text, re.IGNORECASE)
if match:
- raw = match.group(1).upper().strip()
- # Ensure space before incode (last 3 chars): "IP265AT" → "IP26 5AT"
- if " " not in raw and len(raw) >= 5:
- return raw[:-3] + " " + raw[-3:]
- return raw
+ return match.group(1).upper().strip()
return None
@@ -640,29 +635,6 @@ def _resolve_outcode_postcodes(
return results
-def _parse_or_date(date_str: str) -> str:
- """Parse OpenRent date strings to ISO format (YYYY-MM-DD).
- Handles 'Today', 'Tomorrow', and 'DD Month, YYYY' formats."""
- if not date_str:
- return ""
- stripped = date_str.strip()
- lower = stripped.lower()
- if lower == "today":
- from datetime import datetime
- return datetime.now().strftime("%Y-%m-%d")
- if lower == "tomorrow":
- from datetime import datetime, timedelta
- return (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d")
- # Try "DD Month, YYYY" format (e.g., "01 April, 2026")
- from datetime import datetime
- for fmt in ("%d %B, %Y", "%d %B %Y"):
- try:
- return datetime.strptime(stripped, fmt).strftime("%Y-%m-%d")
- except ValueError:
- continue
- return date_str # Return as-is if unparseable
-
-
def transform_property(
search_data: dict,
detail_data: dict | None,
@@ -795,7 +767,7 @@ def transform_property(
"Total floor area (sqm)": parse_floor_area(description),
"Listing URL": listing_url,
"Listing features": [],
- "first_visible_date": _parse_or_date(detail.get("available_date", "")),
+ "first_visible_date": detail.get("available_date", ""),
}
diff --git a/finder/scraper.py b/finder/scraper.py
index 78db671..4f81aee 100644
--- a/finder/scraper.py
+++ b/finder/scraper.py
@@ -3,7 +3,6 @@ import logging
import random
import threading
import time
-from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
import polars as pl
@@ -16,7 +15,6 @@ from constants import (
CHECKPOINT_INTERVAL,
DATA_DIR,
DELAY_BETWEEN_OUTCODES,
- HOMECOUK_CONCURRENCY,
RELOAD_URL,
SCRAPE_HOMECOUK,
SCRAPE_OPENRENT,
@@ -505,133 +503,59 @@ def run_scrape(
hk_start = start_indices.get("hk", 0)
if hk_start > 0:
log.info("home.co.uk resuming from outcode %d/%d", hk_start, len(shuffled))
- log.info(
- "home.co.uk scraping ENABLED (concurrency=%d)", HOMECOUK_CONCURRENCY
- )
+ client = make_homecouk_client(*hk_result)
+ log.info("home.co.uk scraping ENABLED")
homecouk_enabled.set(1)
-
- # Shared state across pool threads
- cookie_state = {
- "cookies": hk_result[0],
- "user_agent": hk_result[1],
- "generation": 0,
- }
- cookie_lock = threading.Lock()
- results_lock = threading.Lock()
- completed_count = [hk_start]
- disabled = [False]
- _local = threading.local()
-
- def _get_client():
- """Get or create a thread-local curl_cffi session."""
- with cookie_lock:
- gen = cookie_state["generation"]
- cookies = cookie_state["cookies"]
- ua = cookie_state["user_agent"]
- if not hasattr(_local, "client") or _local.gen != gen:
- if hasattr(_local, "client"):
- try:
- _local.client.close()
- except Exception:
- pass
- _local.client = make_homecouk_client(cookies, ua)
- _local.gen = gen
- return _local.client
-
- def _refresh_cookies():
- """Refresh cookies via FlareSolverr. Thread-safe with generation check."""
- with cookie_lock:
- pre_gen = cookie_state["generation"]
- new = load_homecouk_cookies()
- if not new:
- return False
- with cookie_lock:
- if cookie_state["generation"] == pre_gen:
- cookie_state["cookies"] = new[0]
- cookie_state["user_agent"] = new[1]
- cookie_state["generation"] += 1
- cookie_refreshes_total.labels(result="success").inc()
- log.info("home.co.uk cookies refreshed")
- return True
-
- def _scrape_outcode(outcode):
- if disabled[0]:
- return
- client = _get_client()
- for ch_cfg in CHANNELS:
- ch = ch_cfg["channel"]
- if disabled[0]:
- return
- try:
- props = homecouk_search_outcode(
- client, outcode, ch, pc_index
- )
- if props:
- with results_lock:
- hk_results[ch].extend(props)
- log.info(
- "home.co.uk %s: +%d properties", outcode, len(props)
- )
- except CookiesExpiredError:
- log.warning(
- "home.co.uk cookies expired — attempting refresh"
- )
- if _refresh_cookies():
- client = _get_client()
- try:
- props = homecouk_search_outcode(
- client, outcode, ch, pc_index
- )
- if props:
- with results_lock:
- hk_results[ch].extend(props)
- log.info(
- "home.co.uk %s: +%d properties",
- outcode,
- len(props),
- )
- except Exception as e:
- log.error(
- "home.co.uk %s/%s (after refresh): %s",
- outcode,
- ch,
- e,
- )
- scrape_errors_total.labels(source="homecouk").inc()
- else:
- log.warning(
- "Cookie refresh failed, disabling home.co.uk"
- )
- disabled[0] = True
- homecouk_enabled.set(0)
- cookie_refreshes_total.labels(result="failure").inc()
- with status_lock:
- status.errors.append(
- "home.co.uk cookies expired and refresh failed"
- )
- return
- except Exception as e:
- log.error("home.co.uk %s/%s: %s", outcode, ch, e)
- scrape_errors_total.labels(source="homecouk").inc()
-
- with results_lock:
- completed_count[0] += 1
- progress.update("hk", completed_count[0])
- time.sleep(DELAY_BETWEEN_OUTCODES)
-
try:
- work = [oc for i, oc in enumerate(shuffled) if i >= hk_start]
- with ThreadPoolExecutor(
- max_workers=HOMECOUK_CONCURRENCY
- ) as pool:
- list(pool.map(_scrape_outcode, work))
+ for i, outcode in enumerate(shuffled):
+ if i < hk_start:
+ continue
+ for ch_cfg in CHANNELS:
+ ch = ch_cfg["channel"]
+ try:
+ props = homecouk_search_outcode(
+ client, outcode, ch, pc_index
+ )
+ hk_results[ch].extend(props)
+ if props:
+ log.info("home.co.uk %s: +%d properties", outcode, len(props))
+ except CookiesExpiredError:
+ log.warning(
+ "home.co.uk cookies expired — attempting refresh"
+ )
+ client.close()
+ hk_new = load_homecouk_cookies()
+ if hk_new:
+ client = make_homecouk_client(*hk_new)
+ log.info("home.co.uk cookies refreshed, continuing")
+ cookie_refreshes_total.labels(result="success").inc()
+ else:
+ log.warning(
+ "Cookie refresh failed, disabling home.co.uk"
+ )
+ homecouk_enabled.set(0)
+ cookie_refreshes_total.labels(result="failure").inc()
+ with status_lock:
+ status.errors.append(
+ "home.co.uk cookies expired and refresh failed"
+ )
+ progress.update("hk", len(shuffled))
+ return
+ except Exception as e:
+ log.error("home.co.uk %s/%s: %s", outcode, ch, e)
+ scrape_errors_total.labels(source="homecouk").inc()
+
+ progress.update("hk", i + 1)
+ time.sleep(DELAY_BETWEEN_OUTCODES)
except Exception as e:
log.exception("Fatal home.co.uk error: %s", e)
with status_lock:
status.errors.append(f"Fatal home.co.uk: {e}")
-
- if disabled[0]:
- progress.update("hk", len(shuffled))
+ finally:
+ try:
+ client.close()
+ except Exception:
+ pass
def or_worker():
or_result = load_openrent_cookies()
diff --git a/finder/storage.py b/finder/storage.py
index 487ee34..4ab685f 100644
--- a/finder/storage.py
+++ b/finder/storage.py
@@ -5,7 +5,7 @@ from pathlib import Path
import polars as pl
from constants import MAX_BEDROOMS, MAX_RENT_MONTHLY, MIN_RENT_MONTHLY
-from transform import map_property_type, normalize_price
+from transform import normalize_price
log = logging.getLogger("rightmove")
@@ -43,19 +43,6 @@ def write_parquet(properties: list[dict], path: Path, channel: str) -> None:
MAX_BEDROOMS,
)
- # Re-derive Property type from Property sub-type using current PROPERTY_TYPE_MAP.
- # This retroactively fixes data scraped with older versions of the type map.
- remapped = 0
- for p in properties:
- sub_type = p.get("Property sub-type", "")
- if sub_type and sub_type != "Unknown":
- new_type = map_property_type(sub_type)
- if new_type != p.get("Property type"):
- p["Property type"] = new_type
- remapped += 1
- if remapped:
- log.info("Re-mapped %d property types from sub-types", remapped)
-
# Parse first_visible_date to datetime
listing_dates = []
for p in properties:
@@ -69,27 +56,7 @@ def write_parquet(properties: list[dict], path: Path, channel: str) -> None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
listing_dates.append(dt)
except (ValueError, TypeError):
- # Try additional date formats (OpenRent: "DD Month, YYYY", "Today")
- parsed = None
- stripped = fvd.strip()
- lower = stripped.lower()
- if lower == "today":
- parsed = datetime.now().replace(
- hour=0, minute=0, second=0, microsecond=0
- )
- elif lower == "tomorrow":
- from datetime import timedelta
- parsed = (
- datetime.now() + timedelta(days=1)
- ).replace(hour=0, minute=0, second=0, microsecond=0)
- else:
- for fmt in ("%d %B, %Y", "%d %B %Y"):
- try:
- parsed = datetime.strptime(stripped, fmt)
- break
- except ValueError:
- continue
- listing_dates.append(parsed)
+ listing_dates.append(None)
else:
listing_dates.append(None)
diff --git a/finder/transform.py b/finder/transform.py
index 301e0e6..1027220 100644
--- a/finder/transform.py
+++ b/finder/transform.py
@@ -49,22 +49,6 @@ def map_property_type(sub_type: str | None) -> str:
canonical = PROPERTY_TYPE_MAP.get(sub_type)
if canonical:
return canonical
- # Try title-case variant (e.g., "country house" → "Country House")
- canonical = PROPERTY_TYPE_MAP.get(sub_type.title())
- if canonical:
- return canonical
- # Keyword fallback for compound types not in the map
- lower = sub_type.lower()
- if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower:
- return "Flats/Maisonettes"
- if "semi" in lower and "detach" in lower:
- return "Semi-Detached"
- if "detach" in lower:
- return "Detached"
- if "terrace" in lower or "mews" in lower:
- return "Terraced"
- if "house" in lower or "cottage" in lower:
- return "Detached"
log.warning("Unknown propertySubType: %r — mapping to Other", sub_type)
return "Other"
@@ -102,15 +86,6 @@ def fix_coords(lat: float, lng: float) -> tuple[float, float]:
return lat, lng
-def normalize_postcode(postcode: str) -> str:
- """Ensure UK postcode has a space before the 3-char incode.
- E.g., 'SW1A1AA' → 'SW1A 1AA', 'E1 4AB' unchanged."""
- postcode = postcode.strip().upper()
- if " " in postcode or len(postcode) < 5:
- return postcode
- return postcode[:-3] + " " + postcode[-3:]
-
-
def normalize_price(amount: int, frequency: str) -> int:
"""Normalise price to monthly for rentals (weekly × 52/12, yearly ÷ 12)."""
if frequency == "weekly":
diff --git a/finder/uv.lock b/finder/uv.lock
index 2cc4cec..26df5be 100644
--- a/finder/uv.lock
+++ b/finder/uv.lock
@@ -301,7 +301,6 @@ dependencies = [
{ name = "fake-useragent" },
{ name = "flask" },
{ name = "httpx" },
- { name = "lxml" },
{ name = "playwright" },
{ name = "playwright-stealth" },
{ name = "polars" },
@@ -316,7 +315,6 @@ requires-dist = [
{ name = "fake-useragent", specifier = ">=2.2.0" },
{ name = "flask" },
{ name = "httpx" },
- { name = "lxml" },
{ name = "playwright", specifier = ">=1.58.0" },
{ name = "playwright-stealth", specifier = ">=2.0.2" },
{ name = "polars" },
diff --git a/finder/zoopla.py b/finder/zoopla.py
index f610704..19d3b31 100644
--- a/finder/zoopla.py
+++ b/finder/zoopla.py
@@ -104,22 +104,6 @@ _EXTRACT_LISTINGS_JS = r"""() => {
if (ptMatch) property_type = ptMatch[1].trim();
else if (/\bstudio\s*(?:flat|apartment)?\s+(?:for\s+sale|to\s+(?:rent|let)|for\s+rent)/i.test(text)) property_type = 'Studio';
- // Keyword fallback when regex doesn't match current DOM format
- if (!property_type) {
- const lower = text.toLowerCase();
- if (/\bstudio\b/.test(lower)) property_type = 'Studio';
- else if (/\bpenthouse\b/.test(lower)) property_type = 'Penthouse';
- else if (/\bmaisonette\b/.test(lower)) property_type = 'Maisonette';
- else if (/\bapartment\b/.test(lower)) property_type = 'Apartment';
- else if (/\bflat\b/.test(lower)) property_type = 'Flat';
- else if (/\bsemi[- ]?detached\b/.test(lower)) property_type = 'Semi-Detached';
- else if (/\bdetached\b/.test(lower)) property_type = 'Detached';
- else if (/\bterraced?\b/.test(lower)) property_type = 'Terraced';
- else if (/\bbungalow\b/.test(lower)) property_type = 'Bungalow';
- else if (/\bcottage\b/.test(lower)) property_type = 'Cottage';
- else if (/\bhouse\b/.test(lower)) property_type = 'House';
- }
-
results.push({
id, url: href.replace(window.location.origin, ''),
price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
@@ -188,22 +172,6 @@ _EXTRACT_LISTINGS_JS = r"""() => {
if (ptMatch2) property_type = ptMatch2[1].trim();
else if (/\bstudio\s*(?:flat|apartment)?\s+(?:for\s+sale|to\s+(?:rent|let)|for\s+rent)/i.test(text)) property_type = 'Studio';
- // Keyword fallback when regex doesn't match current DOM format
- if (!property_type) {
- const lower = text.toLowerCase();
- if (/\bstudio\b/.test(lower)) property_type = 'Studio';
- else if (/\bpenthouse\b/.test(lower)) property_type = 'Penthouse';
- else if (/\bmaisonette\b/.test(lower)) property_type = 'Maisonette';
- else if (/\bapartment\b/.test(lower)) property_type = 'Apartment';
- else if (/\bflat\b/.test(lower)) property_type = 'Flat';
- else if (/\bsemi[- ]?detached\b/.test(lower)) property_type = 'Semi-Detached';
- else if (/\bdetached\b/.test(lower)) property_type = 'Detached';
- else if (/\bterraced?\b/.test(lower)) property_type = 'Terraced';
- else if (/\bbungalow\b/.test(lower)) property_type = 'Bungalow';
- else if (/\bcottage\b/.test(lower)) property_type = 'Cottage';
- else if (/\bhouse\b/.test(lower)) property_type = 'House';
- }
-
results.push({
id, url: href.replace(window.location.origin, ''),
price: priceMatch ? parseInt(priceMatch[1].replace(/,/g, '')) : null,
@@ -628,15 +596,10 @@ def _resolve_outcode_coords(
def _extract_postcode(text: str) -> str | None:
- """Extract a full UK postcode from text like 'Dollar Bay Place, Canary Wharf E14 9SS'.
- Normalizes to include a space before the 3-char incode."""
+ """Extract a full UK postcode from text like 'Dollar Bay Place, Canary Wharf E14 9SS'."""
match = re.search(r"([A-Z]{1,2}\d[A-Z0-9]?\s*\d[A-Z]{2})", text, re.IGNORECASE)
if match:
- raw = match.group(1).upper().strip()
- # Ensure space before incode (last 3 chars): "SW1A1AA" → "SW1A 1AA"
- if " " not in raw and len(raw) >= 5:
- return raw[:-3] + " " + raw[-3:]
- return raw
+ return match.group(1).upper().strip()
return None
@@ -688,20 +651,13 @@ def _detect_rent_frequency(price_text: str) -> str:
Zoopla price elements contain text like '£1,500 pcm', '£350 pw',
'£18,000 pa'. Defaults to 'monthly' if no frequency indicator found.
-
- Checks monthly indicators (pcm) BEFORE weekly (pw) because Zoopla cards
- often display both monthly and weekly prices in the same text. When the
- JS extraction falls back to full card text, checking pcm first ensures
- the captured monthly price gets the correct frequency label.
"""
lower = price_text.lower()
- if "pcm" in lower or "per month" in lower or "per calendar month" in lower:
- return "monthly"
if "pw" in lower or "per week" in lower or "/w" in lower:
return "weekly"
if "pa" in lower or "per annum" in lower or "/y" in lower or "per year" in lower:
return "yearly"
- # No indicator — default monthly (Zoopla standard)
+ # pcm, per month, /m, or no indicator — default monthly
return "monthly"
diff --git a/frontend/src/components/invite/InvitePage.tsx b/frontend/src/components/invite/InvitePage.tsx
index 0758d1c..8fb68e1 100644
--- a/frontend/src/components/invite/InvitePage.tsx
+++ b/frontend/src/components/invite/InvitePage.tsx
@@ -204,7 +204,7 @@ export default function InvitePage({
)}
- Property prices, energy ratings, crime stats, school ratings and more
+ Property prices, energy ratings, crime stats, school ratings & more
diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx
index 5defdd9..8adea94 100644
--- a/frontend/src/components/learn/LearnPage.tsx
+++ b/frontend/src/components/learn/LearnPage.tsx
@@ -15,7 +15,7 @@ const DATA_SOURCES = [
id: 'price-paid',
name: 'Price Paid Data',
origin: 'HM Land Registry',
- use: 'Complete historical property sale prices for England.',
+ use: 'Complete historical property sale prices for England. Used for the last known sale price of each property.',
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
license: 'Open Government Licence v3.0',
},
@@ -23,7 +23,7 @@ const DATA_SOURCES = [
id: 'epc',
name: 'Energy Performance Certificates (EPC)',
origin: 'Ministry of Housing, Communities & Local Government',
- use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Matched with Price Paid records by address within each postcode. Property owners can opt out of public disclosure.',
+ use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
optOutUrl:
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
url: 'https://epc.opendatacommunities.org/downloads/domestic',
@@ -33,7 +33,7 @@ const DATA_SOURCES = [
id: 'nspl',
name: 'National Statistics Postcode Lookup (NSPL)',
origin: 'ONS / ArcGIS',
- use: 'Maps postcodes to coordinates and statistical area codes, used to link all area-level datasets to individual properties.',
+ use: 'Maps postcodes to latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.',
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
license: 'Open Government Licence v3.0',
},
@@ -41,7 +41,7 @@ const DATA_SOURCES = [
id: 'iod',
name: 'English Indices of Deprivation 2025',
origin: 'Ministry of Housing, Communities & Local Government',
- use: 'Relative deprivation scores across income, employment, education, health, crime, and living environment for every neighbourhood in England.',
+ use: 'Relative deprivation scores for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.',
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
license: 'Open Government Licence v3.0',
},
@@ -49,7 +49,7 @@ const DATA_SOURCES = [
id: 'ethnicity',
name: 'Population by Ethnicity (2021 Census)',
origin: 'ONS',
- use: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per local authority.',
+ use: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
license: 'Open Government Licence v3.0',
},
@@ -65,7 +65,7 @@ const DATA_SOURCES = [
id: 'osm-pois',
name: 'OpenStreetMap POIs',
origin: 'OpenStreetMap contributors / Geofabrik',
- use: 'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
+ use: 'Points of interest extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.',
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
license: 'Open Data Commons Open Database License (ODbL)',
},
@@ -81,7 +81,7 @@ const DATA_SOURCES = [
id: 'naptan',
name: 'NaPTAN (Public Transport Stops)',
origin: 'Department for Transport',
- use: 'Station and stop locations for rail, bus, metro/tram, ferry, and airports across England.',
+ use: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.',
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
license: 'Open Government Licence v3.0',
},
@@ -89,7 +89,7 @@ const DATA_SOURCES = [
id: 'noise',
name: 'Defra Noise Mapping',
origin: 'Defra / Environment Agency',
- use: 'Road noise levels (24-hour weighted average) from the 2022 strategic noise mapping, modelled at high resolution and sampled at each postcode.',
+ use: 'Strategic noise mapping Round 4 (2022) for road, rail, and airport sources. Lden (day-evening-night 24h weighted average) at 10m grid resolution, modelled at 4m above ground. Sampled at postcode centroids via WCS GeoTIFF tiles.',
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
license: 'Open Government Licence v3.0',
},
@@ -105,7 +105,7 @@ const DATA_SOURCES = [
id: 'broadband',
name: 'Ofcom Broadband Performance',
origin: 'Ofcom',
- use: 'Fixed broadband coverage and maximum download speeds by area from Ofcom Connected Nations 2025.',
+ use: 'Fixed broadband coverage and speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.',
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
license: 'Open Government Licence v3.0',
},
@@ -144,22 +144,22 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: "I don't even know which areas to look at. Can this help?",
answer:
- 'That\'s exactly what it\'s for. Set your filters (budget, commute time, low crime, good schools) and the map lights up to show you every area that ticks every box. No more Googling "best areas to live near Manchester" at midnight.',
+ 'That\'s exactly what it\'s for. Set your filters (budget, commute time, low crime, good schools, whatever matters) and the map lights up to show you where ticks every box. No more Googling "best areas to live near Manchester" at 1am.',
},
{
question: "I'm moving somewhere I've never been. How do I even start?",
answer:
- "Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes.",
+ "Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes. It's like having a local's knowledge of every neighbourhood in England.",
},
{
question: 'How do I find areas that tick all my boxes at once?',
answer:
- 'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.',
+ 'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the affordable sweet spots. The map updates live as you drag sliders, so you can watch neighbourhoods light up or drop off in real time.',
},
],
},
{
- title: 'Commute and Travel',
+ title: 'Commute & Travel',
items: [
{
question: 'Can I see how long my commute would actually be from different areas?',
@@ -174,22 +174,22 @@ const FAQ_SECTIONS: FAQSection[] = [
],
},
{
- title: 'Budget and Value',
+ title: 'Budget & Value',
items: [
{
question: 'How do I find areas where I get the most space for my money?',
answer:
- "Filter by price per sqm and you'll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
+ "Filter by price per sqm and you'll instantly see which postcodes give you the most square footage per pound. Pair it with the energy rating filter to avoid cheap-but-freezing money pits.",
},
{
question: "How do I make sure a cheap area isn't cheap for a reason?",
answer:
- "Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, you've found genuine value, not just a low price with trade-offs you haven't spotted yet.",
+ "Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable AND scores well on the stuff that matters, that's your hidden gem, not just a cheap postcode with a catch.",
},
],
},
{
- title: 'Safety and Neighbourhood',
+ title: 'Safety & Neighbourhood',
items: [
{
question: 'How can I check if an area is safe before I move there?',
@@ -198,19 +198,19 @@ const FAQ_SECTIONS: FAQSection[] = [
},
{
question:
- 'I keep finding flats that look great online, then the area turns out to be rough.',
+ 'I keep finding flats that look great online, then the area turns out to be grim.',
answer:
- "That's exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
+ "That's why we built this. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you waste a Saturday viewing.",
},
],
},
{
- title: 'Families and Schools',
+ title: 'Families & Schools',
items: [
{
question: 'Can I find areas with good schools AND low crime in one search?',
answer:
- 'Yes. Stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, and the map highlights only the areas that tick every box. No more cross-referencing five different websites.',
+ 'Absolutely. Stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, then watch the map highlight only the areas that tick every box. No more cross-referencing five different websites with a spreadsheet.',
},
{
question: 'How do I know if a neighbourhood has parks and playgrounds nearby?',
@@ -220,7 +220,7 @@ const FAQ_SECTIONS: FAQSection[] = [
],
},
{
- title: 'Environment and Quality of Life',
+ title: 'Environment & Quality of Life',
items: [
{
question: "Can I find energy-efficient homes that aren't on a noisy road?",
@@ -230,12 +230,12 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'Does it show flood or subsidence risk?',
answer:
- "We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before committing to a property. Filter out risky areas early.",
+ "We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before you fall in love with a property. Filter it out early and save yourself the surveyor's surprise.",
},
{
question: 'Can I find areas with fast broadband that are actually quiet?',
answer:
- 'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to compare areas at a glance.',
+ 'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to spot the sweet spots instantly.',
},
],
},
@@ -245,12 +245,12 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'I already use Rightmove. What does this add?',
answer:
- "Rightmove shows you houses. We show you areas. Crime rates, school ratings, broadband speeds, noise levels, deprivation scores, and more, all filterable on one map. You can judge a neighbourhood before you even look at listings.",
+ "Rightmove shows you houses. We show you areas. You'll see 56 layers of data (crime rates, school ratings, broadband speeds, noise levels, deprivation scores) all on one map, so you can judge a neighbourhood before you even look at listings.",
},
{
question: "Can't I just research all this myself for free?",
answer:
- 'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have it all filterable and colour-coded on one map in seconds.',
+ 'You could spend weeks cross-referencing police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have all 56 datasets filterable and colour-coded on one map in seconds. Your time has a price too.',
},
{
question: 'Where does the data actually come from?',
@@ -260,15 +260,15 @@ const FAQ_SECTIONS: FAQSection[] = [
],
},
{
- title: 'Pricing and Access',
+ title: 'Pricing & Access',
items: [
{
question: 'Is it really worth paying for a property search tool?',
answer:
- "Buying a home is likely the biggest purchase you'll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
+ "You're making a decision worth \u00a3200k to \u00a3500k or more. Even spotting one red flag (a noisy road, poor broadband, rising crime) that changes your mind could save you years of regret. This costs less than a single viewing trip in petrol.",
},
{
- question: "Is this a subscription?",
+ question: "Is this another subscription that'll drain my account?",
answer:
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever you're curious about a new area, and it's still there if you ever move again.",
},
@@ -285,7 +285,7 @@ const FAQ_SECTIONS: FAQSection[] = [
],
},
{
- title: 'Tips and Tricks',
+ title: 'Tips & Tricks',
items: [
{
question: 'How do I use the AI filter instead of adding filters one by one?',
diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx
index b4d315c..0850e26 100644
--- a/frontend/src/components/map/AiFilterInput.tsx
+++ b/frontend/src/components/map/AiFilterInput.tsx
@@ -6,7 +6,7 @@ import type { AiFilterErrorType } from '../../hooks/useAiFilters';
const EXAMPLE_QUERIES = [
'Safe area near good schools',
- '30 min commute to Kings Cross, under \u00A3500k',
+ '30 min commute to Kings Cross, under 500k',
'Quiet village, 3 bed, fast broadband',
];
@@ -177,7 +177,7 @@ export default memo(function AiFilterInput({
resizeTextarea();
}}
onKeyDown={handleKeyDown}
- placeholder="e.g. quiet area, under £400k, near good schools..."
+ placeholder="e.g. quiet area, under 400k, near good schools..."
className="flex-1 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400 focus:bg-white dark:focus:bg-warm-800 resize-none overflow-hidden"
rows={1}
style={{ maxHeight: '6rem' }}
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx
index 771a3e6..e69f239 100644
--- a/frontend/src/components/map/AreaPane.tsx
+++ b/frontend/src/components/map/AreaPane.tsx
@@ -109,7 +109,8 @@ export default function AreaPane({
)}
- Stats for all properties in this {isPostcode ? 'postcode' : 'area'}
+ Stats for {isPostcode ? 'current and historical' : 'all'} properties in this{' '}
+ {isPostcode ? 'postcode' : 'area'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
- Choose the filters that matter to you. The map updates as you go.
+ Everyone cares about different things. Pick the filters that matter most to you.
) : (
- See crime, schools, noise, broadband, and 50+ more filters across all of England.
+ The biggest financial decision of your life deserves proper tools behind it.
- One-time payment, lifetime access.
+ Don't leave it to chance.
- Start with your must-haves, then layer on nice-to-haves. The map narrows as you add
- filters. The areas left are your best matches.
+ Start with your must-haves, then layer on nice-to-haves. The map narrows down as you
+ add filters. The areas that survive are your best matches.
Energy{' '}
- (EPC ratings, insulation, heating costs)
+ (EPC ratings for lower bills and fewer surprises)
- Tip: if nothing matches, relax one constraint at a time to see which trade-off opens
- up the most options.
+ Tip: if nothing survives, relax one constraint at a time to see which compromise
+ unlocks the most options.
- Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants,
- healthcare, leisure, and more. Updated regularly with complete category coverage.
+ Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories
+ include public transport stops, shops, restaurants, healthcare facilities, leisure
+ venues, and more. Data is filtered and mapped to friendly names with exhaustive
+ category coverage.
- Uses the fastest realistic journey time (if you time your departure well and catch good
- connections). The default uses the median, representing a typical journey
- regardless of when you leave.
+ Uses the 5th percentile travel time - the fastest realistic journey if
+ you time your departure to catch optimal connections. The default uses the{' '}
+ median, representing a typical journey regardless of when you leave.
)}
diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx
index 11c7a37..41b3732 100644
--- a/frontend/src/components/pricing/PricingPage.tsx
+++ b/frontend/src/components/pricing/PricingPage.tsx
@@ -12,7 +12,7 @@ const FEATURES = [
'Every postcode scored and filterable',
'Unlimited map exploration and exports',
'Multiple decades of historical price data',
- 'Crime, schools, transport, broadband and more',
+ 'Crime, schools, transport, broadband & more',
'All future data updates included',
];
@@ -207,7 +207,7 @@ export default function PricingPage({
or a road you didn't know about.
- Less than a home survey. Far more useful.
+ Less than your survey costs. Vastly more useful.
- Full access to every feature, every postcode, across all of England.
+ You now have full access to every feature across all of England. Happy exploring!
diff --git a/frontend/src/hooks/useHexagonSelection.ts b/frontend/src/hooks/useHexagonSelection.ts
index a29976a..5a36932 100644
--- a/frontend/src/hooks/useHexagonSelection.ts
+++ b/frontend/src/hooks/useHexagonSelection.ts
@@ -57,7 +57,7 @@ export function useHexagonSelection({
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
if (fields) {
- params.set('fields', fields.join(';;'));
+ params.set('fields', fields.join(','));
}
if (journeyDest) {
params.set('journey_mode', journeyDest.mode);
diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts
index 4eeb277..704caa6 100644
--- a/frontend/src/hooks/useTravelTime.ts
+++ b/frontend/src/hooks/useTravelTime.ts
@@ -4,7 +4,7 @@ import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
-export const TRANSPORT_MODES: TransportMode[] = ['transit', 'car', 'bicycle', 'walking'];
+export const TRANSPORT_MODES: TransportMode[] = ['car', 'bicycle', 'walking', 'transit'];
export const MODE_LABELS: Record = {
car: 'Car',
diff --git a/frontend/src/hooks/useTutorial.ts b/frontend/src/hooks/useTutorial.ts
index e9a8fd2..5f13d37 100644
--- a/frontend/src/hooks/useTutorial.ts
+++ b/frontend/src/hooks/useTutorial.ts
@@ -9,7 +9,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="filters"]',
title: 'Tell the map what matters',
content:
- 'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
+ 'Set your budget, commute limit, school quality, crime threshold \u2014 whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
placement: 'right',
disableBeacon: true,
},
@@ -17,7 +17,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="ai-filters"]',
title: 'Or just describe it',
content:
- 'Type what you want in plain English, like "quiet area near good schools under \u00A3400k", and we\u2019ll set up the filters for you.',
+ 'Type what you want in plain English \u2014 like "quiet area near good schools under \u00A3400k" \u2014 and we\u2019ll set up the filters for you.',
placement: 'right',
disableBeacon: true,
},
@@ -25,7 +25,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="map"]',
title: 'Explore what\u2019s out there',
content:
- 'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.',
+ 'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise \u2014 everything about that neighbourhood.',
placement: 'bottom',
disableBeacon: true,
},
@@ -40,7 +40,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="right-pane"]',
title: 'Dig into the details',
content:
- 'See area statistics, histograms, and individual property records: prices, floor area, energy ratings, and more.',
+ 'See area statistics, histograms, and individual property records \u2014 prices, floor area, energy ratings, and more.',
placement: 'left',
disableBeacon: true,
},
diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts
index e7e1195..404177d 100644
--- a/frontend/src/lib/consts.ts
+++ b/frontend/src/lib/consts.ts
@@ -189,6 +189,7 @@ export const STACKED_ENUM_GROUPS: Record<
valueColors: ['#3b82f6', '#f59e0b'],
},
],
+ Environment: [],
};
/**
diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs
index 7fb4b0d..7868f56 100644
--- a/server-rs/src/features.rs
+++ b/server-rs/src/features.rs
@@ -39,6 +39,11 @@ pub struct FeatureConfig {
pub const INTEGER_BIN_FEATURES: &[&str] =
&["Number of bedrooms & living rooms", "Bedrooms", "Bathrooms"];
+pub struct FeatureGroup {
+ pub name: &'static str,
+ pub features: &'static [FeatureConfig],
+}
+
pub struct EnumFeatureConfig {
pub name: &'static str,
/// If set, values are presented in this order instead of alphabetical.
@@ -52,45 +57,16 @@ pub struct EnumFeatureConfig {
pub source: &'static str,
}
-/// Wrapper enum allowing numeric and enum features to be interleaved
-/// in a single ordered array. The position in the array determines
-/// the display order on the frontend.
-pub enum Feature {
- Numeric(FeatureConfig),
- Enum(EnumFeatureConfig),
-}
-
-pub struct FeatureGroup {
+pub struct EnumFeatureGroup {
pub name: &'static str,
- pub features: &'static [Feature],
+ pub features: &'static [EnumFeatureConfig],
}
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup {
name: "Properties",
features: &[
- Feature::Enum(EnumFeatureConfig {
- name: "Listing status",
- order: Some(&["Historical sale", "For sale", "For rent"]),
- description: "Whether the property is from historical sales, currently for sale, or for rent",
- detail: "Indicates the source of the property record: 'Historical sale' from HM Land Registry Price Paid data, 'For sale' from current online buy listings, or 'For rent' from current online rental listings.",
- source: "online-listings",
- }),
- Feature::Enum(EnumFeatureConfig {
- name: "Property type",
- order: Some(&["Detached", "Semi-Detached", "Terraced", "Flats/Maisonettes", "Other"]),
- description: "Type of property: detached, semi-detached, terraced, flat/maisonette, or other",
- detail: "From HM Land Registry Price Paid data and EPC certificates. Detached, Semi-Detached, Terraced (includes all terrace sub-types), Flats/Maisonettes, or Other (bungalows, park homes, etc.).",
- source: "price-paid",
- }),
- Feature::Enum(EnumFeatureConfig {
- name: "Leasehold/Freehold",
- order: Some(&["Freehold", "Leasehold"]),
- description: "Whether the property is leasehold or freehold",
- detail: "From HM Land Registry Price Paid data. Freehold means you own the building and the land it stands on. Leasehold means you own the building but not the land: you have a lease from the freeholder for a set number of years.",
- source: "price-paid",
- }),
- Feature::Numeric(FeatureConfig {
+ FeatureConfig {
name: "Last known price",
bounds: Bounds::Fixed {
min: 0.0,
@@ -106,8 +82,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: true,
modes: &["historical"],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Estimated current price",
bounds: Bounds::Fixed {
min: 0.0,
@@ -115,7 +91,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
step: 10000.0,
description: "Inflation-adjusted estimate of the current property value",
- detail: "Based on the last sale price, adjusted for local price changes over time using a repeat-sales index (tracked per postcode sector and property type). If post-sale improvements are detected from EPC records, a renovation premium is added. Recent sales will be close to the original price; older sales are adjusted more.",
+ detail: "Estimated by applying a repeat-sales price index to the last known sale price, plus a renovation premium for properties with post-sale improvements detected from EPC records (extensions, renovations, remodelling). The index tracks price changes within each postcode sector and property type. Renovation premiums are estimated per area from observed repeat-sale pairs and decay over time. Properties sold recently will have estimates close to their sale price; older sales are adjusted more.",
source: "price-paid",
prefix: "£",
suffix: "",
@@ -123,25 +99,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: true,
modes: &["historical"],
linked: "Asking price",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Asking price",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 2_500_000.0,
- },
- step: 10000.0,
- description: "Asking price for properties currently listed for sale",
- detail: "The advertised asking price from online property portals. Only available for 'For sale' listings.",
- source: "online-listings",
- prefix: "£",
- suffix: "",
- raw: false,
- absolute: true,
- modes: &["buy"],
- linked: "Estimated current price",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
@@ -157,8 +116,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &["historical"],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Est. price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
@@ -174,8 +133,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &["historical"],
linked: "Asking price per sqm",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Asking price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
@@ -191,39 +150,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &["buy"],
linked: "Est. price per sqm",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Estimated monthly rent",
- bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
- step: 25.0,
- description: "Median monthly private rent for the local area",
- detail: "Median monthly rental price from ONS Private Rental Market Summary Statistics (Oct 2022 - Sep 2023), matched by local authority and bedroom count. Based on Valuation Office Agency lettings data.",
- source: "ons-rental",
- prefix: "£",
- suffix: "/mo",
- raw: false,
- absolute: false,
- modes: &["historical"],
- linked: "Asking rent (monthly)",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Asking rent (monthly)",
- bounds: Bounds::Percentile {
- low: 0.0,
- high: 98.0,
- },
- step: 50.0,
- description: "Listed monthly rent for properties currently for rent",
- detail: "The advertised rental price from online property portals, converted to a monthly figure where needed (e.g. weekly or yearly listings). Only available for 'For rent' listings.",
- source: "online-listings",
- prefix: "£",
- suffix: "/mo",
- raw: false,
- absolute: false,
- modes: &["rent"],
- linked: "Estimated monthly rent",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Total floor area (sqm)",
bounds: Bounds::Percentile {
low: 0.0,
@@ -239,131 +167,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Number of bedrooms & living rooms",
- bounds: Bounds::Fixed {
- min: 1.0,
- max: 12.0,
- },
- step: 1.0,
- description: "Count of habitable rooms from the EPC survey",
- detail: "Total number of habitable rooms (bedrooms plus living rooms) as recorded in the Energy Performance Certificate. Kitchens and bathrooms are typically excluded unless they are large enough to count as habitable rooms.",
- source: "epc",
- prefix: "",
- suffix: " rooms",
- raw: false,
- absolute: true,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Bedrooms",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 10.0,
- },
- step: 1.0,
- description: "Number of bedrooms from online listing",
- detail: "Number of bedrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
- source: "online-listings",
- prefix: "",
- suffix: "",
- raw: false,
- absolute: true,
- modes: &["buy", "rent"],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Bathrooms",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 10.0,
- },
- step: 1.0,
- description: "Number of bathrooms from online listing",
- detail: "Number of bathrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
- source: "online-listings",
- prefix: "",
- suffix: "",
- raw: false,
- absolute: true,
- modes: &["buy", "rent"],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Construction year",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 2026.0,
- },
- step: 1.0,
- description: "Estimated year of construction from the EPC",
- detail: "Derived from the construction age band in the EPC (e.g. '1930-1949') by taking the midpoint. Less precise for older buildings where the age band spans several decades.",
- source: "epc",
- prefix: "",
- suffix: "",
- raw: true,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Date of last transaction",
- bounds: Bounds::Fixed {
- min: 1995.0,
- max: 2026.0,
- },
- step: 1.0,
- description: "Date of the most recent sale from the Land Registry",
- detail: "The date of the most recent recorded sale for this property from HM Land Registry Price Paid data. Stored as a datetime in the data; converted to fractional year for filtering and charting.",
- source: "price-paid",
- prefix: "",
- suffix: "",
- raw: true,
- absolute: false,
- modes: &["historical"],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Listing date",
- bounds: Bounds::Fixed {
- min: 2006.0,
- max: 2026.0,
- },
- step: 1.0,
- description: "Date the property was first listed online",
- detail: "The date when the property listing first appeared on the online property portal. Stored as a datetime; converted to fractional year for filtering. Only populated for online listings.",
- source: "online-listings",
- prefix: "",
- suffix: "",
- raw: true,
- absolute: false,
- modes: &["buy", "rent"],
- linked: "",
- }),
- Feature::Enum(EnumFeatureConfig {
- name: "Former council house",
- order: Some(&["Yes", "No"]),
- description: "Whether the property was ever recorded as social housing",
- detail: "Derived from the TENURE field in Energy Performance Certificate data. If any EPC certificate for this property recorded the tenure as social rental, it indicates the property was council or housing-association stock at the time of that inspection. Properties that were later sold (e.g. via Right to Buy) retain this flag.",
- source: "epc",
- }),
- Feature::Enum(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",
- }),
- Feature::Enum(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",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Interior height (m)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -379,30 +184,163 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &["historical"],
linked: "",
- }),
+ },
+ FeatureConfig {
+ name: "Number of bedrooms & living rooms",
+ bounds: Bounds::Fixed {
+ min: 1.0,
+ max: 12.0,
+ },
+ step: 1.0,
+ description: "Count of habitable rooms from the EPC survey",
+ detail: "Total number of habitable rooms (bedrooms plus living rooms) as recorded in the Energy Performance Certificate. Kitchens and bathrooms are typically excluded unless they are large enough to count as habitable rooms.",
+ source: "epc",
+ prefix: "",
+ suffix: " rooms",
+ raw: false,
+ absolute: true,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Estimated monthly rent",
+ bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
+ step: 25.0,
+ description: "Median monthly private rent for the local area and bedroom count",
+ detail: "Median monthly rental price from ONS Private Rental Market Summary Statistics (Oct 2022 - Sep 2023). Matched by local authority district and estimated bedroom count (habitable rooms minus 1). Based on Valuation Office Agency lettings data.",
+ source: "ons-rental",
+ prefix: "£",
+ suffix: "/mo",
+ raw: false,
+ absolute: false,
+ modes: &["historical"],
+ linked: "Asking rent (monthly)",
+ },
+ FeatureConfig {
+ name: "Date of last transaction",
+ bounds: Bounds::Fixed {
+ min: 1995.0,
+ max: 2026.0,
+ },
+ step: 1.0,
+ description: "Date of the most recent sale from the Land Registry",
+ detail: "The date of the most recent recorded sale for this property from HM Land Registry Price Paid data. Stored as a datetime in the data; converted to fractional year for filtering and charting.",
+ source: "price-paid",
+ prefix: "",
+ suffix: "",
+ raw: true,
+ absolute: false,
+ modes: &["historical"],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Construction year",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 2026.0,
+ },
+ step: 1.0,
+ description: "Estimated year of construction from the EPC",
+ detail: "The approximate construction year as recorded in the Energy Performance Certificate. Derived from the construction age band (e.g. '1930-1949') by taking the midpoint. May be approximate, especially for older buildings.",
+ source: "epc",
+ prefix: "",
+ suffix: "",
+ raw: true,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Asking price",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 2_500_000.0,
+ },
+ step: 10000.0,
+ description: "Listed asking price for properties currently for sale",
+ detail: "The advertised asking price for properties currently listed for sale on online property portals. Only populated for 'For sale' listings; null for historical sales and rentals.",
+ source: "online-listings",
+ prefix: "£",
+ suffix: "",
+ raw: false,
+ absolute: true,
+ modes: &["buy"],
+ linked: "Estimated current price",
+ },
+ FeatureConfig {
+ name: "Asking rent (monthly)",
+ bounds: Bounds::Percentile {
+ low: 0.0,
+ high: 98.0,
+ },
+ step: 50.0,
+ description: "Listed monthly rent for properties currently for rent",
+ detail: "The advertised rental price normalised to monthly for properties currently listed for rent on online property portals. Weekly rents are converted (×52/12), yearly (/12), daily (×365.25/12), and quarterly (/3). Only populated for 'For rent' listings.",
+ source: "online-listings",
+ prefix: "£",
+ suffix: "/mo",
+ raw: false,
+ absolute: false,
+ modes: &["rent"],
+ linked: "Estimated monthly rent",
+ },
+ FeatureConfig {
+ name: "Bedrooms",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 10.0,
+ },
+ step: 1.0,
+ description: "Number of bedrooms from online listing",
+ detail: "Number of bedrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
+ source: "online-listings",
+ prefix: "",
+ suffix: "",
+ raw: false,
+ absolute: true,
+ modes: &["buy", "rent"],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Bathrooms",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 10.0,
+ },
+ step: 1.0,
+ description: "Number of bathrooms from online listing",
+ detail: "Number of bathrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
+ source: "online-listings",
+ prefix: "",
+ suffix: "",
+ raw: false,
+ absolute: true,
+ modes: &["buy", "rent"],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Listing date",
+ bounds: Bounds::Fixed {
+ min: 2006.0,
+ max: 2026.0,
+ },
+ step: 1.0,
+ description: "Date the property was first listed online",
+ detail: "The date when the property listing first appeared on the online property portal. Stored as a datetime; converted to fractional year for filtering. Only populated for online listings.",
+ source: "online-listings",
+ prefix: "",
+ suffix: "",
+ raw: true,
+ absolute: false,
+ modes: &["buy", "rent"],
+ linked: "",
+ },
],
},
FeatureGroup {
name: "Transport",
features: &[
- Feature::Numeric(FeatureConfig {
- name: "Distance to nearest train or tube station (km)",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 0.1,
- description: "Distance to the closest train or tube station",
- detail: "Straight-line distance in kilometres from the postcode to the nearest rail station or Tube/metro/tram stop.",
- source: "naptan",
- prefix: "",
- suffix: " km",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ FeatureConfig {
name: "Train or tube stations within 1km",
bounds: Bounds::Percentile {
low: 5.0,
@@ -410,7 +348,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
step: 1.0,
description: "Number of train or tube stations within 1km",
- detail: "Rail stations and Tube/metro/tram stops within 1km of the postcode. Does not include bus stops.",
+ detail: "Count of rail stations and Tube/metro/tram stops within a 1km radius of the property's postcode. Derived from the NaPTAN (National Public Transport Access Nodes) dataset. Does not include bus stops.",
source: "naptan",
prefix: "",
suffix: "",
@@ -418,89 +356,38 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
+ },
+ FeatureConfig {
+ name: "Distance to nearest train or tube station (km)",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 0.1,
+ description: "Distance to the closest train or tube station",
+ detail: "Straight-line distance in kilometres from the property's postcode centroid to the nearest rail station or Tube/metro/tram stop. Derived from the NaPTAN (National Public Transport Access Nodes) dataset.",
+ source: "naptan",
+ prefix: "",
+ suffix: " km",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
],
},
FeatureGroup {
name: "Education",
features: &[
- Feature::Numeric(FeatureConfig {
- name: "Good+ primary schools within 2km",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 10.0,
- },
- step: 1.0,
- description: "Primary schools rated Good or Outstanding by Ofsted within 2km",
- detail: "State-funded primary schools within 2km with a current Ofsted rating of Good or Outstanding. Schools not yet inspected are excluded.",
- source: "ofsted",
- prefix: "",
- suffix: "",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Good+ secondary schools within 2km",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 5.0,
- },
- step: 1.0,
- description: "Secondary schools rated Good or Outstanding by Ofsted within 2km",
- detail: "State-funded secondary schools within 2km with a current Ofsted rating of Good or Outstanding. Schools not yet inspected are excluded.",
- source: "ofsted",
- prefix: "",
- suffix: "",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Good+ primary schools within 5km",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 30.0,
- },
- step: 1.0,
- description: "Primary schools rated Good or Outstanding by Ofsted within 5km",
- detail: "State-funded primary schools within 5km with a current Ofsted rating of Good or Outstanding. Schools not yet inspected are excluded.",
- source: "ofsted",
- prefix: "",
- suffix: "",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Good+ secondary schools within 5km",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 15.0,
- },
- step: 1.0,
- description: "Secondary schools rated Good or Outstanding by Ofsted within 5km",
- detail: "State-funded secondary schools within 5km with a current Ofsted rating of Good or Outstanding. Schools not yet inspected are excluded.",
- source: "ofsted",
- prefix: "",
- suffix: "",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ FeatureConfig {
name: "Education, Skills and Training Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
- description: "Education quality score for the local area (higher = better)",
- detail: "From the English Indices of Deprivation (inverted so higher = better). Covers school attainment, entry to higher education, adult qualifications, and English language proficiency. Higher scores indicate less deprivation.",
+ description: "IoD education score for the local area (higher = better)",
+ detail: "From the English Indices of Deprivation (inverted so higher = better). Measures education, skills and training quality in the local area (LSOA). Higher scores indicate less deprivation. Combines children/young people sub-domain (school attainment, entry to higher education) and adult skills sub-domain (adult qualifications, English language proficiency).",
source: "iod",
prefix: "",
suffix: "",
@@ -508,13 +395,81 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
+ },
+ FeatureConfig {
+ name: "Good+ primary schools within 5km",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 30.0,
+ },
+ step: 1.0,
+ description: "Primary schools rated Good or Outstanding by Ofsted nearby",
+ detail: "Number of state-funded primary schools within 5km that have a current Ofsted rating of Good or Outstanding. Based on the latest inspection outcomes dataset. Schools that have not yet been inspected are excluded.",
+ source: "ofsted",
+ prefix: "",
+ suffix: "",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Good+ secondary schools within 5km",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 15.0,
+ },
+ step: 1.0,
+ description: "Secondary schools rated Good or Outstanding by Ofsted nearby",
+ detail: "Number of state-funded secondary schools within 5km that have a current Ofsted rating of Good or Outstanding. Based on the latest inspection outcomes dataset. Schools that have not yet been inspected are excluded.",
+ source: "ofsted",
+ prefix: "",
+ suffix: "",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Good+ primary schools within 2km",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 10.0,
+ },
+ step: 1.0,
+ description: "Primary schools rated Good or Outstanding by Ofsted within walking distance",
+ detail: "Number of state-funded primary schools within 2km that have a current Ofsted rating of Good or Outstanding. Based on the latest inspection outcomes dataset. Schools that have not yet been inspected are excluded.",
+ source: "ofsted",
+ prefix: "",
+ suffix: "",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Good+ secondary schools within 2km",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 5.0,
+ },
+ step: 1.0,
+ description: "Secondary schools rated Good or Outstanding by Ofsted within walking distance",
+ detail: "Number of state-funded secondary schools within 2km that have a current Ofsted rating of Good or Outstanding. Based on the latest inspection outcomes dataset. Schools that have not yet been inspected are excluded.",
+ source: "ofsted",
+ prefix: "",
+ suffix: "",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
],
},
FeatureGroup {
name: "Deprivation",
features: &[
- Feature::Numeric(FeatureConfig {
+ FeatureConfig {
name: "Income Score (rate)",
bounds: Bounds::Fixed { min: 0.0, max: 0.6 },
step: 0.01,
@@ -527,8 +482,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Employment Score (rate)",
bounds: Bounds::Fixed { min: 0.0, max: 0.4 },
step: 0.01,
@@ -541,8 +496,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Health Deprivation and Disability Score",
bounds: Bounds::Percentile {
low: 2.0,
@@ -558,8 +513,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Living Environment Score",
bounds: Bounds::Percentile {
low: 2.0,
@@ -567,7 +522,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
step: 0.1,
description: "Quality of the local indoor and outdoor environment (higher = better)",
- detail: "From the English Indices of Deprivation (inverted so higher = better). Combines housing quality (condition, central heating) and outdoor environment (air quality, road safety). Higher scores indicate better living environments.",
+ detail: "From the English Indices of Deprivation (inverted so higher = better). Measures the quality of the local environment. Combines the Indoors sub-domain (housing quality, central heating, housing conditions) and Outdoors sub-domain (air quality, road traffic accidents). Higher scores indicate better living environments.",
source: "iod",
prefix: "",
suffix: "",
@@ -575,8 +530,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Indoors Sub-domain Score",
bounds: Bounds::Percentile {
low: 2.0,
@@ -592,8 +547,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Outdoors Sub-domain Score",
bounds: Bounds::Percentile {
low: 2.0,
@@ -609,47 +564,13 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
+ },
],
},
- FeatureGroup {
+ FeatureGroup {
name: "Crime",
features: &[
- Feature::Numeric(FeatureConfig {
- name: "Serious crime per 1k residents (avg/yr)",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 0.1,
- description: "Serious crime rate per 1,000 residents per year",
- detail: "Violence, robbery, burglary, and weapons possession per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data (2023-2025) and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.",
- source: "crime",
- prefix: "",
- suffix: "/yr",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Minor crime per 1k residents (avg/yr)",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 0.1,
- description: "Minor crime rate per 1,000 residents per year",
- detail: "Anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data (2023-2025) and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.",
- source: "crime",
- prefix: "",
- suffix: "/yr",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ FeatureConfig {
name: "Serious crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -665,8 +586,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Minor crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -682,16 +603,16 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Violence and sexual offences (avg/yr)",
+ },
+ FeatureConfig {
+ name: "Serious crime per 1k residents (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
- step: 1.0,
- description: "Average yearly violent and sexual offences in the area",
- detail: "Average number of violence and sexual offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes assault, harassment, and sexual offences.",
+ step: 0.1,
+ description: "Serious crime rate per 1,000 residents per year",
+ detail: "Violence, robbery, burglary, and weapons possession per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data (2023-2025) and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.",
source: "crime",
prefix: "",
suffix: "/yr",
@@ -699,16 +620,16 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Burglary (avg/yr)",
+ },
+ FeatureConfig {
+ name: "Minor crime per 1k residents (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
- step: 1.0,
- description: "Average yearly burglary offences in the area",
- detail: "Average number of burglary offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes residential and commercial burglary.",
+ step: 0.1,
+ description: "Minor crime rate per 1,000 residents per year",
+ detail: "Anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data (2023-2025) and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.",
source: "crime",
prefix: "",
suffix: "/yr",
@@ -716,42 +637,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Robbery (avg/yr)",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 1.0,
- description: "Average yearly robbery offences in the area",
- detail: "Average number of robbery offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Robbery involves theft with force or threat of force.",
- source: "crime",
- prefix: "",
- suffix: "/yr",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Vehicle crime (avg/yr)",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 1.0,
- description: "Average yearly vehicle crime in the area",
- detail: "Average number of vehicle crime incidents per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes theft of and from vehicles.",
- source: "crime",
- prefix: "",
- suffix: "/yr",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Anti-social behaviour (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -767,8 +654,25 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
+ name: "Violence and sexual offences (avg/yr)",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 1.0,
+ description: "Average yearly violent and sexual offences in the area",
+ detail: "Average number of violence and sexual offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes assault, harassment, and sexual offences.",
+ source: "crime",
+ prefix: "",
+ suffix: "/yr",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
name: "Criminal damage and arson (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -784,8 +688,59 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
+ name: "Burglary (avg/yr)",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 1.0,
+ description: "Average yearly burglary offences in the area",
+ detail: "Average number of burglary offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes residential and commercial burglary.",
+ source: "crime",
+ prefix: "",
+ suffix: "/yr",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Vehicle crime (avg/yr)",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 1.0,
+ description: "Average yearly vehicle crime in the area",
+ detail: "Average number of vehicle crime incidents per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes theft of and from vehicles.",
+ source: "crime",
+ prefix: "",
+ suffix: "/yr",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Robbery (avg/yr)",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 1.0,
+ description: "Average yearly robbery offences in the area",
+ detail: "Average number of robbery offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Robbery involves theft with force or threat of force.",
+ source: "crime",
+ prefix: "",
+ suffix: "/yr",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
name: "Other theft (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -801,25 +756,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Theft from the person (avg/yr)",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 1.0,
- description: "Average yearly theft from the person in the area",
- detail: "Average number of theft from the person offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes pickpocketing and bag snatching without force.",
- source: "crime",
- prefix: "",
- suffix: "/yr",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Shoplifting (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -835,25 +773,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Bicycle theft (avg/yr)",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 1.0,
- description: "Average yearly bicycle theft in the area",
- detail: "Average number of bicycle theft offences per year in the LSOA, from police.uk street-level crime data (2023-2025).",
- source: "crime",
- prefix: "",
- suffix: "/yr",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Drugs (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -869,8 +790,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Possession of weapons (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -886,8 +807,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Public order (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -903,8 +824,42 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
+ name: "Bicycle theft (avg/yr)",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 1.0,
+ description: "Average yearly bicycle theft in the area",
+ detail: "Average number of bicycle theft offences per year in the LSOA, from police.uk street-level crime data (2023-2025).",
+ source: "crime",
+ prefix: "",
+ suffix: "/yr",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Theft from the person (avg/yr)",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 1.0,
+ description: "Average yearly theft from the person in the area",
+ detail: "Average number of theft from the person offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes pickpocketing and bag snatching without force.",
+ source: "crime",
+ prefix: "",
+ suffix: "/yr",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
name: "Other crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
@@ -920,30 +875,13 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
+ },
],
},
FeatureGroup {
name: "Demographics",
features: &[
- Feature::Numeric(FeatureConfig {
- name: "Median age",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 0.5,
- description: "Median age of the local population",
- detail: "From the 2021 Census (TS007A). Median age of usual residents in the LSOA, computed by linear interpolation from five-year age band counts. Areas with younger populations tend to be urban, university towns, or have more families; older medians are typical in rural and coastal areas.",
- source: "census-2021",
- prefix: "",
- suffix: " years",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ FeatureConfig {
name: "% White",
bounds: Bounds::Fixed {
min: 0.0,
@@ -959,8 +897,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "% South Asian",
bounds: Bounds::Fixed {
min: 0.0,
@@ -976,25 +914,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "% Black",
- bounds: Bounds::Fixed {
- min: 0.0,
- max: 100.0,
- },
- step: 0.1,
- description: "Percentage of population identifying as Black",
- detail: "From the 2021 Census. Percentage of the local authority population identifying as Black, Black British, Caribbean, or African.",
- source: "ethnicity",
- prefix: "",
- suffix: "%",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "% East Asian",
bounds: Bounds::Fixed {
min: 0.0,
@@ -1010,8 +931,25 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
+ name: "% Black",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 100.0,
+ },
+ step: 0.1,
+ description: "Percentage of population identifying as Black",
+ detail: "From the 2021 Census. Percentage of the local authority population identifying as Black, Black British, Caribbean, or African.",
+ source: "ethnicity",
+ prefix: "",
+ suffix: "%",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
name: "% Mixed",
bounds: Bounds::Fixed {
min: 0.0,
@@ -1027,8 +965,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "% Other",
bounds: Bounds::Fixed {
min: 0.0,
@@ -1044,47 +982,30 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
+ },
+ FeatureConfig {
+ name: "Median age",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 0.5,
+ description: "Median age of the local population",
+ detail: "From the 2021 Census (TS007A). Median age of usual residents in the LSOA, computed by linear interpolation from five-year age band counts. Areas with younger populations tend to be urban, university towns, or have more families; older medians are typical in rural and coastal areas.",
+ source: "census-2021",
+ prefix: "",
+ suffix: " years",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
],
},
FeatureGroup {
name: "Amenities",
features: &[
- Feature::Numeric(FeatureConfig {
- name: "Distance to nearest park (km)",
- bounds: Bounds::Percentile {
- low: 2.0,
- high: 98.0,
- },
- step: 0.1,
- description: "Distance to the closest park or green space",
- detail: "Straight-line distance in kilometres from the postcode to the nearest park entrance. Covers public parks, gardens, playing fields, and play spaces. Uses access point locations from the OS Open Greenspace dataset, so properties bordering a large park correctly show a short distance.",
- source: "os-open-greenspace",
- prefix: "",
- suffix: " km",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
- name: "Number of parks within 2km",
- bounds: Bounds::Percentile {
- low: 5.0,
- high: 95.0,
- },
- step: 1.0,
- description: "Number of parks and green spaces within 2km",
- detail: "Count of public parks, gardens, playing fields, and play spaces with at least one entrance within a 2km radius of the property's postcode centroid. Derived from the OS Open Greenspace dataset (Ordnance Survey), using park entrance locations for accurate proximity matching.",
- source: "os-open-greenspace",
- prefix: "",
- suffix: "",
- raw: false,
- absolute: false,
- modes: &[],
- linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ FeatureConfig {
name: "Number of restaurants within 2km",
bounds: Bounds::Percentile {
low: 5.0,
@@ -1092,7 +1013,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
step: 1.0,
description: "Number of restaurants and cafes within 2km",
- detail: "Restaurants, cafes, and food establishments within 2km of the postcode. Sourced from OpenStreetMap.",
+ detail: "Count of restaurants, cafes, and food establishments within a 2km radius of the property's postcode centroid. Derived from OpenStreetMap POI data using haversine distance calculation with a 0.05° spatial grid for candidate reduction.",
source: "osm-pois",
prefix: "",
suffix: "",
@@ -1100,8 +1021,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
name: "Number of grocery shops and supermarkets within 2km",
bounds: Bounds::Percentile {
low: 5.0,
@@ -1117,8 +1038,47 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Numeric(FeatureConfig {
+ },
+ FeatureConfig {
+ name: "Number of parks within 2km",
+ bounds: Bounds::Percentile {
+ low: 5.0,
+ high: 95.0,
+ },
+ step: 1.0,
+ description: "Number of parks and green spaces within 2km",
+ detail: "Count of public parks, gardens, playing fields, and play spaces with at least one entrance within a 2km radius of the property's postcode centroid. Derived from the OS Open Greenspace dataset (Ordnance Survey), using park entrance locations for accurate proximity matching.",
+ source: "os-open-greenspace",
+ prefix: "",
+ suffix: "",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ FeatureConfig {
+ name: "Distance to nearest park (km)",
+ bounds: Bounds::Percentile {
+ low: 2.0,
+ high: 98.0,
+ },
+ step: 0.1,
+ description: "Distance to the closest park or green space",
+ detail: "Straight-line distance in kilometres from the property's postcode centroid to the nearest park entrance. Covers public parks, gardens, playing fields, and play spaces. Derived from the OS Open Greenspace dataset (Ordnance Survey), using access point locations rather than polygon centroids for accuracy — so properties bordering a large park correctly show a short distance.",
+ source: "os-open-greenspace",
+ prefix: "",
+ suffix: " km",
+ raw: false,
+ absolute: false,
+ modes: &[],
+ linked: "",
+ },
+ ],
+ },
+ FeatureGroup {
+ name: "Environment",
+ features: &[
+ FeatureConfig {
name: "Noise (dB)",
bounds: Bounds::Fixed {
min: 50.0,
@@ -1126,7 +1086,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
step: 1.0,
description: "Road noise level at the postcode in decibels (Lden)",
- detail: "Road noise level in decibels (Lden, a 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
+ detail: "Road noise level in decibels (Lden — day-evening-night 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid. Sampled at postcode centroids via WCS GeoTIFF tiles. Values above ~55 dB are generally considered noticeable; above ~70 dB can affect health.",
source: "noise",
prefix: "",
suffix: " dB",
@@ -1134,53 +1094,96 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
absolute: false,
modes: &[],
linked: "",
- }),
- Feature::Enum(EnumFeatureConfig {
- name: "Max available download speed (Mbps)",
- order: Some(&["10", "30", "100", "300", "1000"]),
- description: "Maximum broadband download speed available at the postcode",
- detail: "Maximum fixed broadband download speed available from any provider, from Ofcom Connected Nations 2025. Represents theoretical maximum, not achieved speeds. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit.",
- source: "broadband",
- }),
+ },
],
},
];
+pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
+ EnumFeatureGroup {
+ name: "Properties",
+ features: &[
+ EnumFeatureConfig {
+ name: "Listing status",
+ order: Some(&["Historical sale", "For sale", "For rent"]),
+ description: "Whether the property is from historical sales, currently for sale, or for rent",
+ detail: "Indicates the source of the property record: 'Historical sale' from HM Land Registry Price Paid data, 'For sale' from current online buy listings, or 'For rent' from current online rental listings.",
+ source: "online-listings",
+ },
+ EnumFeatureConfig {
+ name: "Leasehold/Freehold",
+ order: Some(&["Freehold", "Leasehold"]),
+ description: "Whether the property is leasehold or freehold",
+ detail: "From HM Land Registry Price Paid data. Freehold means you own the building and the land it stands on. Leasehold means you own the building but not the land — you have a lease from the freeholder for a set number of years.",
+ source: "price-paid",
+ },
+ EnumFeatureConfig {
+ name: "Property type",
+ order: Some(&["Detached", "Semi-Detached", "Terraced", "Flats/Maisonettes", "Other"]),
+ description: "Type of property: detached, semi-detached, terraced, flat/maisonette, or other",
+ detail: "From HM Land Registry Price Paid data and EPC certificates. Detached, Semi-Detached, Terraced (includes all terrace sub-types), Flats/Maisonettes, or Other (bungalows, park homes, etc.).",
+ source: "price-paid",
+ },
+ EnumFeatureConfig {
+ name: "Former council house",
+ order: Some(&["Yes", "No"]),
+ description: "Whether the property was ever recorded as social housing",
+ detail: "Derived from the TENURE field in Energy Performance Certificate data. If any EPC certificate for this property recorded the tenure as social rental, it indicates the property was council or housing-association stock at the time of that inspection. Properties that were later sold (e.g. via Right to Buy) retain this flag.",
+ source: "epc",
+ },
+ 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 {
+ name: "Environment",
+ features: &[
+ EnumFeatureConfig {
+ name: "Max available download speed (Mbps)",
+ order: Some(&["10", "30", "100", "300", "1000"]),
+ description: "Maximum broadband download speed available at the postcode",
+ detail: "Maximum available fixed broadband download speed in Megabits per second, from Ofcom's Connected Nations 2025 report. Measured at Output Area level and represents the maximum speed available from any provider, not actual achieved speeds. Tiers: 10 = basic, 30 = superfast (SFBB), 100 = ultrafast 100Mbit, 300 = ultrafast (UFBB), 1000 = gigabit.",
+ source: "broadband",
+ },
+ ],
+ },
+];
/// Flat ordered list of all numeric feature names (follows group order).
pub fn all_numeric_feature_names() -> Vec<&'static str> {
FEATURE_GROUPS
.iter()
- .flat_map(|group| group.features.iter())
- .filter_map(|feature| match feature {
- Feature::Numeric(c) => Some(c.name),
- Feature::Enum(_) => None,
- })
+ .flat_map(|group| group.features.iter().map(|feature| feature.name))
.collect()
}
/// Flat ordered list of all enum feature names (follows group order).
pub fn all_enum_feature_names() -> Vec<&'static str> {
- FEATURE_GROUPS
+ ENUM_FEATURE_GROUPS
.iter()
- .flat_map(|group| group.features.iter())
- .filter_map(|feature| match feature {
- Feature::Enum(c) => Some(c.name),
- Feature::Numeric(_) => None,
- })
+ .flat_map(|group| group.features.iter().map(|feature| feature.name))
.collect()
}
/// Look up the configured value order for an enum feature by name.
pub fn order_for(name: &str) -> Option<&'static [&'static str]> {
- FEATURE_GROUPS
+ ENUM_FEATURE_GROUPS
.iter()
.flat_map(|group| group.features.iter())
- .find_map(|feature| match feature {
- Feature::Enum(c) if c.name == name => Some(c.order),
- _ => None,
- })
- .flatten()
+ .find(|feature| feature.name == name)
+ .and_then(|feature| feature.order)
}
/// Whether this feature should use integer-width histogram bins.
@@ -1193,10 +1196,8 @@ pub fn bounds_for(name: &str) -> Option<&'static Bounds> {
FEATURE_GROUPS
.iter()
.flat_map(|group| group.features.iter())
- .find_map(|feature| match feature {
- Feature::Numeric(c) if c.name == name => Some(&c.bounds),
- _ => None,
- })
+ .find(|feature| feature.name == name)
+ .map(|feature| &feature.bounds)
}
/// Canonical display order for POI category groups.
diff --git a/server-rs/src/parsing/fields.rs b/server-rs/src/parsing/fields.rs
index 303b44c..772bed5 100644
--- a/server-rs/src/parsing/fields.rs
+++ b/server-rs/src/parsing/fields.rs
@@ -18,7 +18,7 @@ pub fn parse_field_indices(
return Ok(Some(vec![]));
}
let mut indices = Vec::new();
- for name in fields_str.split(";;") {
+ for name in fields_str.split(',') {
let name = name.trim();
if name.is_empty() {
continue;
@@ -38,7 +38,7 @@ pub fn parse_field_set(fields: Option<&str>) -> (bool, HashSet) {
let field_set: HashSet = fields
.map(|fields_str| {
fields_str
- .split(";;")
+ .split(',')
.map(|field| field.trim().to_string())
.filter(|field| !field.is_empty())
.collect()
diff --git a/server-rs/src/routes/ai_filters.rs b/server-rs/src/routes/ai_filters.rs
index cc5a0dc..0492e9c 100644
--- a/server-rs/src/routes/ai_filters.rs
+++ b/server-rs/src/routes/ai_filters.rs
@@ -1022,7 +1022,7 @@ pub async fn post_ai_filters(
"No properties match these filters. Try relaxing some constraints.".to_string()
} else {
format!(
- "{}. No properties match. Try relaxing some constraints.",
+ "{}. No properties match — try relaxing some constraints.",
notes
)
};
diff --git a/server-rs/src/routes/features.rs b/server-rs/src/routes/features.rs
index 21d8e82..20c082f 100644
--- a/server-rs/src/routes/features.rs
+++ b/server-rs/src/routes/features.rs
@@ -6,7 +6,7 @@ use serde::Serialize;
use tracing::info;
use crate::data::{Histogram, PropertyData};
-use crate::features::{Feature, FEATURE_GROUPS};
+use crate::features::{ENUM_FEATURE_GROUPS, FEATURE_GROUPS};
use crate::state::SharedState;
fn is_empty(val: &str) -> bool {
@@ -69,53 +69,74 @@ pub struct FeaturesResponse {
}
/// Build the features response at startup. Called once and cached in AppState.
-/// Feature order in each group follows the array order in FEATURE_GROUPS.
pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
+ // Collect all group names in order, merging numeric and enum groups with the same name
+ let mut group_names: Vec<&str> = Vec::new();
+ for feature_group in FEATURE_GROUPS {
+ if !group_names.contains(&feature_group.name) {
+ group_names.push(feature_group.name);
+ }
+ }
+ for enum_group in ENUM_FEATURE_GROUPS {
+ if !group_names.contains(&enum_group.name) {
+ group_names.push(enum_group.name);
+ }
+ }
+
let mut groups: Vec = Vec::new();
- for feature_group in FEATURE_GROUPS {
+ for &group_name in &group_names {
let mut features: Vec = Vec::new();
- for feature in feature_group.features {
- match feature {
- Feature::Numeric(config) => {
+ // Add numeric features for this group
+ for feature_group in FEATURE_GROUPS {
+ if feature_group.name == group_name {
+ for feature_config in feature_group.features {
if let Some(feat_idx) = data
.feature_names
.iter()
- .position(|name| name == config.name)
+ .position(|feat_name| feat_name == feature_config.name)
{
let stats = &data.feature_stats[feat_idx];
features.push(FeatureInfo::Numeric {
- name: config.name.to_string(),
+ name: feature_config.name.to_string(),
min: stats.slider_min,
max: stats.slider_max,
- step: config.step,
+ step: feature_config.step,
histogram: stats.histogram.clone(),
- description: config.description,
- detail: config.detail,
- source: config.source,
- prefix: config.prefix,
- suffix: config.suffix,
- raw: config.raw,
- absolute: config.absolute,
- modes: config.modes,
- linked: config.linked,
+ description: feature_config.description,
+ detail: feature_config.detail,
+ source: feature_config.source,
+ prefix: feature_config.prefix,
+ suffix: feature_config.suffix,
+ raw: feature_config.raw,
+ absolute: feature_config.absolute,
+ modes: feature_config.modes,
+ linked: feature_config.linked,
});
}
}
- Feature::Enum(config) => {
+ }
+ }
+
+ // Add enum features for this group
+ for enum_group in ENUM_FEATURE_GROUPS {
+ if enum_group.name == group_name {
+ for enum_config in enum_group.features {
+ // Find the feature index by name
if let Some(feat_idx) = data
.feature_names
.iter()
- .position(|name| name == config.name)
+ .position(|name| name == enum_config.name)
{
+ // Check if this feature has enum values
if let Some(values) = data.enum_values.get(&feat_idx) {
features.push(FeatureInfo::Enum {
- name: config.name.to_string(),
+ name: enum_config.name.to_string(),
values: values.clone(),
- description: config.description,
- detail: config.detail,
- source: config.source,
+ description: enum_config.description,
+ detail: enum_config.detail,
+ source: enum_config.source,
});
}
}
@@ -125,7 +146,7 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
if !features.is_empty() {
groups.push(FeatureGroupResponse {
- name: feature_group.name.to_string(),
+ name: group_name.to_string(),
features,
});
}
diff --git a/server-rs/src/routes/shorten.rs b/server-rs/src/routes/shorten.rs
index fa95bf7..f383840 100644
--- a/server-rs/src/routes/shorten.rs
+++ b/server-rs/src/routes/shorten.rs
@@ -142,7 +142,7 @@ pub async fn get_short_url(
let redirect_url = format!("/dashboard?{params}");
let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url);
let og_url = format!("{}/s/{code}", state.public_url);
- let og_title = "Perfect Postcode | Every neighbourhood in England";
+ let og_title = "Perfect Postcode \u{2014} Every neighbourhood in England";
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
let html = format!(