From 89a85e9a0c56dddaa8f02edbe092f66e387b5971 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 28 Mar 2026 12:00:15 +0000 Subject: [PATCH] Updates --- finder/constants.py | 1 + finder/scraper.py | 170 ++- frontend/src/components/invite/InvitePage.tsx | 2 +- frontend/src/components/learn/LearnPage.tsx | 60 +- frontend/src/components/map/AiFilterInput.tsx | 4 +- frontend/src/components/map/AreaPane.tsx | 3 +- .../src/components/map/FeatureBrowser.tsx | 6 +- frontend/src/components/map/Filters.tsx | 292 ++-- frontend/src/components/map/POIPane.tsx | 6 +- .../src/components/map/TravelTimeCard.tsx | 6 +- .../src/components/pricing/PricingPage.tsx | 4 +- .../src/components/ui/LicenseSuccessModal.tsx | 4 +- .../src/components/ui/TravelTimeInfoPopup.tsx | 2 +- frontend/src/components/ui/UpgradeModal.tsx | 2 +- frontend/src/hooks/useTravelTime.ts | 2 +- frontend/src/hooks/useTutorial.ts | 8 +- frontend/src/lib/consts.ts | 1 - server-rs/src/features.rs | 1253 ++++++++--------- server-rs/src/parsing/fields.rs | 4 +- server-rs/src/routes/ai_filters.rs | 2 +- server-rs/src/routes/features.rs | 71 +- server-rs/src/routes/shorten.rs | 2 +- 22 files changed, 1006 insertions(+), 899 deletions(-) diff --git a/finder/constants.py b/finder/constants.py index 821aae3..9aee415 100644 --- a/finder/constants.py +++ b/finder/constants.py @@ -55,6 +55,7 @@ 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" diff --git a/finder/scraper.py b/finder/scraper.py index 4f81aee..78db671 100644 --- a/finder/scraper.py +++ b/finder/scraper.py @@ -3,6 +3,7 @@ import logging import random import threading import time +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field import polars as pl @@ -15,6 +16,7 @@ from constants import ( CHECKPOINT_INTERVAL, DATA_DIR, DELAY_BETWEEN_OUTCODES, + HOMECOUK_CONCURRENCY, RELOAD_URL, SCRAPE_HOMECOUK, SCRAPE_OPENRENT, @@ -503,59 +505,133 @@ 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)) - client = make_homecouk_client(*hk_result) - log.info("home.co.uk scraping ENABLED") + log.info( + "home.co.uk scraping ENABLED (concurrency=%d)", HOMECOUK_CONCURRENCY + ) homecouk_enabled.set(1) - try: - 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) + # 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)) 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}") - finally: - try: - client.close() - except Exception: - pass + + if disabled[0]: + progress.update("hk", len(shuffled)) def or_worker(): or_result = load_openrent_cookies() diff --git a/frontend/src/components/invite/InvitePage.tsx b/frontend/src/components/invite/InvitePage.tsx index 8fb68e1..0758d1c 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 & more + Property prices, energy ratings, crime stats, school ratings and more

diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx index 8adea94..5defdd9 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. Used for the last known sale price of each property.', + use: 'Complete historical property sale prices for England.', 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. Fuzzy-joined with Price Paid records by address within postcode buckets. 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. Matched with Price Paid records by address within each postcode. 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 latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.', + use: 'Maps postcodes to coordinates and statistical area codes, used to link all area-level datasets to individual properties.', 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 for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.', + use: 'Relative deprivation scores across income, employment, education, health, crime, and living environment for every neighbourhood in England.', 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. Joined via Local Authority District code.', + use: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per local authority.', 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 extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.', + use: 'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.', 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: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.', + use: 'Station and stop locations for rail, bus, metro/tram, ferry, and airports across England.', 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: '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.', + use: 'Road noise levels (24-hour weighted average) from the 2022 strategic noise mapping, modelled at high resolution and sampled at each postcode.', 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 speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.', + use: 'Fixed broadband coverage and maximum download speeds by area from Ofcom Connected Nations 2025.', 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, 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.', + '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.', }, { 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. It's like having a local's knowledge of every neighbourhood in England.", + "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.", }, { 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 affordable sweet spots. The map updates live as you drag sliders, so you can watch neighbourhoods light up or drop off in real time.', + '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.', }, ], }, { - title: 'Commute & Travel', + title: 'Commute and 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 & Value', + title: 'Budget and 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 square footage per pound. Pair it with the energy rating filter to avoid cheap-but-freezing money pits.", + "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.", }, { 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 the stuff that matters, that's your hidden gem, not just a cheap postcode with a catch.", + "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.", }, ], }, { - title: 'Safety & Neighbourhood', + title: 'Safety and 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 grim.', + 'I keep finding flats that look great online, then the area turns out to be rough.', answer: - "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.", + "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.", }, ], }, { - title: 'Families & Schools', + title: 'Families and Schools', items: [ { question: 'Can I find areas with good schools AND low crime in one search?', answer: - '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.', + '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.', }, { question: 'How do I know if a neighbourhood has parks and playgrounds nearby?', @@ -220,7 +220,7 @@ const FAQ_SECTIONS: FAQSection[] = [ ], }, { - title: 'Environment & Quality of Life', + title: 'Environment and 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 you fall in love with a property. Filter it out early and save yourself the surveyor's surprise.", + "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.", }, { 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 spot the sweet spots instantly.', + '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.', }, ], }, @@ -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. 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.", + "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.", }, { question: "Can't I just research all this myself for free?", answer: - '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.', + '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.', }, { question: 'Where does the data actually come from?', @@ -260,15 +260,15 @@ const FAQ_SECTIONS: FAQSection[] = [ ], }, { - title: 'Pricing & Access', + title: 'Pricing and Access', items: [ { question: 'Is it really worth paying for a property search tool?', answer: - "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.", + "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.", }, { - question: "Is this another subscription that'll drain my account?", + question: "Is this a subscription?", 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 & Tricks', + title: 'Tips and 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 0850e26..b4d315c 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 500k', + '30 min commute to Kings Cross, under \u00A3500k', '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 e69f239..771a3e6 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -109,8 +109,7 @@ export default function AreaPane({

)}

- Stats for {isPostcode ? 'current and historical' : 'all'} properties in this{' '} - {isPostcode ? 'postcode' : 'area'} + Stats for all properties in this {isPostcode ? 'postcode' : 'area'} {Object.keys(filters).length > 0 ? ' matching all active filters' : ''}

{stats && stats.count > 0 && ( diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index f508eaf..2b94721 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -200,15 +200,15 @@ export default function FeatureBrowser({ /> ) : isLicensed ? (

- Everyone cares about different things. Pick the filters that matter most to you. + Choose the filters that matter to you. The map updates as you go.

) : (

- The biggest financial decision of your life deserves proper tools behind it. + See crime, schools, noise, broadband, and 50+ more filters across all of England.

- Don't leave it to chance. + One-time payment, lifetime access.

{enabledFeatureList.length === 0 && activeEntryCount === 0 && (

- Add filters below to narrow the map to areas that match + Add filters below to narrow the map to areas that match your criteria

)}
- {travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)} - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onDragStart={() => onDragStart(travelFieldKey(entry))} - onDragChange={onDragChange} - onDragEnd={() => onTravelTimeDragEnd(index)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - /> -
- ))} - {enabledFeatureList.map((feature) => { + {enabledFeatureList.map((feature, featureIdx) => { if (feature.type === 'enum') { const selectedValues = (filters[feature.name] as string[]) || []; const allValues = feature.values || []; return ( -
-
- - -
- - {allValues.map((val) => ( - { - const next = selectedValues.includes(val) - ? selectedValues.filter((v) => v !== val) - : [...selectedValues, val]; - onFilterChange(feature.name, next); - }} - size="xs" + + {featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => ( +
+ onTogglePin(travelFieldKey(entry))} + onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)} + onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} + onDragStart={() => onDragStart(travelFieldKey(entry))} + onDragChange={onDragChange} + onDragEnd={() => onTravelTimeDragEnd(index)} + onToggleBest={() => onTravelTimeToggleBest(index)} + onRemove={() => onTravelTimeRemoveEntry(index)} /> - ))} - -
+
+ ))} +
+
+ + +
+ + {allValues.map((val) => ( + { + const next = selectedValues.includes(val) + ? selectedValues.filter((v) => v !== val) + : [...selectedValues, val]; + onFilterChange(feature.name, next); + }} + size="xs" + /> + ))} + +
+ ); } @@ -561,66 +572,111 @@ export default memo(function Filters({ })(); return ( -
-
- - -
-
- {mobileIcon &&
{mobileIcon}
} -
- { - const step = feature.step ?? 1; - const snap = (v: number) => Math.round(v / step) * step; - onDragChange([ - pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)), - pMax >= 100 - ? (hist?.max ?? feature.max!) - : snap(scale.toValue(pMax)), - ]); - } - : ([min, max]) => - onDragChange([ - min <= feature.min! ? (hist?.min ?? feature.min!) : min, - max >= feature.max! ? (hist?.max ?? feature.max!) : max, - ]) - } - onPointerDown={() => onDragStart(feature.name)} - onPointerUp={() => onDragEnd()} - /> - onFilterChange(feature.name, v)} - /> + + {featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => ( +
+ onTogglePin(travelFieldKey(entry))} + onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)} + onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} + onDragStart={() => onDragStart(travelFieldKey(entry))} + onDragChange={onDragChange} + onDragEnd={() => onTravelTimeDragEnd(index)} + onToggleBest={() => onTravelTimeToggleBest(index)} + onRemove={() => onTravelTimeRemoveEntry(index)} + /> +
+ ))} +
+
+ + +
+
+ {mobileIcon &&
{mobileIcon}
} +
+ { + const step = feature.step ?? 1; + const snap = (v: number) => Math.round(v / step) * step; + onDragChange([ + pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)), + pMax >= 100 + ? (hist?.max ?? feature.max!) + : snap(scale.toValue(pMax)), + ]); + } + : ([min, max]) => + onDragChange([ + min <= feature.min! ? (hist?.min ?? feature.min!) : min, + max >= feature.max! ? (hist?.max ?? feature.max!) : max, + ]) + } + onPointerDown={() => onDragStart(feature.name)} + onPointerUp={() => onDragEnd()} + /> + onFilterChange(feature.name, v)} + /> +
-
+ ); })} + {travelInsertIdx >= enabledFeatureList.length && travelTimeEntries.map((entry, index) => ( +
+ onTogglePin(travelFieldKey(entry))} + onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)} + onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} + onDragStart={() => onDragStart(travelFieldKey(entry))} + onDragChange={onDragChange} + onDragEnd={() => onTravelTimeDragEnd(index)} + onToggleBest={() => onTravelTimeToggleBest(index)} + onRemove={() => onTravelTimeRemoveEntry(index)} + /> +
+ ))}
@@ -662,8 +718,8 @@ export default memo(function Filters({ setShowPhilosophy(false)}>

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

@@ -673,7 +729,7 @@ export default memo(function Filters({

- Budget & basics + Budget and basics {' '} (price range, floor area, property type)

@@ -720,14 +776,14 @@ export default memo(function Filters({

Energy{' '} - (EPC ratings for lower bills and fewer surprises) + (EPC ratings, insulation, heating costs)

- Tip: if nothing survives, relax one constraint at a time to see which compromise - unlocks the most options. + Tip: if nothing matches, relax one constraint at a time to see which trade-off opens + up the most options.

{onResetTutorial && ( diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx index a5f49bc..67916de 100644 --- a/frontend/src/components/map/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -140,10 +140,8 @@ export default function POIPane({ } >

- 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. + Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants, + healthcare, leisure, and more. Updated regularly with complete category coverage.

)} diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index ef6a66b..4d0b4cf 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -122,9 +122,9 @@ export function TravelTimeCard({ {showBestInfo && ( setShowBestInfo(false)}>

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

)} diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index 41b3732..11c7a37 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 & more', + 'Crime, schools, transport, broadband and more', 'All future data updates included', ]; @@ -207,7 +207,7 @@ export default function PricingPage({ or a road you didn't know about.

- Less than your survey costs. Vastly more useful. + Less than a home survey. Far more useful.

diff --git a/frontend/src/components/ui/LicenseSuccessModal.tsx b/frontend/src/components/ui/LicenseSuccessModal.tsx index d17adbc..6af356a 100644 --- a/frontend/src/components/ui/LicenseSuccessModal.tsx +++ b/frontend/src/components/ui/LicenseSuccessModal.tsx @@ -50,12 +50,12 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
🎉
-

Welcome aboard!

+

You're in.

Your lifetime access is now active.

- You now have full access to every feature across all of England. Happy exploring! + Full access to every feature, every postcode, across all of England.

diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts index 704caa6..4eeb277 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[] = ['car', 'bicycle', 'walking', 'transit']; +export const TRANSPORT_MODES: TransportMode[] = ['transit', 'car', 'bicycle', 'walking']; export const MODE_LABELS: Record = { car: 'Car', diff --git a/frontend/src/hooks/useTutorial.ts b/frontend/src/hooks/useTutorial.ts index 5f13d37..e9a8fd2 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 \u2014 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. 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 \u2014 like "quiet area near good schools under \u00A3400k" \u2014 and we\u2019ll set up the filters for you.', + 'Type what you want in plain English, like "quiet area near good schools under \u00A3400k", 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 \u2014 everything about that neighbourhood.', + 'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more 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 \u2014 prices, floor area, energy ratings, and more.', + 'See area statistics, histograms, and individual property records: 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 404177d..e7e1195 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -189,7 +189,6 @@ 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 7868f56..7fb4b0d 100644 --- a/server-rs/src/features.rs +++ b/server-rs/src/features.rs @@ -39,11 +39,6 @@ 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. @@ -57,16 +52,45 @@ pub struct EnumFeatureConfig { pub source: &'static str, } -pub struct EnumFeatureGroup { +/// 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 name: &'static str, - pub features: &'static [EnumFeatureConfig], + pub features: &'static [Feature], } pub static FEATURE_GROUPS: &[FeatureGroup] = &[ FeatureGroup { name: "Properties", features: &[ - FeatureConfig { + 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 { name: "Last known price", bounds: Bounds::Fixed { min: 0.0, @@ -82,8 +106,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: true, modes: &["historical"], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Estimated current price", bounds: Bounds::Fixed { min: 0.0, @@ -91,7 +115,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 10000.0, description: "Inflation-adjusted estimate of the current property value", - detail: "Estimated by applying a repeat-sales price index to the last known sale price, 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.", + 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.", source: "price-paid", prefix: "£", suffix: "", @@ -99,8 +123,25 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: true, modes: &["historical"], linked: "Asking price", - }, - FeatureConfig { + }), + 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 { name: "Price per sqm", bounds: Bounds::Percentile { low: 0.0, @@ -116,8 +157,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &["historical"], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Est. price per sqm", bounds: Bounds::Percentile { low: 0.0, @@ -133,8 +174,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &["historical"], linked: "Asking price per sqm", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Asking price per sqm", bounds: Bounds::Percentile { low: 0.0, @@ -150,8 +191,39 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &["buy"], linked: "Est. price per sqm", - }, - FeatureConfig { + }), + 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 { name: "Total floor area (sqm)", bounds: Bounds::Percentile { low: 0.0, @@ -167,25 +239,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { - name: "Interior height (m)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 0.1, - description: "Average storey height from the EPC survey", - detail: "Average internal floor-to-ceiling height in metres as recorded during the Energy Performance Certificate assessment. Calculated by dividing the total internal volume by the total floor area.", - source: "epc", - prefix: "", - suffix: " m", - raw: false, - absolute: false, - modes: &["historical"], - linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Number of bedrooms & living rooms", bounds: Bounds::Fixed { min: 1.0, @@ -201,90 +256,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ 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 { + }), + Feature::Numeric(FeatureConfig { name: "Bedrooms", bounds: Bounds::Fixed { min: 0.0, @@ -300,8 +273,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: true, modes: &["buy", "rent"], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Bathrooms", bounds: Bounds::Fixed { min: 0.0, @@ -317,8 +290,42 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: true, modes: &["buy", "rent"], linked: "", - }, - FeatureConfig { + }), + 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, @@ -334,30 +341,51 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ 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 { + name: "Interior height (m)", + bounds: Bounds::Percentile { + low: 2.0, + high: 98.0, + }, + step: 0.1, + description: "Average storey height from the EPC survey", + detail: "Average internal floor-to-ceiling height in metres as recorded during the Energy Performance Certificate assessment. Calculated by dividing the total internal volume by the total floor area.", + source: "epc", + prefix: "", + suffix: " m", + raw: false, + absolute: false, + modes: &["historical"], + linked: "", + }), ], }, FeatureGroup { name: "Transport", features: &[ - FeatureConfig { - name: "Train or tube stations within 1km", - bounds: Bounds::Percentile { - low: 5.0, - high: 95.0, - }, - step: 1.0, - description: "Number of train or tube stations within 1km", - 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: "", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { + Feature::Numeric(FeatureConfig { name: "Distance to nearest train or tube station (km)", bounds: Bounds::Percentile { low: 2.0, @@ -365,7 +393,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, 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.", + detail: "Straight-line distance in kilometres from the postcode to the nearest rail station or Tube/metro/tram stop.", source: "naptan", prefix: "", suffix: " km", @@ -373,21 +401,106 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, + }), + Feature::Numeric(FeatureConfig { + name: "Train or tube stations within 1km", + bounds: Bounds::Percentile { + low: 5.0, + high: 95.0, + }, + 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.", + source: "naptan", + prefix: "", + suffix: "", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), ], }, FeatureGroup { name: "Education", features: &[ - FeatureConfig { + 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 { name: "Education, Skills and Training Score", bounds: Bounds::Percentile { low: 2.0, high: 98.0, }, step: 0.1, - 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).", + 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.", source: "iod", prefix: "", suffix: "", @@ -395,81 +508,13 @@ 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: &[ - FeatureConfig { + Feature::Numeric(FeatureConfig { name: "Income Score (rate)", bounds: Bounds::Fixed { min: 0.0, max: 0.6 }, step: 0.01, @@ -482,8 +527,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Employment Score (rate)", bounds: Bounds::Fixed { min: 0.0, max: 0.4 }, step: 0.01, @@ -496,8 +541,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Health Deprivation and Disability Score", bounds: Bounds::Percentile { low: 2.0, @@ -513,8 +558,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Living Environment Score", bounds: Bounds::Percentile { low: 2.0, @@ -522,7 +567,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). 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.", + 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.", source: "iod", prefix: "", suffix: "", @@ -530,8 +575,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Indoors Sub-domain Score", bounds: Bounds::Percentile { low: 2.0, @@ -547,8 +592,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Outdoors Sub-domain Score", bounds: Bounds::Percentile { low: 2.0, @@ -564,47 +609,13 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, + }), ], }, - FeatureGroup { + FeatureGroup { name: "Crime", features: &[ - FeatureConfig { - name: "Serious crime (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 1.0, - description: "Aggregate of serious crime categories per year", - detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.", - source: "crime", - prefix: "", - suffix: "/yr", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { - name: "Minor crime (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 1.0, - description: "Aggregate of minor crime categories per year", - detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.", - source: "crime", - prefix: "", - suffix: "/yr", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { + Feature::Numeric(FeatureConfig { name: "Serious crime per 1k residents (avg/yr)", bounds: Bounds::Percentile { low: 2.0, @@ -620,8 +631,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Minor crime per 1k residents (avg/yr)", bounds: Bounds::Percentile { low: 2.0, @@ -637,16 +648,16 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { - name: "Anti-social behaviour (avg/yr)", + }), + Feature::Numeric(FeatureConfig { + name: "Serious crime (avg/yr)", bounds: Bounds::Percentile { low: 2.0, high: 98.0, }, step: 1.0, - description: "Average yearly anti-social behaviour incidents in the area", - detail: "Average number of anti-social behaviour incidents per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes nuisance, environmental, and personal anti-social behaviour.", + description: "Aggregate of serious crime categories per year", + detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.", source: "crime", prefix: "", suffix: "/yr", @@ -654,8 +665,25 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { + name: "Minor crime (avg/yr)", + bounds: Bounds::Percentile { + low: 2.0, + high: 98.0, + }, + step: 1.0, + description: "Aggregate of minor crime categories per year", + detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.", + source: "crime", + prefix: "", + suffix: "/yr", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), + Feature::Numeric(FeatureConfig { name: "Violence and sexual offences (avg/yr)", bounds: Bounds::Percentile { low: 2.0, @@ -671,25 +699,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { - name: "Criminal damage and arson (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 1.0, - description: "Average yearly criminal damage and arson in the area", - detail: "Average number of criminal damage and arson incidents 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 { + }), + Feature::Numeric(FeatureConfig { name: "Burglary (avg/yr)", bounds: Bounds::Percentile { low: 2.0, @@ -705,25 +716,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ 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 { + }), + Feature::Numeric(FeatureConfig { name: "Robbery (avg/yr)", bounds: Bounds::Percentile { low: 2.0, @@ -739,8 +733,59 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + 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 { + name: "Anti-social behaviour (avg/yr)", + bounds: Bounds::Percentile { + low: 2.0, + high: 98.0, + }, + step: 1.0, + description: "Average yearly anti-social behaviour incidents in the area", + detail: "Average number of anti-social behaviour incidents per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes nuisance, environmental, and personal anti-social behaviour.", + source: "crime", + prefix: "", + suffix: "/yr", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), + Feature::Numeric(FeatureConfig { + name: "Criminal damage and arson (avg/yr)", + bounds: Bounds::Percentile { + low: 2.0, + high: 98.0, + }, + step: 1.0, + description: "Average yearly criminal damage and arson in the area", + detail: "Average number of criminal damage and arson incidents 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 { name: "Other theft (avg/yr)", bounds: Bounds::Percentile { low: 2.0, @@ -756,93 +801,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { - name: "Shoplifting (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 1.0, - description: "Average yearly shoplifting offences in the area", - detail: "Average number of shoplifting 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: "Drugs (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 1.0, - description: "Average yearly drug offences in the area", - detail: "Average number of drug offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes possession and trafficking offences.", - source: "crime", - prefix: "", - suffix: "/yr", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { - name: "Possession of weapons (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 1.0, - description: "Average yearly weapons possession offences in the area", - detail: "Average number of possession of weapons 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: "Public order (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 1.0, - description: "Average yearly public order offences in the area", - detail: "Average number of public order offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes causing fear, alarm, or distress.", - source: "crime", - prefix: "", - suffix: "/yr", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - 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 { + }), + Feature::Numeric(FeatureConfig { name: "Theft from the person (avg/yr)", bounds: Bounds::Percentile { low: 2.0, @@ -858,8 +818,93 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { + name: "Shoplifting (avg/yr)", + bounds: Bounds::Percentile { + low: 2.0, + high: 98.0, + }, + step: 1.0, + description: "Average yearly shoplifting offences in the area", + detail: "Average number of shoplifting 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 { + 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 { + name: "Drugs (avg/yr)", + bounds: Bounds::Percentile { + low: 2.0, + high: 98.0, + }, + step: 1.0, + description: "Average yearly drug offences in the area", + detail: "Average number of drug offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes possession and trafficking offences.", + source: "crime", + prefix: "", + suffix: "/yr", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), + Feature::Numeric(FeatureConfig { + name: "Possession of weapons (avg/yr)", + bounds: Bounds::Percentile { + low: 2.0, + high: 98.0, + }, + step: 1.0, + description: "Average yearly weapons possession offences in the area", + detail: "Average number of possession of weapons 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 { + name: "Public order (avg/yr)", + bounds: Bounds::Percentile { + low: 2.0, + high: 98.0, + }, + step: 1.0, + description: "Average yearly public order offences in the area", + detail: "Average number of public order offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes causing fear, alarm, or distress.", + source: "crime", + prefix: "", + suffix: "/yr", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), + Feature::Numeric(FeatureConfig { name: "Other crime (avg/yr)", bounds: Bounds::Percentile { low: 2.0, @@ -875,115 +920,13 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, + }), ], }, FeatureGroup { name: "Demographics", features: &[ - FeatureConfig { - name: "% White", - bounds: Bounds::Fixed { - min: 0.0, - max: 100.0, - }, - step: 1.0, - description: "Percentage of population identifying as White", - detail: "From the 2021 Census. Percentage of the local authority population identifying as White (English, Welsh, Scottish, Northern Irish, British, Irish, Gypsy or Irish Traveller, Roma, or any other White background).", - source: "ethnicity", - prefix: "", - suffix: "%", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { - name: "% South Asian", - bounds: Bounds::Fixed { - min: 0.0, - max: 100.0, - }, - step: 0.1, - description: "Percentage of population identifying as South Asian", - detail: "From the 2021 Census. Percentage of the local authority population identifying as Indian, Pakistani, Bangladeshi, or any other Asian background.", - source: "ethnicity", - prefix: "", - suffix: "%", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { - name: "% East Asian", - bounds: Bounds::Fixed { - min: 0.0, - max: 100.0, - }, - step: 0.1, - description: "Percentage of population identifying as East Asian", - detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese.", - source: "ethnicity", - prefix: "", - suffix: "%", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - 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, - max: 100.0, - }, - step: 0.1, - description: "Percentage of population identifying as Mixed or Multiple ethnic groups", - detail: "From the 2021 Census. Percentage of the local authority population identifying as Mixed or Multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian, or any other Mixed or Multiple background).", - source: "ethnicity", - prefix: "", - suffix: "%", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { - name: "% Other", - bounds: Bounds::Fixed { - min: 0.0, - max: 100.0, - }, - step: 0.1, - description: "Percentage of population identifying as Other ethnic group", - detail: "From the 2021 Census. Percentage of the local authority population identifying as Other ethnic group (Arab or any other ethnic group not covered by the main categories).", - source: "ethnicity", - prefix: "", - suffix: "%", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { + Feature::Numeric(FeatureConfig { name: "Median age", bounds: Bounds::Percentile { low: 2.0, @@ -999,47 +942,132 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, + }), + Feature::Numeric(FeatureConfig { + name: "% White", + bounds: Bounds::Fixed { + min: 0.0, + max: 100.0, + }, + step: 1.0, + description: "Percentage of population identifying as White", + detail: "From the 2021 Census. Percentage of the local authority population identifying as White (English, Welsh, Scottish, Northern Irish, British, Irish, Gypsy or Irish Traveller, Roma, or any other White background).", + source: "ethnicity", + prefix: "", + suffix: "%", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), + Feature::Numeric(FeatureConfig { + name: "% South Asian", + bounds: Bounds::Fixed { + min: 0.0, + max: 100.0, + }, + step: 0.1, + description: "Percentage of population identifying as South Asian", + detail: "From the 2021 Census. Percentage of the local authority population identifying as Indian, Pakistani, Bangladeshi, or any other Asian background.", + source: "ethnicity", + prefix: "", + suffix: "%", + raw: false, + 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 { + name: "% East Asian", + bounds: Bounds::Fixed { + min: 0.0, + max: 100.0, + }, + step: 0.1, + description: "Percentage of population identifying as East Asian", + detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese.", + source: "ethnicity", + prefix: "", + suffix: "%", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), + Feature::Numeric(FeatureConfig { + name: "% Mixed", + bounds: Bounds::Fixed { + min: 0.0, + max: 100.0, + }, + step: 0.1, + description: "Percentage of population identifying as Mixed or Multiple ethnic groups", + detail: "From the 2021 Census. Percentage of the local authority population identifying as Mixed or Multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian, or any other Mixed or Multiple background).", + source: "ethnicity", + prefix: "", + suffix: "%", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), + Feature::Numeric(FeatureConfig { + name: "% Other", + bounds: Bounds::Fixed { + min: 0.0, + max: 100.0, + }, + step: 0.1, + description: "Percentage of population identifying as Other ethnic group", + detail: "From the 2021 Census. Percentage of the local authority population identifying as Other ethnic group (Arab or any other ethnic group not covered by the main categories).", + source: "ethnicity", + prefix: "", + suffix: "%", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), ], }, FeatureGroup { name: "Amenities", features: &[ - FeatureConfig { - name: "Number of restaurants within 2km", + Feature::Numeric(FeatureConfig { + name: "Distance to nearest park (km)", bounds: Bounds::Percentile { - low: 5.0, - high: 95.0, + low: 2.0, + high: 98.0, }, - step: 1.0, - description: "Number of restaurants and cafes within 2km", - 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", + 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: "", + suffix: " km", raw: false, absolute: false, modes: &[], linked: "", - }, - FeatureConfig { - name: "Number of grocery shops and supermarkets within 2km", - bounds: Bounds::Percentile { - low: 5.0, - high: 95.0, - }, - step: 1.0, - description: "Number of grocery shops and supermarkets within 2km", - detail: "Count of supermarkets, convenience stores, and other grocery shops within a 2km radius of the property's postcode centroid. Derived from OpenStreetMap POI data.", - source: "osm-pois", - prefix: "", - suffix: "", - raw: false, - absolute: false, - modes: &[], - linked: "", - }, - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { name: "Number of parks within 2km", bounds: Bounds::Percentile { low: 5.0, @@ -1055,30 +1083,42 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, modes: &[], linked: "", - }, - FeatureConfig { - name: "Distance to nearest park (km)", + }), + Feature::Numeric(FeatureConfig { + name: "Number of restaurants within 2km", bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, + low: 5.0, + high: 95.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", + 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.", + source: "osm-pois", prefix: "", - suffix: " km", + suffix: "", raw: false, absolute: false, modes: &[], linked: "", - }, - ], - }, - FeatureGroup { - name: "Environment", - features: &[ - FeatureConfig { + }), + Feature::Numeric(FeatureConfig { + name: "Number of grocery shops and supermarkets within 2km", + bounds: Bounds::Percentile { + low: 5.0, + high: 95.0, + }, + step: 1.0, + description: "Number of grocery shops and supermarkets within 2km", + detail: "Count of supermarkets, convenience stores, and other grocery shops within a 2km radius of the property's postcode centroid. Derived from OpenStreetMap POI data.", + source: "osm-pois", + prefix: "", + suffix: "", + raw: false, + absolute: false, + modes: &[], + linked: "", + }), + Feature::Numeric(FeatureConfig { name: "Noise (dB)", bounds: Bounds::Fixed { min: 50.0, @@ -1086,7 +1126,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 — 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.", + 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.", source: "noise", prefix: "", suffix: " dB", @@ -1094,96 +1134,53 @@ 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().map(|feature| feature.name)) + .flat_map(|group| group.features.iter()) + .filter_map(|feature| match feature { + Feature::Numeric(c) => Some(c.name), + Feature::Enum(_) => None, + }) .collect() } /// Flat ordered list of all enum feature names (follows group order). pub fn all_enum_feature_names() -> Vec<&'static str> { - ENUM_FEATURE_GROUPS + FEATURE_GROUPS .iter() - .flat_map(|group| group.features.iter().map(|feature| feature.name)) + .flat_map(|group| group.features.iter()) + .filter_map(|feature| match feature { + Feature::Enum(c) => Some(c.name), + Feature::Numeric(_) => None, + }) .collect() } /// Look up the configured value order for an enum feature by name. pub fn order_for(name: &str) -> Option<&'static [&'static str]> { - ENUM_FEATURE_GROUPS + FEATURE_GROUPS .iter() .flat_map(|group| group.features.iter()) - .find(|feature| feature.name == name) - .and_then(|feature| feature.order) + .find_map(|feature| match feature { + Feature::Enum(c) if c.name == name => Some(c.order), + _ => None, + }) + .flatten() } /// Whether this feature should use integer-width histogram bins. @@ -1196,8 +1193,10 @@ pub fn bounds_for(name: &str) -> Option<&'static Bounds> { FEATURE_GROUPS .iter() .flat_map(|group| group.features.iter()) - .find(|feature| feature.name == name) - .map(|feature| &feature.bounds) + .find_map(|feature| match feature { + Feature::Numeric(c) if c.name == name => Some(&c.bounds), + _ => None, + }) } /// Canonical display order for POI category groups. diff --git a/server-rs/src/parsing/fields.rs b/server-rs/src/parsing/fields.rs index 772bed5..303b44c 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 0492e9c..cc5a0dc 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 20c082f..21d8e82 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::{ENUM_FEATURE_GROUPS, FEATURE_GROUPS}; +use crate::features::{Feature, FEATURE_GROUPS}; use crate::state::SharedState; fn is_empty(val: &str) -> bool { @@ -69,74 +69,53 @@ 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 &group_name in &group_names { + for feature_group in FEATURE_GROUPS { let mut features: Vec = Vec::new(); - // 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 { + for feature in feature_group.features { + match feature { + Feature::Numeric(config) => { if let Some(feat_idx) = data .feature_names .iter() - .position(|feat_name| feat_name == feature_config.name) + .position(|name| name == config.name) { let stats = &data.feature_stats[feat_idx]; features.push(FeatureInfo::Numeric { - name: feature_config.name.to_string(), + name: config.name.to_string(), min: stats.slider_min, max: stats.slider_max, - step: feature_config.step, + step: config.step, histogram: stats.histogram.clone(), - 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, + 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, }); } } - } - } - - // 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 + Feature::Enum(config) => { if let Some(feat_idx) = data .feature_names .iter() - .position(|name| name == enum_config.name) + .position(|name| name == config.name) { - // Check if this feature has enum values if let Some(values) = data.enum_values.get(&feat_idx) { features.push(FeatureInfo::Enum { - name: enum_config.name.to_string(), + name: config.name.to_string(), values: values.clone(), - description: enum_config.description, - detail: enum_config.detail, - source: enum_config.source, + description: config.description, + detail: config.detail, + source: config.source, }); } } @@ -146,7 +125,7 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse { if !features.is_empty() { groups.push(FeatureGroupResponse { - name: group_name.to_string(), + name: feature_group.name.to_string(), features, }); } diff --git a/server-rs/src/routes/shorten.rs b/server-rs/src/routes/shorten.rs index f383840..fa95bf7 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 \u{2014} Every neighbourhood in England"; + let og_title = "Perfect Postcode | 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!(