Compare commits
2 commits
d93beb9201
...
89a85e9a0c
| Author | SHA1 | Date | |
|---|---|---|---|
| 89a85e9a0c | |||
| 7591e5fc05 |
32 changed files with 1200 additions and 909 deletions
|
|
@ -163,6 +163,7 @@ services:
|
|||
# - ./finder:/app
|
||||
# environment:
|
||||
# FLARESOLVERR_URL: http://flaresolverr:8191
|
||||
# RELOAD_URL: http://server:8001/api/reload
|
||||
# depends_on:
|
||||
# gluetun:
|
||||
# condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -104,6 +105,18 @@ 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",
|
||||
|
|
@ -113,6 +126,19 @@ 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 = [
|
||||
|
|
|
|||
|
|
@ -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 or "Unknown",
|
||||
"Property sub-type": listing_type.title() if listing_type else "Unknown",
|
||||
"price": int(price),
|
||||
"price_frequency": "" if channel == "BUY" else "monthly",
|
||||
"Price qualifier": price_qualifier,
|
||||
|
|
|
|||
|
|
@ -289,10 +289,15 @@ 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'."""
|
||||
"""Extract full UK postcode from text like '2 Bed Flat, Pimlico, SW1V 2AA'.
|
||||
Normalizes to include a space before the 3-char incode."""
|
||||
match = re.search(r"([A-Z]{1,2}\d[A-Z0-9]?\s*\d[A-Z]{2})", text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).upper().strip()
|
||||
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 None
|
||||
|
||||
|
||||
|
|
@ -635,6 +640,29 @@ 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,
|
||||
|
|
@ -767,7 +795,7 @@ def transform_property(
|
|||
"Total floor area (sqm)": parse_floor_area(description),
|
||||
"Listing URL": listing_url,
|
||||
"Listing features": [],
|
||||
"first_visible_date": detail.get("available_date", ""),
|
||||
"first_visible_date": _parse_or_date(detail.get("available_date", "")),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 normalize_price
|
||||
from transform import map_property_type, normalize_price
|
||||
|
||||
log = logging.getLogger("rightmove")
|
||||
|
||||
|
|
@ -43,6 +43,19 @@ 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:
|
||||
|
|
@ -56,7 +69,27 @@ 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):
|
||||
listing_dates.append(None)
|
||||
# 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)
|
||||
else:
|
||||
listing_dates.append(None)
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,22 @@ 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"
|
||||
|
||||
|
|
@ -86,6 +102,15 @@ 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":
|
||||
|
|
|
|||
2
finder/uv.lock
generated
2
finder/uv.lock
generated
|
|
@ -301,6 +301,7 @@ dependencies = [
|
|||
{ name = "fake-useragent" },
|
||||
{ name = "flask" },
|
||||
{ name = "httpx" },
|
||||
{ name = "lxml" },
|
||||
{ name = "playwright" },
|
||||
{ name = "playwright-stealth" },
|
||||
{ name = "polars" },
|
||||
|
|
@ -315,6 +316,7 @@ 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" },
|
||||
|
|
|
|||
|
|
@ -104,6 +104,22 @@ _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,
|
||||
|
|
@ -172,6 +188,22 @@ _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,
|
||||
|
|
@ -596,10 +628,15 @@ 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'."""
|
||||
"""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."""
|
||||
match = re.search(r"([A-Z]{1,2}\d[A-Z0-9]?\s*\d[A-Z]{2})", text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).upper().strip()
|
||||
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 None
|
||||
|
||||
|
||||
|
|
@ -651,13 +688,20 @@ 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"
|
||||
# pcm, per month, /m, or no indicator — default monthly
|
||||
# No indicator — default monthly (Zoopla standard)
|
||||
return "monthly"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export default function InvitePage({
|
|||
</div>
|
||||
)}
|
||||
<p className="text-warm-600 dark:text-warm-400 text-3xl">
|
||||
Property prices, energy ratings, crime stats, school ratings & more
|
||||
Property prices, energy ratings, crime stats, school ratings and more
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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?',
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -109,8 +109,7 @@ export default function AreaPane({
|
|||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
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' : ''}
|
||||
</p>
|
||||
{stats && stats.count > 0 && (
|
||||
|
|
|
|||
|
|
@ -200,15 +200,15 @@ export default function FeatureBrowser({
|
|||
/>
|
||||
) : isLicensed ? (
|
||||
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
||||
Don't leave it to chance.
|
||||
One-time payment, lifetime access.
|
||||
</p>
|
||||
<button
|
||||
onClick={onUpgradeClick}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { Fragment, memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { ChevronIcon, LightbulbIcon } from '../ui/icons';
|
||||
|
||||
|
|
@ -11,6 +11,8 @@ import InfoPopup from '../ui/InfoPopup';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { getFeatureIcon } from '../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../lib/group-icons';
|
||||
import AiFilterInput from './AiFilterInput';
|
||||
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
|
||||
import FeatureBrowser from './FeatureBrowser';
|
||||
|
|
@ -394,6 +396,16 @@ export default memo(function Filters({
|
|||
return scales;
|
||||
}, [features]);
|
||||
|
||||
// Insert travel time cards right before the first Transport feature,
|
||||
// so they visually group with their category.
|
||||
const travelInsertIdx = useMemo(() => {
|
||||
const idx = enabledFeatureList.findIndex((f) => f.group === 'Transport');
|
||||
if (idx >= 0) return idx;
|
||||
// No transport features enabled — place after Properties, before next group
|
||||
const afterProps = enabledFeatureList.findIndex((f) => f.group !== 'Properties');
|
||||
return afterProps >= 0 ? afterProps : enabledFeatureList.length;
|
||||
}, [enabledFeatureList]);
|
||||
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount;
|
||||
|
||||
return (
|
||||
|
|
@ -458,70 +470,71 @@ export default memo(function Filters({
|
|||
</div>
|
||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
Add filters below to narrow the map to areas that match
|
||||
Add filters below to narrow the map to areas that match your criteria
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
isActive={activeFeature === travelFieldKey(entry)}
|
||||
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
|
||||
onTogglePin={() => 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)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{enabledFeatureList.map((feature) => {
|
||||
{enabledFeatureList.map((feature, featureIdx) => {
|
||||
if (feature.type === 'enum') {
|
||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} size="sm" />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<PillGroup>
|
||||
{allValues.map((val) => (
|
||||
<PillToggle
|
||||
key={val}
|
||||
label={val}
|
||||
active={selectedValues.includes(val)}
|
||||
onClick={() => {
|
||||
const next = selectedValues.includes(val)
|
||||
? selectedValues.filter((v) => v !== val)
|
||||
: [...selectedValues, val];
|
||||
onFilterChange(feature.name, next);
|
||||
}}
|
||||
size="xs"
|
||||
<Fragment key={feature.name}>
|
||||
{featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => (
|
||||
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
isActive={activeFeature === travelFieldKey(entry)}
|
||||
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
|
||||
onTogglePin={() => 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)}
|
||||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} size="sm" />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<PillGroup>
|
||||
{allValues.map((val) => (
|
||||
<PillToggle
|
||||
key={val}
|
||||
label={val}
|
||||
active={selectedValues.includes(val)}
|
||||
onClick={() => {
|
||||
const next = selectedValues.includes(val)
|
||||
? selectedValues.filter((v) => v !== val)
|
||||
: [...selectedValues, val];
|
||||
onFilterChange(feature.name, next);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -552,64 +565,118 @@ export default memo(function Filters({
|
|||
clampMax ? feature.max! : displayValue[1],
|
||||
];
|
||||
|
||||
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
|
||||
const mobileIcon = getFeatureIcon(feature.name, mobileIconClass) || (() => {
|
||||
const G = feature.group ? getGroupIcon(feature.group) : null;
|
||||
return G ? <G className={mobileIconClass} /> : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
<Fragment key={feature.name}>
|
||||
{featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => (
|
||||
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
isActive={activeFeature === travelFieldKey(entry)}
|
||||
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
|
||||
onTogglePin={() => 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)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex md:block items-start gap-1.5">
|
||||
{mobileIcon && <div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Slider
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => {
|
||||
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()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={feature.raw}
|
||||
feature={feature}
|
||||
onValueChange={(v) => onFilterChange(feature.name, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Slider
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => {
|
||||
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()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={feature.raw}
|
||||
feature={feature}
|
||||
onValueChange={(v) => onFilterChange(feature.name, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{travelInsertIdx >= enabledFeatureList.length && travelTimeEntries.map((entry, index) => (
|
||||
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
isActive={activeFeature === travelFieldKey(entry)}
|
||||
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
|
||||
onTogglePin={() => 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)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -651,8 +718,8 @@ export default memo(function Filters({
|
|||
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -662,7 +729,7 @@ export default memo(function Filters({
|
|||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">
|
||||
Budget & basics
|
||||
Budget and basics
|
||||
</span>{' '}
|
||||
(price range, floor area, property type)
|
||||
</p>
|
||||
|
|
@ -709,14 +776,14 @@ export default memo(function Filters({
|
|||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">Energy</span>{' '}
|
||||
(EPC ratings for lower bills and fewer surprises)
|
||||
(EPC ratings, insulation, heating costs)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-warm-500 dark:text-warm-400 italic text-xs">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{onResetTutorial && (
|
||||
|
|
|
|||
|
|
@ -338,6 +338,18 @@ export default function MapPage({
|
|||
return () => document.removeEventListener('wheel', handleWheel);
|
||||
}, []);
|
||||
|
||||
// On mobile, push a guard history entry to absorb accidental back navigations
|
||||
// (e.g. iOS Safari edge-swipe that CSS touch-action can't prevent)
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
window.history.pushState({ dashboardGuard: true }, '');
|
||||
const handlePopState = () => {
|
||||
window.history.pushState({ dashboardGuard: true }, '');
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [isMobile]);
|
||||
|
||||
const { handleHexagonClick } = selection;
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
|
||||
|
|
@ -611,7 +623,7 @@ export default function MapPage({
|
|||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative touch-pan-y">
|
||||
{initialLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -140,10 +140,8 @@ export default function POIPane({
|
|||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -122,9 +122,9 @@ export function TravelTimeCard({
|
|||
{showBestInfo && (
|
||||
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey if
|
||||
you time your departure to catch optimal connections. The default uses the{' '}
|
||||
<strong>median</strong>, 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 <strong>median</strong>, representing a typical journey
|
||||
regardless of when you leave.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</p>
|
||||
<p className="text-warm-200 font-semibold">
|
||||
Less than your survey costs. Vastly more useful.
|
||||
Less than a home survey. Far more useful.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface FeatureLabelProps {
|
|||
className?: string;
|
||||
size?: 'xs' | 'sm';
|
||||
description?: string;
|
||||
hideIconOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export function FeatureLabel({
|
||||
|
|
@ -23,9 +24,11 @@ export function FeatureLabel({
|
|||
className = '',
|
||||
size = 'xs',
|
||||
description,
|
||||
hideIconOnMobile,
|
||||
}: FeatureLabelProps) {
|
||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
const iconClass = 'w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0';
|
||||
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
|
||||
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
|
||||
const featureIcon = getFeatureIcon(feature.name, iconClass);
|
||||
const GroupIcon = !featureIcon && feature.group ? getGroupIcon(feature.group) : null;
|
||||
const modeTag =
|
||||
|
|
|
|||
|
|
@ -50,12 +50,12 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
||||
<div className="text-5xl mb-3">🎉</div>
|
||||
<h2 className="text-2xl font-bold text-white">Welcome aboard!</h2>
|
||||
<h2 className="text-2xl font-bold text-white">You're in.</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">Your lifetime access is now active.</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
|
||||
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.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function TravelTimeInfoPopup({
|
|||
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={onClose}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Shows how long it takes to reach the selected destination from each area
|
||||
{MODE_INFO[mode]} Use the slider to filter areas within your preferred commute time.
|
||||
{MODE_INFO[mode]} Use the slider to set your maximum commute time.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export default function UpgradeModal({
|
|||
onClick={onZoomToFreeZone}
|
||||
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
|
||||
>
|
||||
Or continue exploring inner London
|
||||
Continue exploring inner London
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<TransportMode, string> = {
|
||||
car: 'Car',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -189,7 +189,6 @@ export const STACKED_ENUM_GROUPS: Record<
|
|||
valueColors: ['#3b82f6', '#f59e0b'],
|
||||
},
|
||||
],
|
||||
Environment: [],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<String>) {
|
|||
let field_set: HashSet<String> = fields
|
||||
.map(|fields_str| {
|
||||
fields_str
|
||||
.split(',')
|
||||
.split(";;")
|
||||
.map(|field| field.trim().to_string())
|
||||
.filter(|field| !field.is_empty())
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<FeatureGroupResponse> = Vec::new();
|
||||
|
||||
for &group_name in &group_names {
|
||||
for feature_group in FEATURE_GROUPS {
|
||||
let mut features: Vec<FeatureInfo> = 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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue