Compare commits

...

2 commits

Author SHA1 Message Date
2f149503bb all is well
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 7m0s
CI / Check (push) Failing after 7m9s
2026-05-17 17:20:19 +01:00
eac1bd0d13 seems alright 2026-05-17 13:52:11 +01:00
95 changed files with 24610 additions and 153801 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

View file

@ -82,7 +82,7 @@ PROPERTY_TYPE_MAP = {
"Farm / Barn": "Other",
"Farm House": "Other",
"House": "Detached",
"House of Multiple Occupation": "Flats/Maisonettes",
"House of Multiple Occupation": "Other",
"House Share": "Other",
"Not Specified": "Other",
"Chalet": "Other",
@ -90,15 +90,15 @@ PROPERTY_TYPE_MAP = {
"Coach House": "Other",
"Character Property": "Other",
"Cluster House": "Other",
"Retirement Property": "Flats/Maisonettes",
"Retirement Property": "Other",
"Parking": "Other",
"Plot": "Other",
"Garages": "Other",
"Mews": "Terraced",
"Property": "Other",
"Flat Share": "Other",
"Block of Apartments": "Flats/Maisonettes",
"Private Halls": "Flats/Maisonettes",
"Block of Apartments": "Other",
"Private Halls": "Other",
"Terraced Bungalow": "Terraced",
"Equestrian Facility": "Other",
"Ground Maisonette": "Flats/Maisonettes",
@ -107,13 +107,13 @@ PROPERTY_TYPE_MAP = {
"Farm Land": "Other",
"House Boat": "Other",
"Barn": "Other",
"Serviced Apartments": "Flats/Maisonettes",
"Serviced Apartments": "Other",
# Space-separated variants (from home.co.uk underscore/hyphen normalization)
"Semi Detached": "Semi-Detached",
"Semi Detached Bungalow": "Semi-Detached",
"End Of Terrace": "Terraced",
"End Terrace": "Terraced",
"Block Of Apartments": "Flats/Maisonettes",
"Block Of Apartments": "Other",
# Lowercase variants (from home.co.uk / Rightmove APIs)
"house": "Detached",
"bungalow": "Other",
@ -121,7 +121,7 @@ PROPERTY_TYPE_MAP = {
"land": "Other",
"other": "Other",
"not-specified": "Other",
"retirement-property": "Flats/Maisonettes",
"retirement-property": "Other",
"equestrian-facility": "Other",
"flat": "Flats/Maisonettes",
"detached": "Detached",

View file

@ -19,7 +19,12 @@ from constants import (
RETRY_BASE_DELAY,
)
from spatial import PostcodeSpatialIndex
from transform import normalize_postcode, normalize_sub_type, validate_floor_area
from transform import (
normalize_postcode,
normalize_sub_type,
parse_int_value,
validate_floor_area,
)
log = logging.getLogger("homecouk")
@ -170,11 +175,19 @@ def parse_floor_area(description: str | None) -> float | None:
"""Try to extract floor area from description text like '789 sq.ft.' or '73 sq.m.'."""
if not description:
return None
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", description, re.IGNORECASE)
m = re.search(
r"([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*ft|square\s+feet|ft(?:\^?2|²))",
description,
re.IGNORECASE,
)
if m:
sqft = float(m.group(1).replace(",", ""))
return validate_floor_area(round(sqft * 0.092903, 1))
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", description, re.IGNORECASE)
m = re.search(
r"([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*m|square\s+met(?:er|re)s?|m(?:\^?2|²))",
description,
re.IGNORECASE,
)
if m:
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
return None
@ -237,6 +250,15 @@ def map_property_type(raw_type: str | None) -> str:
# Home.co.uk uses types like "House", "Flat", "Apartment", "Detached", etc.
# Try common patterns
lower = raw_type.lower()
excluded_flat_like = (
"block of apartment",
"house of multiple occupation",
"private halls",
"retirement",
"serviced apartment",
)
if any(term in lower for term in excluded_flat_like):
return "Other"
if (
"flat" in lower
or "apartment" in lower
@ -269,8 +291,10 @@ def transform_property(
log.debug("Coords outside England: lat=%.4f lng=%.4f — skipping", lat, lng)
return None
price = prop.get("price") or prop.get("latest_price")
if not price or int(price) <= 0:
price = parse_int_value(prop.get("price")) or parse_int_value(
prop.get("latest_price")
)
if not price or price <= 0:
return None
# Home.co.uk provides postcodes directly, but fall back to spatial index
@ -281,10 +305,10 @@ def transform_property(
log.debug("No postcode for property at %.4f, %.4f — skipping", lat, lng)
return None
raw_beds = prop.get("bedrooms", 0) or 0
raw_baths = prop.get("bathrooms", 0) or 0
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
raw_beds = parse_int_value(prop.get("bedrooms")) or 0
raw_baths = parse_int_value(prop.get("bathrooms")) or 0
bedrooms = raw_beds if 0 <= raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if 0 <= raw_baths <= MAX_BEDROOMS else 0
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"home.co.uk %s: implausible beds=%d baths=%d (capped to 0)",
@ -318,7 +342,7 @@ def transform_property(
"Leasehold/Freehold": parse_tenure(prop),
"Property type": map_property_type(listing_type),
"Property sub-type": normalize_sub_type(listing_type),
"price": int(price),
"price": price,
"price_frequency": "",
"Price qualifier": price_qualifier,
"Total floor area (sqm)": parse_floor_area(prop.get("description")),
@ -362,7 +386,16 @@ def search_outcode(
break
for prop in raw_props:
transformed = transform_property(prop, pc_index)
try:
transformed = transform_property(prop, pc_index)
except Exception as exc:
log.warning(
"home.co.uk %s property %s failed to transform: %s",
outcode,
prop.get("listing_id") or prop.get("property_id") or "?",
exc,
)
continue
if transformed:
properties.append(transformed)
if max_properties is not None and len(properties) >= max_properties:

63
finder/listing_filters.py Normal file
View file

@ -0,0 +1,63 @@
"""Shared target filters for manual buy-listing scrapes."""
import math
from typing import Any
BUY_MAX_PRICE = 1_000_000
BUY_MIN_BEDROOMS = 2
BUY_MAX_BEDROOMS = 5
BUY_ALLOWED_BATHROOMS = frozenset({2, 3})
BUY_MIN_FLOOR_AREA_SQM = 90.0
BUY_MAX_FLOOR_AREA_SQM = 170.0
BUY_PROPERTY_TYPES = frozenset({"Flats/Maisonettes"})
BUY_MIN_FLOOR_AREA_SQFT = round(BUY_MIN_FLOOR_AREA_SQM / 0.092903)
BUY_MAX_FLOOR_AREA_SQFT = round(BUY_MAX_FLOOR_AREA_SQM / 0.092903)
def _number(value: Any) -> float | None:
if value is None:
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number):
return None
return number
def _int(value: Any) -> int | None:
number = _number(value)
if number is None or not number.is_integer():
return None
return int(number)
def matches_strict_buy_listing_filter(prop: dict) -> bool:
"""Exact filter used to guard scraped/output datasets."""
if "price" in prop:
price = _number(prop.get("price"))
else:
price = _number(prop.get("Asking price"))
if price is None or price <= 0 or price >= BUY_MAX_PRICE:
return False
bedrooms = _int(prop.get("Bedrooms"))
if bedrooms is None or (
bedrooms < BUY_MIN_BEDROOMS or bedrooms > BUY_MAX_BEDROOMS
):
return False
property_type = prop.get("Property type")
if property_type not in BUY_PROPERTY_TYPES:
return False
bathrooms = _int(prop.get("Bathrooms"))
if bathrooms not in BUY_ALLOWED_BATHROOMS:
return False
floor_area = _number(prop.get("Total floor area (sqm)"))
if floor_area is None:
return False
return BUY_MIN_FLOOR_AREA_SQM <= floor_area <= BUY_MAX_FLOOR_AREA_SQM

View file

@ -10,6 +10,15 @@ from constants import (
TYPEAHEAD_URL,
)
from http_client import fetch_with_retry
from listing_filters import (
BUY_ALLOWED_BATHROOMS,
BUY_MAX_BEDROOMS,
BUY_MAX_FLOOR_AREA_SQFT,
BUY_MAX_PRICE,
BUY_MIN_BEDROOMS,
BUY_MIN_FLOOR_AREA_SQFT,
matches_strict_buy_listing_filter,
)
from spatial import PostcodeSpatialIndex
from transform import transform_property
@ -22,12 +31,23 @@ outcode_cache: dict[str, str] = {}
# Requesting index >= 1008 returns HTTP 400.
_MAX_INDEX = 1008
# Property type filters for splitting overcapped searches. Each sub-query
# gets its own 1008 cap, so we can recover listings beyond the unfiltered limit.
_PROPERTY_TYPES = [
"detached", "semi-detached", "terraced", "flat",
"bungalow", "park-home", "land",
]
_BASE_BUY_SEARCH_PARAMS = {
"propertyTypes": "flat",
"minBedrooms": str(BUY_MIN_BEDROOMS),
"maxBedrooms": str(BUY_MAX_BEDROOMS),
"minBathrooms": str(min(BUY_ALLOWED_BATHROOMS)),
"maxBathrooms": str(max(BUY_ALLOWED_BATHROOMS)),
"minSize": str(BUY_MIN_FLOOR_AREA_SQFT),
"maxSize": str(BUY_MAX_FLOOR_AREA_SQFT),
"maxPrice": str(BUY_MAX_PRICE - 1),
}
def _buy_search_params(extra_params: dict | None = None) -> dict:
params = dict(_BASE_BUY_SEARCH_PARAMS)
if extra_params:
params.update(extra_params)
return params
def resolve_outcode_id(client: httpx.Client, outcode: str) -> str | None:
@ -92,8 +112,18 @@ def _paginate(
break
for prop in raw_props:
transformed = transform_property(prop, outcode, pc_index)
if transformed:
try:
transformed = transform_property(prop, outcode, pc_index)
except Exception as exc:
log.warning(
"Rightmove %s/%s property %s failed to transform: %s",
outcode,
channel_cfg["channel"],
prop.get("id", "?"),
exc,
)
continue
if transformed and matches_strict_buy_listing_filter(transformed):
properties.append(transformed)
if max_properties is not None and len(properties) >= max_properties:
return properties, result_count
@ -105,6 +135,15 @@ def _paginate(
if index >= result_count:
break
if index >= _MAX_INDEX:
log.warning(
"%s/%s: %d filtered results exceed Rightmove's %d-result page cap",
outcode,
channel_cfg["channel"],
result_count,
_MAX_INDEX,
)
break
time.sleep(DELAY_BETWEEN_PAGES)
@ -121,54 +160,20 @@ def search_outcode(
) -> list[dict]:
"""Paginate through search results for one outcode+channel. Returns transformed properties.
When the unfiltered result count exceeds 1008 (Rightmove's hard pagination cap),
re-queries per property type to recover listings beyond the cap.
Search requests set the supported Rightmove filters directly: flats,
2-5 bedrooms, 2-3 bathrooms, 969-1830 sq ft, and asking price below £1m.
"""
properties, result_count = _paginate(
client, outcode_id, outcode, channel_cfg, pc_index, max_properties=max_properties
properties, _ = _paginate(
client,
outcode_id,
outcode,
channel_cfg,
pc_index,
extra_params=_buy_search_params(),
max_properties=max_properties,
)
if max_properties is not None and len(properties) >= max_properties:
return properties[:max_properties]
if result_count <= _MAX_INDEX:
return properties
# Hit the 1008 cap — re-search per property type to get full coverage
ch = channel_cfg["channel"]
log.info(
"%s/%s: %d results exceed %d cap, splitting by property type",
outcode, ch, result_count, _MAX_INDEX,
)
all_by_id: dict[str, dict] = {p["id"]: p for p in properties}
for pt in _PROPERTY_TYPES:
pt_props, _ = _paginate(
client, outcode_id, outcode, channel_cfg, pc_index,
extra_params={"propertyTypes": pt},
max_properties=max_properties,
)
new = 0
for p in pt_props:
if p["id"] not in all_by_id:
all_by_id[p["id"]] = p
new += 1
if (
max_properties is not None
and len(all_by_id) >= max_properties
):
break
if new:
log.debug("%s/%s type=%s: +%d new properties", outcode, ch, pt, new)
if max_properties is not None and len(all_by_id) >= max_properties:
break
log.info(
"%s/%s: type split recovered %d%d properties",
outcode, ch, len(properties), len(all_by_id),
)
properties = list(all_by_id.values())
if max_properties is not None:
return properties[:max_properties]
return properties

View file

@ -19,6 +19,7 @@ from homecouk import load_cookies as load_homecouk_cookies
from homecouk import make_client as make_homecouk_client
from homecouk import search_outcode as homecouk_search_outcode
from http_client import make_client
from listing_filters import matches_strict_buy_listing_filter
from rightmove import resolve_outcode_id
from rightmove import search_outcode as rightmove_search_outcode
from spatial import PostcodeSpatialIndex
@ -181,11 +182,11 @@ def _source_names(sources: str | Iterable[str] | None) -> list[str]:
requested = [str(source).strip().lower() for source in sources]
requested = [source for source in requested if source]
if "all" in requested:
return list(SOURCE_ORDER)
unknown = sorted(set(requested) - set(SOURCE_ORDER))
unknown = sorted(set(requested) - set(SOURCE_ORDER) - {"all"})
if unknown:
raise ValueError(f"Unknown source(s): {', '.join(unknown)}")
if "all" in requested:
return list(SOURCE_ORDER)
return [source for source in SOURCE_ORDER if source in requested]
@ -196,19 +197,28 @@ def _dedup_key(prop: dict) -> tuple:
def _merge_properties(source_results: dict[str, list[dict]]) -> tuple[list[dict], dict, int]:
merged: dict[str, dict] = {}
seen_keys: set[tuple] = set()
seen_ids: set[str] = set()
counts = {source: 0 for source in SOURCE_ORDER}
deduped = 0
for source in SOURCE_ORDER:
for prop in source_results.get(source, []):
prop_id = prop.get("id")
key = _dedup_key(prop)
if (prop_id is not None and prop_id in merged) or key in seen_keys:
deduped += 1
continue
storage_key = prop_id if prop_id is not None else f"{source}:{len(merged)}"
if prop_id is not None:
prop_id = str(prop_id)
if prop_id in seen_ids:
deduped += 1
continue
seen_ids.add(prop_id)
storage_key = prop_id
else:
key = _dedup_key(prop)
if key in seen_keys:
deduped += 1
continue
seen_keys.add(key)
storage_key = f"{source}:{len(merged)}"
merged[storage_key] = prop
seen_keys.add(key)
counts[source] += 1
return list(merged.values()), counts, deduped
@ -241,13 +251,22 @@ def _store_properties(
if remaining == 0:
return 0
eligible = [prop for prop in props if _property_is_londonish(prop)]
dropped = len(props) - len(eligible)
if dropped:
londonish = [prop for prop in props if _property_is_londonish(prop)]
dropped_outside_area = len(props) - len(londonish)
if dropped_outside_area:
log.debug(
"%s dropped %d properties outside the Greater London-ish postcode filter",
source,
dropped,
dropped_outside_area,
)
eligible = [prop for prop in londonish if matches_strict_buy_listing_filter(prop)]
dropped_non_matching = len(londonish) - len(eligible)
if dropped_non_matching:
log.debug(
"%s dropped %d properties outside the strict buy-listing filters",
source,
dropped_non_matching,
)
selected = eligible if remaining is None else eligible[:remaining]
@ -367,20 +386,16 @@ def _scrape_homecouk(
log.info("home.co.uk cap reached")
return
remaining = _source_remaining(
results, "homecouk", max_properties_per_source
)
if remaining == 0:
log.info("home.co.uk cap reached")
return
for attempt in range(2):
try:
# home.co.uk cannot express the full filter set at source.
# Fetch the outcode page set first; _store_properties applies
# the strict filter and source cap after transformation.
props = homecouk_search_outcode(
client,
outcode,
pc_index,
max_properties=remaining,
max_properties=None,
)
added = _store_properties(
results,
@ -442,19 +457,17 @@ def _scrape_zoopla(
log.info("Zoopla cap reached")
return
remaining = _source_remaining(results, "zoopla", max_properties_per_source)
if remaining == 0:
log.info("Zoopla cap reached")
return
for attempt in range(2):
try:
# Zoopla source-side filters are unverified here. Fetch the
# outcode page set first; _store_properties applies the
# strict filter and source cap after transformation.
props, _ = zoopla_search_outcode(
page,
outcode,
pc_index,
pc_coords,
max_properties=remaining,
max_properties=None,
)
added = _store_properties(
results,
@ -506,9 +519,6 @@ def run_scrape(
output_base = Path(output_dir) if output_dir is not None else DATA_DIR
output_base.mkdir(parents=True, exist_ok=True)
if "zoopla" in selected_sources and pc_coords is None:
pc_coords = build_postcode_coords()
errors: list[str] = []
results = {source: [] for source in SOURCE_ORDER}
started_at = time.time()
@ -539,7 +549,8 @@ def run_scrape(
)
if "zoopla" in selected_sources:
assert pc_coords is not None
if pc_coords is None:
pc_coords = build_postcode_coords()
_scrape_zoopla(
selected_outcodes,
pc_index,
@ -551,19 +562,36 @@ def run_scrape(
merged, source_counts, deduped = _merge_properties(results)
output_path = output_base / "online_listings_buy.parquet"
write_parquet(merged, output_path)
if merged:
write_parquet(merged, output_path)
else:
if output_path.exists():
output_path.unlink()
log.warning("No strict properties to write to %s", output_path)
filtered = [prop for prop in merged if matches_strict_buy_listing_filter(prop)]
filtered_output_path = output_base / "online_listings_buy_filtered.parquet"
if filtered:
write_parquet(filtered, filtered_output_path)
else:
if filtered_output_path.exists():
filtered_output_path.unlink()
log.warning("No strict-filtered properties to write to %s", filtered_output_path)
counts = {
"total": len(merged),
"filtered_total": len(filtered),
"deduped": deduped,
"sources": source_counts,
}
source_summary = " ".join(
f"{source}:{source_counts[source]}" for source in SOURCE_ORDER
)
log.info(
"Sale scrape complete: %d unique (rightmove:%d homecouk:%d zoopla:%d deduped:%d)",
"Sale scrape complete: %d unique, %d strict-filtered (%s deduped:%d)",
len(merged),
source_counts["rightmove"],
source_counts["homecouk"],
source_counts["zoopla"],
len(filtered),
source_summary,
deduped,
)
@ -575,6 +603,7 @@ def run_scrape(
},
"counts": counts,
"path": str(output_path),
"filtered_path": str(filtered_output_path),
"errors": errors,
"elapsed_seconds": round(time.time() - started_at, 3),
}

View file

@ -45,9 +45,10 @@ def write_parquet(properties: list[dict], path: Path) -> None:
remapped = 0
for p in properties:
sub_type = p.get("Property sub-type", "")
if sub_type and sub_type != "Unknown":
current_type = p.get("Property type")
if sub_type and sub_type != "Unknown" and current_type in (None, "", "Other"):
new_type = map_property_type(sub_type)
if new_type != p.get("Property type"):
if new_type != current_type:
p["Property type"] = new_type
remapped += 1
if remapped:

View file

@ -1,4 +1,5 @@
import logging
import math
import re
from constants import MAX_BEDROOMS, PROPERTY_TYPE_MAP, RIGHTMOVE_BASE
@ -29,17 +30,43 @@ def validate_floor_area(sqm: float | None) -> float | None:
return sqm
def parse_int_value(value) -> int | None:
"""Parse an integer-like API value without truncating decimals."""
if value is None or isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
if not math.isfinite(value) or not value.is_integer():
return None
return int(value)
if isinstance(value, str):
cleaned = value.strip().replace(",", "").replace("£", "")
if not re.fullmatch(r"\d+", cleaned):
return None
return int(cleaned)
return None
def parse_display_size(display_size: str | None) -> float | None:
"""Parse displaySize like '499 sq. ft.' or '4,124 sq. ft.' to sqm."""
if not display_size:
return None
# Try sq. ft. first
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", display_size, re.IGNORECASE)
m = re.search(
r"([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*ft|square\s+feet|ft(?:\^?2|²))",
display_size,
re.IGNORECASE,
)
if m:
sqft = float(m.group(1).replace(",", ""))
return validate_floor_area(round(sqft * 0.092903, 1))
# Try sq. m.
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", display_size, re.IGNORECASE)
m = re.search(
r"([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*m|square\s+met(?:er|re)s?|m(?:\^?2|²))",
display_size,
re.IGNORECASE,
)
if m:
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
return None
@ -86,7 +113,21 @@ def map_property_type(sub_type: str | None) -> str:
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:
excluded_flat_like = (
"block of apartment",
"house of multiple occupation",
"private halls",
"retirement",
"serviced apartment",
)
if any(term in lower for term in excluded_flat_like):
return "Other"
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"
@ -158,10 +199,10 @@ def transform_property(
lat, lng = fix_coords(raw_lat, raw_lng)
price_obj = prop.get("price", {})
amount = price_obj.get("amount")
amount = parse_int_value(price_obj.get("amount"))
if not amount:
return None
price = int(amount)
price = amount
if price <= 0:
return None
@ -172,14 +213,23 @@ def transform_property(
# POA / Auction listings have unreliable prices — treat as no price
pq_lower = price_qualifier.lower()
if "poa" in pq_lower or "auction" in pq_lower:
non_comparable_price_terms = (
"poa",
"auction",
"shared ownership",
"shared equity",
"part buy",
"part rent",
"from",
)
if any(term in pq_lower for term in non_comparable_price_terms):
return None
sub_type = prop.get("propertySubType", "")
raw_beds = prop.get("bedrooms", 0) or 0
raw_baths = prop.get("bathrooms", 0) or 0
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
raw_beds = parse_int_value(prop.get("bedrooms")) or 0
raw_baths = parse_int_value(prop.get("bathrooms")) or 0
bedrooms = raw_beds if 0 <= raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if 0 <= raw_baths <= MAX_BEDROOMS else 0
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"Rightmove %s: implausible beds=%d baths=%d (capped to 0)",
@ -197,8 +247,15 @@ def transform_property(
log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng)
return None
property_url = prop.get("propertyUrl") or ""
if not isinstance(property_url, str):
property_url = ""
listing_id = prop.get("id") or property_url
if not listing_id:
return None
return {
"id": prop.get("id"),
"id": listing_id,
"Bedrooms": bedrooms,
"Bathrooms": bathrooms,
"Number of bedrooms & living rooms": bedrooms + bathrooms,
@ -213,7 +270,7 @@ def transform_property(
"price_frequency": "",
"Price qualifier": price_qualifier,
"Total floor area (sqm)": parse_display_size(prop.get("displaySize")),
"Listing URL": RIGHTMOVE_BASE + prop.get("propertyUrl", ""),
"Listing URL": RIGHTMOVE_BASE + property_url if property_url else "",
"Listing features": key_features,
"first_visible_date": prop.get("firstVisibleDate", ""),
}

View file

@ -24,7 +24,7 @@ import time
from constants import DELAY_BETWEEN_PAGES, MAX_BEDROOMS, PROPERTY_TYPE_MAP, ZOOPLA_BASE
from spatial import PostcodeSpatialIndex
from transform import normalize_sub_type, validate_floor_area
from transform import normalize_sub_type, parse_int_value, validate_floor_area
log = logging.getLogger("zoopla")
@ -106,7 +106,8 @@ _EXTRACT_LISTINGS_JS = r"""() => {
const bedsMatch = text.match(/(\d+)\s*beds?/i);
const bathsMatch = text.match(/(\d+)\s*baths?/i);
const recMatch = text.match(/(\d+)\s*reception/i);
const areaMatch = text.match(/([\d,]+)\s*sq\.?\s*ft/i);
const areaSqftMatch = text.match(/([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*ft|square\s+feet|ft(?:\^?2|²))/i);
const areaSqmMatch = text.match(/([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*m|square\s+met(?:er|re)s?|m(?:\^?2|²))/i);
let tenure = '';
if (/leasehold/i.test(text)) tenure = 'Leasehold';
@ -141,7 +142,8 @@ _EXTRACT_LISTINGS_JS = r"""() => {
beds: bedsMatch && parseInt(bedsMatch[1]) <= 20 ? parseInt(bedsMatch[1]) : null,
baths: bathsMatch && parseInt(bathsMatch[1]) <= 20 ? parseInt(bathsMatch[1]) : null,
receptions: recMatch && parseInt(recMatch[1]) <= 20 ? parseInt(recMatch[1]) : null,
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
floor_area_sqft: areaSqftMatch ? parseInt(areaSqftMatch[1].replace(/,/g, '')) : null,
floor_area_sqm: areaSqmMatch ? parseFloat(areaSqmMatch[1].replace(/,/g, '')) : null,
address, tenure, property_type,
});
}
@ -181,7 +183,8 @@ _EXTRACT_LISTINGS_JS = r"""() => {
const bedsMatch = text.match(/(\d+)\s*beds?/i);
const bathsMatch = text.match(/(\d+)\s*baths?/i);
const recMatch = text.match(/(\d+)\s*reception/i);
const areaMatch = text.match(/([\d,]+)\s*sq\.?\s*ft/i);
const areaSqftMatch = text.match(/([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*ft|square\s+feet|ft(?:\^?2|²))/i);
const areaSqmMatch = text.match(/([\d,]+(?:\.\d+)?)\s*(?:sq\.?\s*m|square\s+met(?:er|re)s?|m(?:\^?2|²))/i);
let address = '';
for (const line of lines) {
@ -225,7 +228,8 @@ _EXTRACT_LISTINGS_JS = r"""() => {
beds: bedsMatch && parseInt(bedsMatch[1]) <= 20 ? parseInt(bedsMatch[1]) : null,
baths: bathsMatch && parseInt(bathsMatch[1]) <= 20 ? parseInt(bathsMatch[1]) : null,
receptions: recMatch && parseInt(recMatch[1]) <= 20 ? parseInt(recMatch[1]) : null,
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
floor_area_sqft: areaSqftMatch ? parseInt(areaSqftMatch[1].replace(/,/g, '')) : null,
floor_area_sqm: areaSqmMatch ? parseFloat(areaSqmMatch[1].replace(/,/g, '')) : null,
address, tenure, property_type,
});
}
@ -611,7 +615,22 @@ def _map_property_type(raw_type: str | None) -> str:
return canonical
# Keyword fallback
lower = raw_type.lower()
if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower or "penthouse" in lower:
excluded_flat_like = (
"block of apartment",
"house of multiple occupation",
"private halls",
"retirement",
"serviced apartment",
)
if any(term in lower for term in excluded_flat_like):
return "Other"
if (
"flat" in lower
or "apartment" in lower
or "maisonette" in lower
or "studio" in lower
or "penthouse" in lower
):
return "Flats/Maisonettes"
if "semi" in lower and "detach" in lower:
return "Semi-Detached"
@ -634,8 +653,8 @@ def transform_property(
Zoopla search cards do not include coordinates, so we resolve lat/lng
from postcodes extracted from the address text."""
price = raw.get("price")
if not price or int(price) <= 0:
price = parse_int_value(raw.get("price"))
if not price or price <= 0:
return None
address = raw.get("address", "")
@ -670,10 +689,10 @@ def transform_property(
if not (49 <= lat <= 56 and -7 <= lng <= 2):
return None
raw_beds = raw.get("beds") or 0
raw_baths = raw.get("baths") or 0
bedrooms = raw_beds if raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if raw_baths <= MAX_BEDROOMS else 0
raw_beds = parse_int_value(raw.get("beds")) or 0
raw_baths = parse_int_value(raw.get("baths")) or 0
bedrooms = raw_beds if 0 <= raw_beds <= MAX_BEDROOMS else 0
bathrooms = raw_baths if 0 <= raw_baths <= MAX_BEDROOMS else 0
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"Zoopla %s: implausible beds=%d baths=%d (capped to 0)",
@ -683,9 +702,13 @@ def transform_property(
# Floor area: convert sq ft to sq m
floor_area_sqm = None
sqft = raw.get("floor_area_sqft")
if sqft:
floor_area_sqm = validate_floor_area(round(sqft * 0.092903, 1))
raw_sqm = raw.get("floor_area_sqm")
if raw_sqm:
floor_area_sqm = validate_floor_area(round(float(raw_sqm), 1))
else:
sqft = raw.get("floor_area_sqft")
if sqft:
floor_area_sqm = validate_floor_area(round(float(sqft) * 0.092903, 1))
listing_id = raw.get("id", "")
listing_url = raw.get("url", "")
@ -704,7 +727,7 @@ def transform_property(
"Leasehold/Freehold": raw.get("tenure") or None,
"Property type": _map_property_type(raw.get("property_type")),
"Property sub-type": normalize_sub_type(raw.get("property_type")),
"price": int(price),
"price": price,
"price_frequency": "",
"Price qualifier": "",
"Total floor area (sqm)": floor_area_sqm,
@ -760,7 +783,18 @@ def search_outcode(
properties = []
dropped = 0
for raw in raw_listings:
transformed = transform_property(raw, pc_index, pc_coords, search_outcode=outcode)
try:
transformed = transform_property(
raw, pc_index, pc_coords, search_outcode=outcode
)
except Exception as exc:
log.warning(
"Zoopla %s property %s failed to transform: %s",
outcode,
raw.get("id", "?"),
exc,
)
transformed = None
if transformed:
properties.append(transformed)
else:

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -47,6 +47,7 @@
"@typescript-eslint/parser": "^8.59.2",
"autoprefixer": "^10.5.0",
"babel-loader": "^10.1.1",
"compression-webpack-plugin": "^12.0.0",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
"eslint": "^9.39.4",
@ -66,6 +67,7 @@
"sharp": "^0.34.5",
"style-loader": "^4.0.0",
"tailwindcss": "^4.2.4",
"terser-webpack-plugin": "^5.3.14",
"ts-loader": "^9.5.7",
"typescript": "^6.0.3",
"vitest": "^4.1.5",
@ -8169,6 +8171,27 @@
"node": ">= 0.8.0"
}
},
"node_modules/compression-webpack-plugin": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-12.0.0.tgz",
"integrity": "sha512-LR4mS19Jqq41XfA3xVMLrtzVNzqJbUHdzPeLRfQoLiAS9s87f0021fDuU89xxVQFcB6d20ufBkv4j1rQ4OowHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"schema-utils": "^4.2.0",
"serialize-javascript": "^7.0.3"
},
"engines": {
"node": ">= 20.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",

View file

@ -54,6 +54,7 @@
"@typescript-eslint/parser": "^8.59.2",
"autoprefixer": "^10.5.0",
"babel-loader": "^10.1.1",
"compression-webpack-plugin": "^12.0.0",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
"eslint": "^9.39.4",
@ -73,6 +74,7 @@
"sharp": "^0.34.5",
"style-loader": "^4.0.0",
"tailwindcss": "^4.2.4",
"terser-webpack-plugin": "^5.3.14",
"ts-loader": "^9.5.7",
"typescript": "^6.0.3",
"vitest": "^4.1.5",

View file

@ -236,6 +236,13 @@ export default function App() {
const authCompletedRef = useRef(false);
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
// Keep a ref to the latest refreshAuth so the mount-only startup effect always
// calls the current implementation without re-running when the callback identity changes.
const refreshAuthRef = useRef(refreshAuth);
useEffect(() => {
refreshAuthRef.current = refreshAuth;
}, [refreshAuth]);
const openAuthModal = useCallback(
(
tab: 'login' | 'register',
@ -284,14 +291,14 @@ export default function App() {
async function refreshOnStartup() {
if (!returnedFromCheckout) {
// Always refresh auth on startup to pick up server-side subscription changes.
refreshAuth().catch(() => {});
refreshAuthRef.current().catch(() => {});
return;
}
setLicenseSuccessStatus('verifying');
for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) {
try {
const refreshedUser = await refreshAuth();
const refreshedUser = await refreshAuthRef.current();
if (cancelled) return;
if (hasFullAccess(refreshedUser)) {
trackEvent('Purchase');
@ -314,7 +321,9 @@ export default function App() {
return () => {
cancelled = true;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Mount-only: this is a startup auth refresh / license verification handshake
// that must fire exactly once on initial load. refreshAuth is read via ref.
}, []);
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
@ -381,20 +390,17 @@ export default function App() {
[inviteCode]
);
const handleEditSearch = useCallback(
(id: string, name: string, params: string) => {
const search = params.startsWith('?') ? params : `?${params}`;
dashboardSearchRef.current = search;
const url = `/dashboard${search}`;
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
setMapUrlState(parseUrlState());
setDashboardRouteKey(search);
setRouteHash('');
setActivePage('dashboard');
setEditingSearch({ id, name });
},
[]
);
const handleEditSearch = useCallback((id: string, name: string, params: string) => {
const search = params.startsWith('?') ? params : `?${params}`;
dashboardSearchRef.current = search;
const url = `/dashboard${search}`;
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
setMapUrlState(parseUrlState());
setDashboardRouteKey(search);
setRouteHash('');
setActivePage('dashboard');
setEditingSearch({ id, name });
}, []);
const handleCancelEdit = useCallback(() => {
setEditingSearch(null);
@ -451,13 +457,25 @@ export default function App() {
activePageRef.current = activePage;
}, [activePage]);
// Refs for the initial history.replaceState seed below — the popstate effect runs
// mount-only, but it needs to read the *initial* page/hash/inviteCode values once.
const initialPageRef = useRef(activePage);
const initialRouteHashRef = useRef(routeHash);
const initialInviteCodeRef = useRef(inviteCode);
useEffect(() => {
if (!window.history.state?.page) {
const hash = routeHash || normalizeHash(window.location.hash);
const initialActivePage = initialPageRef.current;
const hash = initialRouteHashRef.current || normalizeHash(window.location.hash);
window.history.replaceState(
{ page: activePage, hash },
{ page: initialActivePage, hash },
'',
buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash)
buildPageUrl(
initialActivePage,
initialInviteCodeRef.current ?? undefined,
window.location.search,
hash
)
);
}
const handlePopState = (e: PopStateEvent) => {
@ -487,7 +505,10 @@ export default function App() {
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Mount-only: registers a single popstate listener for the app lifetime and
// seeds initial history state. The handler uses only stable setters and module
// functions; initial-render values are read via refs above.
}, []);
const { fetchSearches } = savedSearches;
useEffect(() => {

View file

@ -507,7 +507,7 @@ export default function HomePage({
)}
</td>
{[row.postcode, row.guides].map((has, j) => {
const statusLabel = has ? 'Yes' : 'No';
const statusLabel = has ? t('common.yes') : t('common.no');
return (
<td
key={j}
@ -520,11 +520,11 @@ export default function HomePage({
);
})}
<td
aria-label="Yes"
aria-label={t('common.yes')}
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30"
>
<span aria-hidden="true">&#x2713;</span>
<span className="sr-only">Yes</span>
<span className="sr-only">{t('common.yes')}</span>
</td>
</tr>
))}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { compactHistogramLabel } from './DualHistogram';
describe('compactHistogramLabel', () => {
it('rounds low-cardinality count labels to integers', () => {
const fmt = (value: number) => value.toFixed(2);
const labels = [0, 0.99, 2.98, 4.96, 5.95].map((center, index) =>
compactHistogramLabel(index, 5, 0, 5.95, center, fmt, true)
);
expect(labels).toEqual(['0', '1', '3', '5', '6+']);
});
it('labels the first integer count bucket as zero when it means below one', () => {
const fmt = (value: number) => value.toFixed(2);
expect(compactHistogramLabel(0, 5, 0.99, 5.95, 0.99, fmt, true)).toBe('0');
});
it('keeps fractional labels when integer labels are not requested', () => {
const fmt = (value: number) => value.toFixed(2);
expect(compactHistogramLabel(1, 5, 0, 5.95, 0.99, fmt, false)).toBe('0.99');
});
});

View file

@ -30,6 +30,42 @@ function pickTicks(min: number, max: number, count: number): number[] {
return ticks;
}
function isLowCardinalityHistogram(counts: number[], p1: number, p99: number): boolean {
return counts.length > 0 && counts.length <= 10 && p99 > p1 && p99 - p1 <= 10;
}
export function compactHistogramLabel(
index: number,
barCount: number,
p1: number,
p99: number,
center: number,
formatLabel: (value: number) => string,
integerLabels = false
): string {
const formatAxisValue = (value: number) =>
integerLabels ? Math.round(value).toLocaleString() : formatLabel(value);
if (barCount <= 1) return formatAxisValue(center);
const middleBins = barCount - 2;
if (index === 0) {
if (!integerLabels) return `<${formatLabel(p1)}`;
const firstBoundary = Math.ceil(p1);
return firstBoundary <= 1 ? '0' : `<${firstBoundary.toLocaleString()}`;
}
if (index === barCount - 1) {
if (!integerLabels) return `${formatLabel(p99)}+`;
return `${Math.ceil(p99).toLocaleString()}+`;
}
const middleWidth = middleBins > 0 ? (p99 - p1) / middleBins : 0;
if (Math.abs(middleWidth - 1) < 0.001) {
return formatAxisValue(p1 + index - 1);
}
return formatAxisValue(center);
}
export function DualHistogram({
localCounts,
globalCounts,
@ -38,6 +74,8 @@ export function DualHistogram({
globalMean,
meanLabel,
formatLabel,
compact = false,
integerAxisLabels = false,
}: {
localCounts: number[];
globalCounts: number[];
@ -46,9 +84,15 @@ export function DualHistogram({
globalMean?: number;
meanLabel?: string;
formatLabel?: (value: number) => string;
compact?: boolean;
integerAxisLabels?: boolean;
}) {
const { t } = useTranslation();
const targetBars = 25;
const showCompactAxisLabels =
compact &&
isLowCardinalityHistogram(localCounts, p1, p99) &&
isLowCardinalityHistogram(globalCounts, p1, p99);
const targetBars = compact ? (showCompactAxisLabels ? localCounts.length : 16) : 25;
const localBars = downsampleBars(localCounts, targetBars);
const globalBars = downsampleBars(globalCounts, targetBars);
@ -59,6 +103,8 @@ export function DualHistogram({
const fmt =
formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
if (barCount === 0) return null;
// Compute center value for each bar.
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier.
const middleBins = Math.max(barCount - 2, 0);
@ -97,6 +143,60 @@ export function DualHistogram({
? { right: 0 }
: { left: '50%', transform: 'translateX(-50%)' };
if (compact) {
const axisLabels = showCompactAxisLabels
? barCenters.map((center, index) =>
compactHistogramLabel(index, barCount, p1, p99, center, fmt, integerAxisLabels)
)
: [];
const chartTitle = [
`${fmt(p1)} - ${fmt(p99)}`,
globalMean != null ? `${meanLabel ?? t('areaPane.nationalAvg')}: ${fmt(globalMean)}` : null,
]
.filter(Boolean)
.join('\n');
return (
<div className={showCompactAxisLabels ? 'h-10' : 'h-7'} title={chartTitle}>
<div
className={`${showCompactAxisLabels ? 'h-7' : 'h-full'} relative flex items-end gap-[2px]`}
>
{Array.from({ length: barCount }).map((_, index) => {
const globalHeight = (globalBars[index] / globalMax) * 100;
const localHeight = (localBars[index] / localMax) * 100;
return (
<div key={index} className="relative flex h-full min-w-[2px] flex-1 items-end">
<div
className="absolute bottom-0 left-0 right-0 rounded-t-[2px] bg-warm-300/45 dark:bg-warm-600/50"
style={{ height: `${Math.max(globalHeight, globalBars[index] > 0 ? 8 : 0)}%` }}
/>
{localBars[index] > 0 && (
<div
className="absolute bottom-0 left-[22%] right-[22%] rounded-t-[2px] bg-teal-600 dark:bg-teal-400"
style={{ height: `${Math.max(localHeight, 12)}%` }}
/>
)}
</div>
);
})}
</div>
{showCompactAxisLabels && (
<div className="mt-0.5 flex gap-[2px]">
{axisLabels.map((label, index) => (
<span
key={`${label}-${index}`}
className="min-w-[2px] flex-1 truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
title={label}
>
{label}
</span>
))}
</div>
)}
</div>
);
}
return (
<div className="mt-1">
<div className={showMeanMarker ? 'relative pt-5' : 'relative'}>
@ -152,35 +252,29 @@ export function DualHistogram({
function SkeletonHistogram() {
return (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
<div className="flex justify-between items-baseline">
<div className="h-3 w-24 bg-warm-200 dark:bg-warm-700 rounded" />
<div className="h-3 w-10 bg-warm-200 dark:bg-warm-700 rounded" />
</div>
<div className="flex items-end gap-px h-10 mt-2">
{Array.from({ length: 15 }).map((_, i) => (
<div className="grid min-h-10 animate-pulse grid-cols-[minmax(0,1fr)_6.5rem_minmax(3.5rem,auto)] items-center gap-3 py-1.5">
<div className="h-3 w-24 rounded bg-warm-200 dark:bg-warm-700" />
<div className="flex h-7 items-end gap-[2px]">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="flex-1 bg-warm-200 dark:bg-warm-700 rounded-t-sm min-w-[2px]"
style={{ height: `${20 + Math.sin(i * 0.7) * 30 + 30}%` }}
className="min-w-[2px] flex-1 rounded-t-[2px] bg-warm-200 dark:bg-warm-700"
style={{ height: `${22 + Math.sin(i * 0.7) * 28 + 30}%` }}
/>
))}
</div>
<div className="flex justify-between mt-1">
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
</div>
<div className="h-3 w-10 justify-self-end rounded bg-warm-200 dark:bg-warm-700" />
</div>
);
}
export function LoadingSkeleton() {
return (
<div className="p-3 space-y-4">
<div className="space-y-4 p-3">
{[0, 1, 2].map((groupIdx) => (
<div key={groupIdx}>
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
<div className="space-y-3">
<div className="mb-2 h-3 w-20 animate-pulse rounded bg-warm-200 dark:bg-warm-700" />
<div className="divide-y divide-warm-100 dark:divide-navy-800">
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
<SkeletonHistogram key={i} />
))}

View file

@ -1,16 +1,34 @@
import { ts } from '../../i18n/server';
import { getEnumValueColor } from '../../lib/consts';
function shortenAxisLabel(label: string, total: number): string {
if (label.length <= 3) return label;
const parts = label.split(/[\s/&-]+/).filter(Boolean);
if (parts.length > 1) {
return parts
.map((part) => Array.from(part)[0])
.join('')
.slice(0, 3);
}
return Array.from(label)
.slice(0, total <= 5 ? 3 : 2)
.join('');
}
export default function EnumBarChart({
counts,
globalCounts,
featureName,
compact = false,
}: {
counts: Record<string, number>;
globalCounts?: Record<string, number>;
featureName: string;
compact?: boolean;
}) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
if (entries.length === 0) return null;
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
// When global counts are available, normalize both to percentages for comparison
@ -28,6 +46,71 @@ export default function EnumBarChart({
// Fallback to raw count scaling when no global data
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
if (compact) {
const title = entries
.map(([label, count]) => {
const localPct = localTotal > 0 ? (count / localTotal) * 100 : 0;
const globalPct =
hasGlobal && globalTotal > 0 ? ((globalCounts[label] ?? 0) / globalTotal) * 100 : null;
return `${ts(label)}: ${count.toLocaleString()} (${localPct.toFixed(1)}%)${
globalPct != null ? ` / ${globalPct.toFixed(1)}%` : ''
}`;
})
.join('\n');
return (
<div className="h-10" title={title}>
<div className="flex h-7 items-end gap-[2px]">
{entries.map(([label, count]) => {
const localPct = localTotal > 0 ? count / localTotal : 0;
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
const localHeight = hasGlobal
? maxPct > 0
? (localPct / maxPct) * 100
: 0
: (count / maxCount) * 100;
const globalHeight = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
const color = getEnumValueColor(featureName, label);
return (
<div key={label} className="relative flex h-full min-w-[3px] flex-1 items-end">
{hasGlobal && (
<div
className="absolute bottom-0 left-0 right-0 rounded-t-[2px] bg-warm-300/45 dark:bg-warm-600/50"
style={{ height: `${Math.max(globalHeight, globalPct > 0 ? 8 : 0)}%` }}
/>
)}
{count > 0 && (
<div
className="absolute bottom-0 left-[18%] right-[18%] rounded-t-[2px]"
style={{
height: `${Math.max(localHeight, 12)}%`,
backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})`,
}}
/>
)}
</div>
);
})}
</div>
<div className="mt-0.5 flex gap-[2px]">
{entries.map(([label]) => {
const translated = ts(label);
return (
<span
key={label}
className="min-w-[3px] flex-1 truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
title={translated}
>
{shortenAxisLabel(translated, entries.length)}
</span>
);
})}
</div>
</div>
);
}
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => {

View file

@ -86,10 +86,7 @@ export default function FeatureBrowser({
const showTravelModes =
visibleModes.length > 0 &&
(!search ||
'travel time journey commute car bicycle walking transit transport station tube train'.includes(
search.toLowerCase()
));
(!search || t('filters.travelTimeKeywords').toLowerCase().includes(search.toLowerCase()));
// Keep "Transport" first because journey and transport proximity controls belong together.
const mergedGrouped = useMemo(() => {
@ -123,7 +120,7 @@ export default function FeatureBrowser({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length +

View file

@ -3,35 +3,18 @@ import { useTranslation } from 'react-i18next';
export default function HistogramLegend() {
const { t } = useTranslation();
return (
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.tealBars')}
</span>{' '}
{t('histogramLegend.tealBarsDesc')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.greyBars')}
</span>{' '}
{t('histogramLegend.greyBarsDesc')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">
{t('histogramLegend.dashedLine')}
</span>{' '}
{t('histogramLegend.dashedLineDesc')}
</span>
</div>
<div className="mx-3 mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 rounded border border-warm-200 bg-white px-2.5 py-1.5 text-[10px] text-warm-500 dark:border-navy-800 dark:bg-navy-950/60 dark:text-warm-400">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2 rounded-[2px] bg-teal-600 dark:bg-teal-400" />
<span className="font-medium text-warm-700 dark:text-warm-200">
{t('histogramLegend.tealBars')}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2 rounded-[2px] bg-warm-300/70 dark:bg-warm-600/70" />
<span className="font-medium text-warm-700 dark:text-warm-200">
{t('histogramLegend.greyBars')}
</span>
</div>
</div>
);

View file

@ -9,6 +9,7 @@ vi.mock('react-i18next', () => ({
if (key === 'areaPane.to') return `To ${values?.destination}`;
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
if (key === 'common.min') return 'min';
if (key === 'common.minute') return 'min';
if (key === 'common.loading') return 'Loading';
if (key === 'travel.bestCase') return 'Best case';
if (key === 'areaPane.walk') return 'Walk';

View file

@ -170,7 +170,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
)}
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes}{' '}
{t('common.min')}
{t('common.minute')}
</span>
</div>
</div>
@ -191,7 +191,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
<div className="flex items-center gap-1.5 flex-wrap">
<RouteBadge mode={leg.mode} />
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.minutes} {t('common.min')}
{leg.minutes} {t('common.minute')}
</span>
</div>
{leg.from && leg.to && (
@ -333,7 +333,7 @@ export default function JourneyInstructions({
{!j.loading && totalMin > 0 && (
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
{totalMin} {t('common.min')}
{totalMin} {t('common.minute')}
</span>
)}
</div>
@ -381,7 +381,7 @@ export default function JourneyInstructions({
)}
<span className="text-xs text-warm-600 dark:text-warm-300">
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
{t('common.min')}
{t('common.minute')}
</span>
</div>
{showGoogleMapsLink && (

View file

@ -1,6 +1,7 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
@ -85,10 +86,10 @@ function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
function formatListingHeadline(listing: ActualListing): string | null {
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
const parts: string[] = [];
if (listing.bedrooms != null) parts.push(`${listing.bedrooms} bed`);
if (listing.bathrooms != null) parts.push(`${listing.bathrooms} bath`);
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
if (listing.property_sub_type) parts.push(listing.property_sub_type);
else if (listing.property_type) parts.push(listing.property_type);
return parts.length > 0 ? parts.join(' · ') : null;
@ -730,9 +731,9 @@ export default memo(function Map({
) : null}
</div>
)}
{formatListingHeadline(listingPopup.listing) && (
{formatListingHeadline(listingPopup.listing, t) && (
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
{formatListingHeadline(listingPopup.listing)}
{formatListingHeadline(listingPopup.listing, t)}
</div>
)}
{listingPopup.listing.address && (

View file

@ -6,6 +6,7 @@ import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings';
import { buildTravelParam } from '../../lib/travel-params';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
@ -15,7 +16,7 @@ import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
import { apiUrl, authHeaders } from '../../lib/api';
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
@ -146,6 +147,8 @@ export default function MapPage({
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
const areaPaneScrollTopRef = useRef(0);
const propertiesPaneScrollTopRef = useRef(0);
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
if (!isMobile) return undefined;
@ -408,10 +411,15 @@ export default function MapPage({
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const { listings: actualListings } = useActualListings(
mapData.bounds,
mapData.currentView?.zoom ?? 0
const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features),
[filters, features]
);
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
const { listings: actualListings } = useActualListings(mapData.bounds, {
filterParam: actualListingsFilterParam,
travelParam: actualListingsTravelParam,
});
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
@ -464,11 +472,7 @@ export default function MapPage({
mapData.resolution,
areaStats
);
const tutorial = useTutorial(
initialLoading,
isMobile,
deferTutorial || mapData.licenseRequired
);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
@ -499,15 +503,7 @@ export default function MapPage({
entries,
shareCode
).toString(),
[
entries,
features,
filters,
rightPaneTab,
selectedPOICategories,
shareCode,
shareAndSaveView,
]
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
);
const handleSaveSearch = useCallback(
async (name: string) => {
@ -564,6 +560,11 @@ export default function MapPage({
shareCode={shareCode}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
scrollTopRef={areaPaneScrollTopRef}
scrollRestoreKey={
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
}
scrollSaveDisabled={loadingAreaStats && areaStats == null}
/>
</Suspense>
);
@ -576,6 +577,11 @@ export default function MapPage({
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
scrollTopRef={propertiesPaneScrollTopRef}
scrollRestoreKey={
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
}
scrollSaveDisabled={loadingProperties && properties.length === 0}
/>
</Suspense>
);
@ -652,11 +658,7 @@ export default function MapPage({
};
const exportToast = (
<ExportToast
notice={exportNotice}
closeLabel={t('common.close')}
onClose={clearExportNotice}
/>
<ExportToast notice={exportNotice} closeLabel={t('common.close')} onClose={clearExportNotice} />
);
const toasts = exportToast;
@ -671,9 +673,7 @@ export default function MapPage({
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
}}
/>
</span>

View file

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
import { getPoiCategoryLogoUrl } from '../../lib/map-utils';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -188,7 +188,7 @@ export default function POIPane({
<div className="px-3 py-2">
<PillGroup>
{group.categories.map((category) => {
const logo = POI_CATEGORY_LOGOS[category];
const logo = getPoiCategoryLogoUrl(category);
return (
<PillToggle
key={category}

View file

@ -1,8 +1,9 @@
import { useMemo, useState, useEffect } from 'react';
import { useMemo, useState, useEffect, type MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
@ -17,6 +18,9 @@ interface PropertiesPaneProps {
hexagonId: string | null;
onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void;
scrollTopRef?: MutableRefObject<number>;
scrollRestoreKey?: string | null;
scrollSaveDisabled?: boolean;
}
export function PropertiesPane({
@ -26,10 +30,18 @@ export function PropertiesPane({
hexagonId,
onLoadMore,
onNavigateToSource,
scrollTopRef,
scrollRestoreKey,
scrollSaveDisabled,
}: PropertiesPaneProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
restoreKey: scrollRestoreKey ?? hexagonId,
scrollTopRef,
suspendSave: scrollSaveDisabled ?? (loading && properties.length === 0),
});
useEffect(() => {
setSearch('');
@ -60,65 +72,68 @@ export function PropertiesPane({
return (
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && properties.length > 0} />
<div className="flex-1 overflow-y-auto">
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: t('common.viewDataSource'),
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{t('propertyCard.propertyDataDesc')}
</p>
</InfoPopup>
)}
<div className="p-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('propertyCard.searchPlaceholder')}
className="p-2"
/>
</div>
<div>
{loading && properties.length === 0 ? (
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property, idx) => (
<PropertyCard key={idx} property={property} />
))}
{properties.length < total && (
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
)}
</button>
)}
</>
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: t('common.viewDataSource'),
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{t('propertyCard.propertyDataDesc')}
</p>
</InfoPopup>
)}
</div>
<div className="p-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('propertyCard.searchPlaceholder')}
className="p-2"
/>
</div>
<div>
{loading && properties.length === 0 ? (
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property) => (
<PropertyCard
key={`${property.lat},${property.lon}|${property.postcode ?? ''}|${property.address ?? ''}`}
property={property}
/>
))}
{properties.length < total && (
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
)}
</button>
)}
</>
)}
</div>
</div>
</div>
);

View file

@ -12,6 +12,7 @@ interface StackedBarChartProps {
segments: Segment[];
total: number;
colorMap: Record<string, string>;
compact?: boolean;
}
/** Strip common suffixes/prefixes to produce short legend labels */
@ -28,7 +29,27 @@ function shortenLabel(name: string): string {
.trim();
}
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
function shortenAxisLabel(name: string, total: number): string {
const label = shortenLabel(name);
if (label.length <= 3) return label;
const parts = label.split(/[\s/&-]+/).filter(Boolean);
if (parts.length > 1) {
return parts
.map((part) => Array.from(part)[0])
.join('')
.slice(0, 3);
}
return Array.from(label)
.slice(0, total <= 5 ? 3 : 2)
.join('');
}
export default function StackedBarChart({
segments,
total,
colorMap,
compact = false,
}: StackedBarChartProps) {
const { t } = useTranslation();
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
@ -55,6 +76,53 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
return color;
};
if (compact) {
const maxValue = Math.max(...sortedSegments.map((segment) => segment.value), 1);
const showAxisLabels = sortedSegments.length <= 8;
const title = sortedSegments
.map((segment, i) => {
const label = shortenLabel(ts(segment.name));
return `${label}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`;
})
.join('\n');
return (
<div className={showAxisLabels ? 'h-10' : 'h-7'} title={title}>
<div className={`${showAxisLabels ? 'h-7' : 'h-full'} flex items-end gap-[2px]`}>
{sortedSegments.map((segment) => {
const height = (segment.value / maxValue) * 100;
return (
<div
key={segment.name}
className="min-w-[3px] flex-1 rounded-t-[2px]"
style={{
height: `${Math.max(height, 12)}%`,
backgroundColor: colorFor(segment.name),
}}
/>
);
})}
</div>
{showAxisLabels && (
<div className="mt-0.5 flex gap-[2px]">
{sortedSegments.map((segment) => {
const label = shortenLabel(ts(segment.name));
return (
<span
key={segment.name}
className="min-w-[3px] flex-1 truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
title={label}
>
{shortenAxisLabel(label, sortedSegments.length)}
</span>
);
})}
</div>
)}
</div>
);
}
return (
<div className="space-y-1.5">
{/* Stacked bar */}

View file

@ -7,6 +7,7 @@ interface StackedEnumChartProps {
components: { label: string; stats: EnumFeatureStats }[];
valueOrder: string[];
valueColors: string[];
compact?: boolean;
}
/** Strip common suffixes to produce short row labels */
@ -14,10 +15,24 @@ function shortenLabel(name: string): string {
return name.replace(/ risk$/, '');
}
function shortenAxisLabel(name: string): string {
const label = shortenLabel(name);
if (label.length <= 3) return label;
const parts = label.split(/[\s/&-]+/).filter(Boolean);
if (parts.length > 1) {
return parts
.map((part) => Array.from(part)[0])
.join('')
.slice(0, 3);
}
return Array.from(label).slice(0, 3).join('');
}
export default function StackedEnumChart({
components,
valueOrder,
valueColors,
compact = false,
}: StackedEnumChartProps) {
const { t } = useTranslation();
const visibleRows = components.filter(({ stats }) => {
@ -35,6 +50,63 @@ export default function StackedEnumChart({
);
}
if (compact) {
return (
<div className="divide-y divide-warm-100 dark:divide-navy-800">
{visibleRows.map(({ label, stats }) => {
const counts = valueOrder.map((value) => stats.counts[value] ?? 0);
const total = counts.reduce((a, b) => a + b, 0);
const roundedPcts = roundedPercentages(counts, total, 0);
const title = valueOrder
.map((value, i) => `${ts(value)}: ${counts[i]} (${roundedPcts[i]}%)`)
.join('\n');
return (
<div
key={label}
className="grid min-h-8 grid-cols-[minmax(0,1fr)_6.5rem] items-center gap-3 py-1.5"
>
<span className="truncate text-xs font-medium text-warm-800 dark:text-warm-200">
{shortenLabel(ts(label))}
</span>
<div
className="flex h-5 overflow-hidden rounded-sm bg-warm-200 dark:bg-warm-700"
title={title}
>
{valueOrder.map((value, i) => {
const count = counts[i];
const pct = (count / total) * 100;
if (pct < 0.5) return null;
return (
<div
key={value}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: valueColors[i],
}}
/>
);
})}
</div>
</div>
);
})}
<div className="ml-auto grid w-[6.5rem] grid-flow-col auto-cols-fr gap-1 pt-1">
{valueOrder.map((value) => (
<span
key={value}
className="truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
title={ts(value)}
>
{shortenAxisLabel(ts(value))}
</span>
))}
</div>
</div>
);
}
return (
<div className="space-y-1.5">
{visibleRows.map(({ label, stats }) => {

View file

@ -79,7 +79,7 @@ export function TravelTimeCard({
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{t('travel.travelTime', { mode: modes.label(mode) })}
@ -158,10 +158,10 @@ export function TravelTimeCard({
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} {t('common.min')}
{formatFilterValue(displayRange[0])} {t('common.minute')}
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} {t('common.min')}
{formatFilterValue(displayRange[1])} {t('common.minute')}
</span>
</div>
{filterImpact != null && filterImpact > 0 && (

View file

@ -294,7 +294,7 @@ export function ActiveFilterList({
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
className="sticky top-0 z-10 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
</CollapsibleGroupHeader>

View file

@ -110,7 +110,7 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-l-4 border-teal-300 border-l-teal-600 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:border-l-teal-300 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">

View file

@ -110,7 +110,7 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-l-4 border-teal-300 border-l-teal-600 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:border-l-teal-300 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
@ -122,8 +122,8 @@ export function AddFilterPanel({
</button>
{(!collapsed || !isLicensed) && (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{!collapsed && (
{!collapsed && (
<div className="min-h-0 flex-1 overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={allFeatures}
@ -136,37 +136,37 @@ export function AddFilterPanel({
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onAddTravelTimeEntry}
/>
)}
{!isLicensed && (
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
{t('filters.upgradePrompt')}
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
{t('filters.oneTimeLifetime')}
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
{t('filters.upgradeToFullMap')}
</button>
<svg
viewBox="0 120 1600 230"
className="w-full mt-4 block shrink-0"
preserveAspectRatio="xMidYMax meet"
>
<path
d="M0,350 C400,150 1200,150 1600,350 Z"
className="fill-green-500 dark:fill-green-600"
/>
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)}
</div>
</div>
)}
{!isLicensed && (
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
{t('filters.upgradePrompt')}
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
{t('filters.oneTimeLifetime')}
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
{t('filters.upgradeToFullMap')}
</button>
<svg
viewBox="0 120 1600 230"
className="w-full mt-4 block shrink-0"
preserveAspectRatio="xMidYMax meet"
>
<path
d="M0,350 C400,150 1200,150 1600,350 Z"
className="fill-green-500 dark:fill-green-600"
/>
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)}
</div>
)}
</div>

View file

@ -1,4 +1,4 @@
import { useEffect, type FormEvent } from 'react';
import { useEffect, useRef, type FormEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
@ -30,6 +30,8 @@ export function ClearFiltersDialog({
}: ClearFiltersDialogProps) {
const { t } = useTranslation();
const isEditing = !!editingSearchName && !!onUpdateAndClear;
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
@ -40,17 +42,41 @@ export function ClearFiltersDialog({
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
'input, button, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'
);
(firstFocusable ?? dialogRef.current)?.focus();
return () => {
previouslyFocusedRef.current?.focus?.();
};
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
role="presentation"
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="clear-filters-dialog-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
<h2
id="clear-filters-dialog-title"
className="text-lg font-semibold text-navy-950 dark:text-white"
>
{t('filters.clearAllTitle')}
</h2>
<button
@ -67,9 +93,7 @@ export function ClearFiltersDialog({
i18nKey="filters.clearAllUpdatePrompt"
values={{ name: editingSearchName }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
}}
/>
</p>

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
import { useModalA11y } from '../../hooks/useModalA11y';
type View = 'login' | 'register' | 'forgot';
@ -34,11 +35,20 @@ export default function AuthModal({
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [resetSent, setResetSent] = useState(false);
const dialogRef = useModalA11y();
useEffect(() => {
trackEvent('Auth Modal Open', { tab: initialTab });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const switchView = useCallback(
(newView: View) => {
setView(newView);
@ -97,14 +107,26 @@ export default function AuthModal({
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="presentation"
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700">
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="auth-modal-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
<h2 id="auth-modal-title" className="text-lg font-semibold text-navy-950 dark:text-white">
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />

View file

@ -26,6 +26,7 @@ export function FeatureLabel({
}: FeatureLabelProps) {
const { t } = useTranslation();
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const gapClass = size === 'sm' ? 'gap-2' : 'gap-1';
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);
@ -56,7 +57,7 @@ export function FeatureLabel({
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} ${gapClass} min-w-0 ${className}`}
>
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}

View file

@ -3,10 +3,7 @@ interface IndeterminateProgressBarProps {
className?: string;
}
export function IndeterminateProgressBar({
show,
className = '',
}: IndeterminateProgressBarProps) {
export function IndeterminateProgressBar({ show, className = '' }: IndeterminateProgressBarProps) {
if (!show) return null;
return (

View file

@ -1,5 +1,7 @@
import { useRef, useCallback, type ReactNode } from 'react';
import { useCallback, useEffect, useId, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useClickOutside } from '../../hooks/useClickOutside';
import { useModalA11y } from '../../hooks/useModalA11y';
import { CloseIcon } from './icons';
import { IconButton } from './IconButton';
@ -11,7 +13,8 @@ interface InfoPopupProps {
}
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
const popupRef = useRef<HTMLDivElement>(null);
const popupRef = useModalA11y();
const titleId = useId();
const handleClose = useCallback(() => {
onClose();
@ -19,14 +22,31 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
useClickOutside(popupRef, handleClose);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const popup = (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70"
role="presentation"
>
<div
ref={popupRef}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5 outline-none"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
<h3 id={titleId} className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
{title}
</h3>
<IconButton onClick={onClose} className="shrink-0">
<CloseIcon />
</IconButton>
@ -43,4 +63,8 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
</div>
</div>
);
if (typeof document === 'undefined') return popup;
return createPortal(popup, document.body);
}

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { useModalA11y } from '../../hooks/useModalA11y';
interface LicenseSuccessModalProps {
onClose: () => void;
@ -14,6 +15,7 @@ export default function LicenseSuccessModal({
const { t } = useTranslation();
const isSuccess = status === 'success';
const isVerifying = status === 'verifying';
const dialogRef = useModalA11y();
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
@ -36,6 +38,14 @@ export default function LicenseSuccessModal({
return () => clearTimeout(timer);
}, [isSuccess, onClose]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const title =
status === 'verifying'
? t('licenseSuccess.verifyingTitle')
@ -56,9 +66,12 @@ export default function LicenseSuccessModal({
: t('licenseSuccess.description');
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
role="presentation"
>
{isSuccess && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
{particles.map((p) => (
<div
key={p.id}
@ -78,7 +91,14 @@ export default function LicenseSuccessModal({
</div>
)}
<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
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="license-success-modal-title"
tabIndex={-1}
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 outline-none"
>
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
<div className="h-14 mb-3 flex items-center justify-center">
{isVerifying ? (
@ -87,7 +107,9 @@ export default function LicenseSuccessModal({
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
)}
</div>
<h2 className="text-2xl font-bold text-white">{title}</h2>
<h2 id="license-success-modal-title" className="text-2xl font-bold text-white">
{title}
</h2>
<p className="text-warm-300 text-sm mt-2">{subtitle}</p>
</div>
<div className="px-6 py-6">

View file

@ -160,7 +160,7 @@ export default function MobileMenu({
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
{/* Menu panel */}
<div className="mobile-menu-panel fixed top-0 right-0 bottom-0 w-64 bg-navy-900 text-white z-[80] flex flex-col shadow-xl">
<div className="flex items-center justify-between px-3 h-11 border-b border-navy-700">
<div className="flex items-center justify-between px-3 h-12 border-b border-navy-700">
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button
onClick={onClose}

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CheckIcon } from './icons/CheckIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { useModalA11y } from '../../hooks/useModalA11y';
export default function SaveSearchModal({
onClose,
@ -20,6 +21,7 @@ export default function SaveSearchModal({
const { t } = useTranslation();
const [name, setName] = useState('');
const [saved, setSaved] = useState(false);
const dialogRef = useModalA11y();
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@ -44,18 +46,32 @@ export default function SaveSearchModal({
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
role="presentation"
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="save-search-modal-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
<h2
id="save-search-modal-title"
className="text-lg font-semibold text-navy-950 dark:text-white"
>
{saved ? t('saveSearch.saved') : t('saveSearch.title')}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { apiUrl, logNonAbortError } from '../../lib/api';
import { useModalA11y } from '../../hooks/useModalA11y';
interface UpgradeModalProps {
isLoggedIn: boolean;
@ -28,6 +29,7 @@ export default function UpgradeModal({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pricePence, setPricePence] = useState<number | null>(null);
const dialogRef = useModalA11y();
useEffect(() => {
fetch(apiUrl('pricing'))
@ -38,6 +40,14 @@ export default function UpgradeModal({
.catch((err) => logNonAbortError('Failed to fetch pricing', err));
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onZoomToFreeZone();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onZoomToFreeZone]);
const priceLabel =
pricePence === null
? '...'
@ -59,11 +69,23 @@ export default function UpgradeModal({
};
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="upgrade-modal-title"
tabIndex={-1}
className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden outline-none"
>
{/* Close button */}
<button
type="button"
onClick={onZoomToFreeZone}
aria-label={t('common.close')}
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
@ -71,7 +93,9 @@ export default function UpgradeModal({
{/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
{t('upgrade.title')}
</h2>
<p className="text-warm-300 text-sm">
{isShareReturn ? t('upgrade.sharedAreaDescription') : t('upgrade.description')}
</p>

View file

@ -4,9 +4,16 @@ import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
export function useActualListings(bounds: Bounds | null) {
interface UseActualListingsOptions {
filterParam?: string;
travelParam?: string;
}
export function useActualListings(
bounds: Bounds | null,
{ filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
) {
const [listings, setListings] = useState<ActualListing[]>([]);
const [truncated, setTruncated] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
@ -18,7 +25,6 @@ export function useActualListings(bounds: Bounds | null) {
if (!bounds) {
abortControllerRef.current?.abort();
if (listings.length !== 0) setListings([]);
if (truncated) setTruncated(false);
return;
}
@ -30,6 +36,8 @@ export function useActualListings(bounds: Bounds | null) {
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ bounds: boundsStr });
if (filterParam) params.set('filters', filterParam);
if (travelParam) params.set('travel', travelParam);
const res = await fetch(
apiUrl('actual-listings', params),
authHeaders({ signal: abortControllerRef.current.signal })
@ -38,7 +46,6 @@ export function useActualListings(bounds: Bounds | null) {
const json: ActualListingsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setListings(json.listings || []);
setTruncated(Boolean(json.truncated));
} catch (err) {
logNonAbortError('Failed to fetch actual listings', err);
}
@ -48,9 +55,9 @@ export function useActualListings(bounds: Bounds | null) {
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// listings/truncated intentionally excluded — they're internal state, not inputs.
// listings intentionally excluded — it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds]);
}, [bounds, filterParam, travelParam]);
return { listings, truncated };
return { listings };
}

View file

@ -111,7 +111,6 @@ export function useDeckLayers({
isDark,
hexagonData: data,
postcodeData,
resolution: usePostcodeView ? 0 : Math.round(zoom),
usePostcodeView,
});
@ -280,21 +279,33 @@ export function useDeckLayers({
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
// Per-render memo: each of getRatios0/1/2 would otherwise call distToRatios
// on the same row, tripling the work. Cache by row reference.
const ratiosCache = new WeakMap<HexagonData, number[]>();
const getRatios = (d: HexagonData): number[] => {
let r = ratiosCache.get(d);
if (!r) {
r = distToRatios(d[distKey]);
ratiosCache.set(d, r);
}
return r;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum
? {
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[8], r[9]];
},
updateTriggers: {

View file

@ -1,12 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
import {
apiUrl,
buildFilterString,
logNonAbortError,
authHeaders,
isAbortError,
} from '../lib/api';
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import type { TravelTimeEntry } from './useTravelTime';
import { buildTravelParam } from '../lib/travel-params';

View file

@ -206,7 +206,6 @@ export function useHexagonSelection({
const params = new URLSearchParams({
h3,
resolution: res.toString(),
limit: '100',
offset: offset.toString(),
});
@ -250,7 +249,6 @@ export function useHexagonSelection({
try {
const params = new URLSearchParams({
postcode,
limit: '100',
offset: offset.toString(),
});
if (focusAddress && offset === 0) {

View file

@ -45,31 +45,46 @@ export function useListingLayers({
}: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
if (usePostcodeView) {
const allowed = new Set<string>();
for (const feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
}
}
if (allowed.size === 0) return [];
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
}
// Split into two memos so the inactive view's data changes don't invalidate
// the active filtered list. (e.g. in postcode view, hexagonData updates must
// not retrigger filtering / downstream layer rebuilds.)
const postcodeFilteredListings = useMemo(() => {
if (!usePostcodeView || listings.length === 0) return null;
const allowed = new Set<string>();
for (const cell of hexagonData) {
if (cell.count > 0) allowed.add(cell.h3);
for (const feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
}
}
if (allowed.size === 0) return [];
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
}, [listings, postcodeData, usePostcodeView]);
const hexFilteredListings = useMemo(() => {
if (usePostcodeView || listings.length === 0) return null;
const allowed = new Set<string>();
let cellResolution: number | null = null;
for (const cell of hexagonData) {
if (cell.count > 0) {
allowed.add(cell.h3);
if (cellResolution == null) cellResolution = getResolution(cell.h3);
}
}
if (allowed.size === 0 || cellResolution == null) return [];
const resolutionForLookup = cellResolution;
return listings.filter((listing) => {
try {
return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
return allowed.has(latLngToCell(listing.lat, listing.lon, resolutionForLookup));
} catch {
return false;
}
});
}, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
}, [listings, hexagonData, usePostcodeView]);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? [];
}, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]);
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {

View file

@ -127,7 +127,7 @@ export function useLocationSearch(mode?: string) {
const controller = new AbortController();
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed, limit: '20' });
const params = new URLSearchParams({ q: trimmed });
if (mode) params.set('mode', mode);
const res = await fetch(
`/api/places?${params}`,

View file

@ -26,8 +26,8 @@ import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/
import { type TravelTimeEntry } from './useTravelTime';
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
/** Return the p-th percentile (0100) from a sorted typed array via linear interpolation. */
function percentile(sorted: Float64Array, p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = (p / 100) * (sorted.length - 1);
@ -262,10 +262,20 @@ export function useMapData({
useEffect(() => {
if (!activeFeature || !activeDragRequest) return;
// Abort any in-flight previous drag fetch before starting a new one.
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
// Capture the controller locally so this effect's cleanup unambiguously
// aborts THIS request's controller, even if `dragAbortRef.current` has
// been swapped by a subsequent effect run.
const controller = new AbortController();
dragAbortRef.current = controller;
const { signal } = controller;
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
// Capture activeFeature in a local so the async .then() callback cannot
// observe a stale-or-newer value via closure surprise.
const effectActiveFeature = activeFeature;
latestDragRequestKeyRef.current = requestKey;
setDragDataKey('');
dragFeatureRef.current = null;
@ -278,14 +288,15 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
fetch(apiUrl('postcodes', params), authHeaders({ signal }))
.then((res) => res.json())
.then((json: { features: PostcodeFeature[] }) => {
if (signal.aborted) return;
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragPostcodeData(json.features);
setDragHexData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
dragFeatureRef.current = effectActiveFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
} else {
@ -299,31 +310,36 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
fetch(apiUrl('hexagons', params), authHeaders({ signal }))
.then((res) => res.json())
.then((json: ApiResponse) => {
if (signal.aborted) return;
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragHexData(json.features);
setDragPostcodeData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
dragFeatureRef.current = effectActiveFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
}
return () => {
if (dragAbortRef.current) {
dragAbortRef.current.abort();
// Abort the controller captured by THIS effect run rather than reading
// from the ref (which may already have been replaced by a newer run).
controller.abort();
if (dragAbortRef.current === controller) {
dragAbortRef.current = null;
}
if (latestDragRequestKeyRef.current === requestKey) {
latestDragRequestKeyRef.current = '';
}
// Do not clear latestDragRequestKeyRef here: a newer effect run will
// overwrite it with its own requestKey, and clearing it would create a
// brief window in which a late-resolving fetch from this run could pass
// the staleness check against an empty key.
};
}, [
activeFeature,
activeDragRequest,
dataViewFeature,
resolution,
usePostcodeView,
viewFeatureIsEnum,
shareCode,
@ -538,10 +554,14 @@ export function useMapData({
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
// Typed-array sort uses the engine's optimized numeric sort with no
// per-element comparator call — measurably faster than `vals.sort((a,b)=>a-b)`
// for the 5k10k samples a busy viewport produces.
const sorted = Float64Array.from(vals);
sorted.sort();
return [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
percentile(sorted, COLOR_RANGE_LOW_PERCENTILE),
percentile(sorted, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [
bounds,

View file

@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react';
/**
* Shared modal accessibility behavior: locks body scroll, traps Tab focus
* inside the dialog, restores focus on unmount, and focuses the first
* focusable element (or the dialog itself) on mount.
*/
export function useModalA11y(): React.RefObject<HTMLDivElement | null> {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
const focusableSelector =
'input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
const firstFocusable = dialog?.querySelector<HTMLElement>(focusableSelector);
(firstFocusable ?? dialog)?.focus();
// Lock body scroll while preserving scroll position.
const prevOverflow = document.body.style.overflow;
const prevPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !dialog) return;
const focusables = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
if (focusables.length === 0) {
e.preventDefault();
dialog.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !dialog.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = prevOverflow;
document.body.style.paddingRight = prevPaddingRight;
previouslyFocused?.focus?.();
};
}, []);
return dialogRef;
}

View file

@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import type { NearbyStation, GeoPoint } from '../lib/nearby-stations';
import {
STATION_CATEGORIES,
selectNearbyStations,
stationSearchBounds,
} from '../lib/nearby-stations';
import type { POIResponse } from '../types';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../lib/api';
function boundsParam(bounds: ReturnType<typeof stationSearchBounds>): string {
return `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
}
export function useNearbyStations(origin: GeoPoint | null) {
const [stations, setStations] = useState<NearbyStation[]>([]);
const [loading, setLoading] = useState(false);
const lat = origin?.lat;
const lon = origin?.lon;
useEffect(() => {
if (lat == null || lon == null) {
setStations([]);
setLoading(false);
return;
}
const controller = new AbortController();
setStations([]);
setLoading(true);
const originPoint = { lat, lon };
const bounds = stationSearchBounds(originPoint);
const params = new URLSearchParams({
bounds: boundsParam(bounds),
categories: STATION_CATEGORIES.join(','),
});
fetch(apiUrl('pois', params), authHeaders({ signal: controller.signal }))
.then((response) => {
assertOk(response, 'nearby stations');
return response.json() as Promise<POIResponse>;
})
.then((json) => {
if (!controller.signal.aborted) {
setStations(selectNearbyStations(json.pois ?? [], originPoint));
}
})
.catch((error) => logNonAbortError('Failed to fetch nearby stations', error))
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [lat, lon]);
return { stations, loading };
}

View file

@ -44,7 +44,7 @@ function getPoiIconUrlForPoi(poi: POI): string {
}
function isBundledPoiIcon(url: string): boolean {
return url.startsWith('/assets/poi-icons/');
return url.startsWith('/assets/poi-icons/') || url.startsWith('data:image/svg+xml');
}
function hasBundledPoiLogo(poi: POI): boolean {

View file

@ -0,0 +1,80 @@
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import type { MutableRefObject } from 'react';
import { useRetainedScrollTop } from './useRetainedScrollTop';
function ScrollPane({
restoreKey,
savedScrollTopRef,
suspendSave,
}: {
restoreKey: string;
savedScrollTopRef: MutableRefObject<number>;
suspendSave: boolean;
}) {
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
restoreKey,
scrollTopRef: savedScrollTopRef,
suspendSave,
});
return (
<div ref={scrollRef} onScroll={onScroll} data-testid="pane">
<div style={{ height: 2000 }} />
</div>
);
}
describe('useRetainedScrollTop', () => {
afterEach(() => {
cleanup();
});
it('keeps the saved scroll offset while replacement content is loading', () => {
const savedScrollTopRef = { current: 0 };
const view = render(
<ScrollPane
restoreKey="area:a"
savedScrollTopRef={savedScrollTopRef}
suspendSave={false}
/>
);
const pane = view.getByTestId('pane');
pane.scrollTop = 360;
fireEvent.scroll(pane);
expect(savedScrollTopRef.current).toBe(360);
view.rerender(
<ScrollPane restoreKey="area:b" savedScrollTopRef={savedScrollTopRef} suspendSave />
);
pane.scrollTop = 0;
fireEvent.scroll(pane);
expect(savedScrollTopRef.current).toBe(360);
view.rerender(
<ScrollPane
restoreKey="area:b"
savedScrollTopRef={savedScrollTopRef}
suspendSave={false}
/>
);
expect(pane.scrollTop).toBe(360);
});
it('restores the saved offset when a pane remounts', () => {
const savedScrollTopRef = { current: 220 };
const view = render(
<ScrollPane
restoreKey="area:a"
savedScrollTopRef={savedScrollTopRef}
suspendSave={false}
/>
);
expect(view.getByTestId('pane').scrollTop).toBe(220);
});
});

View file

@ -0,0 +1,63 @@
import { useCallback, useLayoutEffect, useRef, type MutableRefObject, type UIEvent } from 'react';
interface UseRetainedScrollTopOptions {
restoreKey: string | null;
scrollTopRef?: MutableRefObject<number>;
suspendSave?: boolean;
}
export function useRetainedScrollTop<T extends HTMLElement>({
restoreKey,
scrollTopRef,
suspendSave = false,
}: UseRetainedScrollTopOptions) {
const fallbackScrollTopRef = useRef(0);
const savedScrollTopRef = scrollTopRef ?? fallbackScrollTopRef;
const nodeRef = useRef<T | null>(null);
const previousRestoreKeyRef = useRef(restoreKey);
const pendingRestoreTopRef = useRef<number | null>(null);
const suspendSaveRef = useRef(suspendSave);
suspendSaveRef.current = suspendSave;
const scrollRef = useCallback(
(node: T | null) => {
const previousNode = nodeRef.current;
if (!node && previousNode && !suspendSaveRef.current) {
savedScrollTopRef.current = previousNode.scrollTop;
}
nodeRef.current = node;
if (node) {
node.scrollTop = pendingRestoreTopRef.current ?? savedScrollTopRef.current;
}
},
[savedScrollTopRef]
);
const onScroll = useCallback(
(event: UIEvent<T>) => {
if (suspendSaveRef.current) return;
savedScrollTopRef.current = event.currentTarget.scrollTop;
},
[savedScrollTopRef]
);
useLayoutEffect(() => {
if (previousRestoreKeyRef.current !== restoreKey) {
previousRestoreKeyRef.current = restoreKey;
pendingRestoreTopRef.current = savedScrollTopRef.current;
}
const node = nodeRef.current;
const pendingRestoreTop = pendingRestoreTopRef.current;
if (!node || pendingRestoreTop == null) return;
node.scrollTop = pendingRestoreTop;
if (!suspendSave) {
pendingRestoreTopRef.current = null;
}
}, [restoreKey, savedScrollTopRef, suspendSave]);
return { scrollRef, onScroll };
}

View file

@ -12,8 +12,16 @@ export interface SavedSearch {
created: string;
}
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_ATTEMPTS = 15;
// Exponential backoff: 2s, 3s, 4s, 6s, 8s, 12s, ... capped at 15s.
// Caps total wait under a minute while staying responsive for fast jobs.
const POLL_BASE_MS = 2000;
const POLL_MAX_MS = 15000;
const POLL_BACKOFF = 1.5;
const MAX_POLL_ATTEMPTS = 8;
function nextPollDelay(attempt: number): number {
return Math.min(POLL_MAX_MS, Math.round(POLL_BASE_MS * Math.pow(POLL_BACKOFF, attempt)));
}
export function useSavedSearches(userId: string | null) {
const [searches, setSearches] = useState<SavedSearch[]>([]);
@ -21,14 +29,16 @@ export function useSavedSearches(userId: string | null) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAttemptsRef = useRef(0);
const pollInFlightRef = useRef(false);
const isMountedRef = useRef(true);
const userIdRef = useRef(userId);
userIdRef.current = userId;
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
pollAttemptsRef.current = 0;
@ -37,6 +47,15 @@ export function useSavedSearches(userId: string | null) {
// Clean up polling on unmount or userId change
useEffect(() => stopPolling, [userId, stopPolling]);
// Mark the hook as unmounted so late-arriving async work doesn't touch state
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
stopPolling();
};
}, [stopPolling]);
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
const records = await pb.collection('saved_searches').getFullList({
sort: '-created',
@ -57,28 +76,41 @@ export function useSavedSearches(userId: string | null) {
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
pollAttemptsRef.current = 0;
pollTimerRef.current = setInterval(async () => {
pollInFlightRef.current = false;
const scheduleNext = () => {
if (!isMountedRef.current) return;
const delay = nextPollDelay(pollAttemptsRef.current);
pollTimerRef.current = setTimeout(tick, delay);
};
const tick = async () => {
pollTimerRef.current = null;
if (pollInFlightRef.current) {
scheduleNext();
return;
}
const uid = userIdRef.current;
if (!uid) {
stopPolling();
return;
}
if (!uid) return;
pollAttemptsRef.current++;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
stopPolling();
return;
}
if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return;
pollInFlightRef.current = true;
try {
const mapped = await fetchRecords(uid);
if (!isMountedRef.current) return;
setSearches(mapped);
if (!mapped.some((s) => !s.screenshotUrl)) {
stopPolling();
}
if (!mapped.some((s) => !s.screenshotUrl)) return;
scheduleNext();
} catch {
// Silent — background poll errors don't surface to UI
// Silent — background poll errors don't surface to UI; keep trying.
if (isMountedRef.current) scheduleNext();
} finally {
pollInFlightRef.current = false;
}
}, POLL_INTERVAL_MS);
}, [stopPolling, fetchRecords]);
};
scheduleNext();
}, [fetchRecords]);
const fetchSearches = useCallback(async () => {
if (!userId) return;

View file

@ -25,21 +25,28 @@ const de: Translations = {
total: 'Gesamt',
min: 'Min.',
max: 'Max.',
minute: 'Min.',
or: 'oder',
area: 'Gebiet',
properties: 'Immobilien',
postcode: 'Postleitzahl',
noAreaSelected: 'Kein Gebiet ausgewählt',
noAreaSelectedDesc:
'Klicke auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
'Klicken Sie auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
clickForDetails: 'Für Details klicken',
property: 'Immobilie',
propertiesPlural: 'Immobilien',
bedsCount: '{{count}} Schlafzimmer',
bedsCount_other: '{{count}} Schlafzimmer',
bathsCount: '{{count}} Bad',
bathsCount_other: '{{count}} Bäder',
places: 'Orte',
noData: 'Keine Daten',
allLow: 'Alles niedrig',
connectingToServer: 'Verbindung zum Server...',
closePane: 'Bereich schließen',
yes: 'Ja',
no: 'Nein',
},
// ── Header / Nav ───────────────────────────────────
@ -310,8 +317,7 @@ const de: Translations = {
'Family trade-offs to compare': 'Familienkompromisse zum Vergleich',
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
'Kombinieren Sie Schulen mit Parks, Straßenlärm, Kriminalität, Wohnfläche, Pendelweg, Breitband und Preis, damit die Auswahlliste den gesamten Umzug widerspiegelt.',
'Does this show school catchment guarantees?':
'Zeigt dies garantierte Schul-Einzugsgebiete?',
'Does this show school catchment guarantees?': 'Zeigt dies garantierte Schul-Einzugsgebiete?',
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
'Nein. Es hilft dabei, vielversprechende Gebiete zu identifizieren, Einzugsgebiete und Zulassungen müssen jedoch bei der Schule oder der örtlichen Behörde überprüft werden.',
'Can I combine school filters with parks and safety?':
@ -480,8 +486,7 @@ const de: Translations = {
'Wie Konto- und gespeicherte Suchdaten im Produkt verarbeitet werden.',
'Compare Bristol postcodes': 'Vergleichen Sie die Postleitzahlen von Bristol',
'Trust and coverage': 'Vertrauen und Abdeckung',
'Perfect Postcode data sources and coverage':
'Perfect Postcode Datenquellen und Abdeckung',
'Perfect Postcode data sources and coverage': 'Perfect Postcode Datenquellen und Abdeckung',
'Perfect Postcode data sources - Property, schools, commute and local context':
'Perfect Postcode Datenquellen: Immobilien, Schulen, Pendelweg und lokaler Kontext',
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
@ -501,8 +506,7 @@ const de: Translations = {
'Travel-time data': 'Reisezeitdaten',
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
'Reisezeitfilter sind für einen konsistenten Gebietsvergleich konzipiert. Bevor Sie sich für ein Gebiet entscheiden, sollten Sie Routenverfügbarkeit, Störungen, Parkmöglichkeiten, Fußläufigkeit und Fahrplandetails überprüfen.',
'Why does coverage focus on England?':
'Warum konzentriert sich die Abdeckung auf England?',
'Why does coverage focus on England?': 'Warum konzentriert sich die Abdeckung auf England?',
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
'Mehrere zentrale Datensätze zu Immobilien, Bildung und lokalem Kontext sind jurisdiktionsspezifisch. Eine Abdeckung von England sorgt für konsistentere Vergleiche.',
'How should I handle stale or missing data?':
@ -588,15 +592,15 @@ const de: Translations = {
createAccount: 'Konto erstellen',
resetPassword: 'Passwort zurücksetzen',
valueProp:
'Speichere Suchen, merke dir Immobilien und erstelle eine Auswahlliste passender Gebiete.',
'Speichern Sie Suchen, merken Sie sich Immobilien und erstellen Sie eine Auswahlliste passender Gebiete.',
continueWithGoogle: 'Weiter mit Google',
email: 'E-Mail',
emailPlaceholder: 'du@beispiel.de',
emailPlaceholder: 'name@beispiel.de',
password: 'Passwort',
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
passwordPlaceholderLogin: 'Dein Passwort',
passwordPlaceholderLogin: 'Ihr Passwort',
forgotPassword: 'Passwort vergessen?',
resetSent: 'Prüfe deine E-Mails für einen Link zum Zurücksetzen.',
resetSent: 'Prüfen Sie Ihre E-Mails für einen Link zum Zurücksetzen.',
pleaseWait: 'Bitte warten...',
sendResetLink: 'Link zum Zurücksetzen senden',
backToLogin: 'Zurück zur Anmeldung',
@ -606,7 +610,7 @@ const de: Translations = {
upgrade: {
title: 'Jede passende Postleitzahl finden',
description:
'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
'Sie erkunden gerade das Demogebiet. Erhalten Sie lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
free: 'Kostenlos',
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
@ -618,7 +622,7 @@ const de: Translations = {
continueWithDemo: 'Mit Demo fortfahren',
backToSharedArea: 'Zurück zum geteilten Gebiet',
sharedAreaDescription:
'Du siehst ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichere dir lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
'Sie sehen ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichern Sie sich lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
},
@ -626,7 +630,7 @@ const de: Translations = {
saveSearch: {
title: 'Suche speichern',
saved: 'Suche gespeichert',
savedSuccess: 'Deine Suche wurde erfolgreich gespeichert.',
savedSuccess: 'Ihre Suche wurde erfolgreich gespeichert.',
viewSavedSearches: 'Gespeicherte Suchen ansehen',
name: 'Name',
namePlaceholder: 'Meine Suche',
@ -636,15 +640,15 @@ const de: Translations = {
// ── License Success ────────────────────────────────
licenseSuccess: {
verifyingTitle: 'Zugang wird geprüft',
verifyingSubtitle: 'Wir prüfen dein Konto, bevor wir die Karte freischalten.',
verifyingSubtitle: 'Wir prüfen Ihr Konto, bevor wir die Karte freischalten.',
verifyingDescription: 'Das dauert nach dem Bezahlen normalerweise nur ein paar Sekunden.',
activationDelayedTitle: 'Zahlung erhalten',
activationDelayedSubtitle: 'Der Zugang wird noch aktiviert.',
activationDelayedDescription:
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisiere gleich noch einmal oder kontaktiere den Support, falls der Zugang nicht erscheint.',
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisieren Sie gleich noch einmal oder kontaktieren Sie den Support, falls der Zugang nicht erscheint.',
stayOnPricing: 'Auf der Preisseite bleiben',
title: 'Du bist dabei.',
subtitle: 'Dein lebenslanger Zugang ist jetzt aktiv.',
title: 'Sie sind dabei.',
subtitle: 'Ihr lebenslanger Zugang ist jetzt aktiv.',
description: 'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
startExploring: 'Jetzt entdecken',
},
@ -655,18 +659,18 @@ const de: Translations = {
addFilter: 'Filter hinzufügen',
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
addFiltersHint:
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
'Fügen Sie unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die Ihren Kriterien entsprechen',
upgradePrompt:
'Finde passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
'Finden Sie passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
upgradeToFullMap: 'Zur Vollversion upgraden',
chooseFilters:
'Klicke auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
'Klicken Sie auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
searchFeatures: 'Filter durchsuchen...',
noMatchingFeatures: 'Keine passenden Filter',
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
tryDifferentSearch: 'Versuchen Sie einen anderen Suchbegriff',
allFeaturesActive: 'Alle Filter sind aktiv',
removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
removeFilterHint: 'Entfernen Sie einen Filter, um verfügbare Merkmale zu sehen',
featureInfo: 'Über diese Daten',
aboutData: 'Über diese Daten',
aboutDataShort: 'Info',
@ -679,7 +683,7 @@ const de: Translations = {
replayTutorial: 'Interaktives Tutorial erneut abspielen',
clearAll: 'Alle löschen',
clearAllTitle: 'Alle Filter löschen?',
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
clearAllSavePrompt: 'Möchten Sie Ihre aktuellen Filter vor dem Löschen speichern?',
clearAllUpdatePrompt:
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
saveAndClear: 'Speichern & löschen',
@ -700,12 +704,14 @@ const de: Translations = {
ethnicity: 'Ethnie',
poiType: 'POI-Typ',
party: 'Partei',
travelTimeKeywords:
'Reisezeit Fahrzeit Pendelzeit Pendeln Fahrt Reise Auto Fahrrad Rad Radfahren zu Fuß Gehen ÖPNV Verkehr Transport Bahnhof Bahn U-Bahn S-Bahn Zug Bus Straßenbahn öffentlich Route travel time journey commute car bicycle bike walking transit transport station train tube bus metro rail route',
},
// ── Philosophy Popup ───────────────────────────────
philosophy: {
intro:
'Beginne mit deinen Muss-Kriterien, dann füge Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn du Filter hinzufügst. Die verbleibenden Gebiete sind deine besten Treffer.',
'Beginnen Sie mit Ihren Muss-Kriterien, dann fügen Sie Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn Sie Filter hinzufügen. Die verbleibenden Gebiete sind Ihre besten Treffer.',
step1Title: 'Budget und Grundlagen',
step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
step2Title: 'Pendelweg',
@ -718,7 +724,7 @@ const de: Translations = {
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
step6Title: 'Energie',
step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
tip: 'Tipp: Wenn nichts passt, lockern Sie eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
},
// ── Travel Time ────────────────────────────────────
@ -755,14 +761,14 @@ const de: Translations = {
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
mainDesc: 'Zeigt die Reisezeit vom ausgewählten Ziel zu jedem Gebiet.',
sliderHint: 'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
sliderHint: 'Verwenden Sie den Schieberegler, um Ihre maximale Pendelzeit festzulegen.',
},
// ── AI Filter ──────────────────────────────────────
aiFilter: {
describeIdealArea: 'Beschreibe, wo du leben möchtest',
describeIdealArea: 'Beschreiben Sie, wo Sie leben möchten',
aiSearch: 'KI-Suche',
describeHint: 'beschreibe, wonach du suchst',
describeHint: 'beschreiben Sie, wonach Sie suchen',
placeholder: 'z. B. 2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhig...',
example1: '2 Schlafzimmer unter £525k, 45 Min. zur Arbeit',
example2: 'Familienfreundliche Gebiete nahe guten Schulen unter £650k',
@ -772,7 +778,7 @@ const de: Translations = {
generatingFilters: 'Filter werden generiert...',
refiningResults: 'Ergebnisse werden verfeinert...',
weeklyLimitReached:
'Du hast das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
'Sie haben das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
},
// ── Map Legend ─────────────────────────────────────
@ -836,6 +842,8 @@ const de: Translations = {
showAllStatsFallback:
'Wechseln Sie zu allen Immobilien, um dieses Gebiet ohne aktive Filter zu prüfen.',
showAllStats: 'Alle Immobilien anzeigen',
closestStations: 'Nächste Bahnhöfe',
noNearbyStations: 'Keine Bahn- oder U-Bahn-Station im Umkreis von 2 km',
closestBlockingFilters: 'Nächste Änderungen, um dieses Gebiet einzuschließen',
lowerMinTo: 'Minimum auf {{value}} senken',
raiseMaxTo: 'Maximum auf {{value}} erhöhen',
@ -1268,7 +1276,7 @@ const de: Translations = {
upgrade: 'Upgraden',
redirecting: 'Weiterleitung…',
receiveNewsletter: 'Newsletter-E-Mails erhalten',
needHelp: 'Brauchst du Hilfe? Schreib uns an',
needHelp: 'Brauchen Sie Hilfe? Schreiben Sie uns an',
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
shareLinksTitle: 'Geteilte Links',
noShareLinksYet: 'Noch keine geteilten Links',
@ -1281,12 +1289,12 @@ const de: Translations = {
searches: 'Suchen',
noSavedSearches: 'Noch keine gespeicherten Suchen',
noSavedSearchesDesc:
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
'Speichern Sie Ihre Filter und Kartenansicht, um genau dort weiterzumachen, wo Sie aufgehört haben.',
clickToRename: 'Klicken zum Umbenennen',
notesPlaceholder: 'Notiere deine Gedanken...',
notesPlaceholder: 'Notieren Sie Ihre Gedanken...',
deleteSearch: 'Suche löschen',
deleteSearchConfirm:
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
'Möchten Sie diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
updating: 'Aktualisiere...',
},
@ -1301,7 +1309,7 @@ const de: Translations = {
copyInviteLink: 'Einladungslink kopieren',
adminInvitesTitle: 'Admin-Einladungen (100% Rabatt)',
referralInvitesTitle: 'Empfehlungseinladungen (30% Rabatt)',
yourInviteLinks: 'Deine Einladungslinks',
yourInviteLinks: 'Ihre Einladungslinks',
noInvitesYet: 'Noch keine Einladungen erstellt',
link: 'Link',
status: 'Status',
@ -1312,13 +1320,13 @@ const de: Translations = {
// ── Invite Page ────────────────────────────────────
invitePage: {
youreInvited: 'Du bist eingeladen!',
youreInvited: 'Sie sind eingeladen!',
specialOffer: 'Sonderangebot!',
invitedByFree: '{{name}} hat dich eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
genericFreeInvite: 'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
exploreEvery: 'Finde Postleitzahlen, die zu deinem Leben passen',
invitedByFree: '{{name}} hat Sie eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
genericFreeInvite: 'Sie wurden eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
exploreEvery: 'Finden Sie Postleitzahlen, die zu Ihrem Leben passen',
propertyInfo: 'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
invalidInvite: 'Ungültige Einladung',
inviteAlreadyUsed: 'Einladung bereits verwendet',
@ -1326,13 +1334,13 @@ const de: Translations = {
invalidInviteLink: 'Ungültiger Einladungslink',
invalidInviteLinkDesc: 'Dieser Einladungslink ist ungültig oder abgelaufen.',
licenseActivated: 'Lizenz aktiviert!',
fullAccessGranted: 'Du hast jetzt vollen Zugang zu Perfect Postcode.',
fullAccessGranted: 'Sie haben jetzt vollen Zugang zu Perfect Postcode.',
activating: 'Wird aktiviert...',
activateLicense: 'Lizenz aktivieren',
claimDiscount: 'Rabatt einlösen',
registerToClaim: 'Registrieren zum Einlösen',
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
youAlreadyHaveLicense: 'Sie haben bereits eine Lizenz',
accountHasFullAccess: 'Ihr Konto hat bereits vollen Zugang.',
failedToValidate: 'Einladungslink konnte nicht validiert werden',
},

View file

@ -23,6 +23,7 @@ const en = {
total: 'Total',
min: 'min',
max: 'max',
minute: 'min',
or: 'or',
area: 'Area',
properties: 'Properties',
@ -33,11 +34,17 @@ const en = {
clickForDetails: 'Click for details',
property: 'property',
propertiesPlural: 'properties',
bedsCount: '{{count}} bed',
bedsCount_other: '{{count}} beds',
bathsCount: '{{count}} bath',
bathsCount_other: '{{count}} baths',
places: 'places',
noData: 'No data',
allLow: 'All low',
connectingToServer: 'Connecting to server...',
closePane: 'Close pane',
yes: 'Yes',
no: 'No',
},
// ── Header / Nav ───────────────────────────────────
@ -653,7 +660,8 @@ const en = {
clearAll: 'Clear all',
clearAllTitle: 'Clear all filters?',
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
clearAllUpdatePrompt: 'Update <strong>{{name}}</strong> with your current filters before clearing?',
clearAllUpdatePrompt:
'Update <strong>{{name}}</strong> with your current filters before clearing?',
saveAndClear: 'Save & Clear',
updateAndClear: 'Update & Clear',
clearWithoutSaving: 'Clear without saving',
@ -672,6 +680,8 @@ const en = {
ethnicity: 'Ethnicity',
poiType: 'POI type',
party: 'Party',
travelTimeKeywords:
'travel time journey commute car bicycle bike cycling walking walk transit transport public station tube train bus metro subway underground rail route',
},
// ── Philosophy Popup ───────────────────────────────
@ -806,6 +816,8 @@ const en = {
showAllStatsFallback:
'Switch to all properties to inspect this area without the active filters.',
showAllStats: 'Show all properties',
closestStations: 'Closest stations',
noNearbyStations: 'No train or tube stations within 2km',
closestBlockingFilters: 'Closest changes to include this area',
lowerMinTo: 'Lower minimum to {{value}}',
raiseMaxTo: 'Raise maximum to {{value}}',

View file

@ -25,6 +25,7 @@ const fr: Translations = {
total: 'Total',
min: 'min',
max: 'max',
minute: 'min',
or: 'ou',
area: 'Zone',
properties: 'Propriétés',
@ -35,11 +36,17 @@ const fr: Translations = {
clickForDetails: 'Cliquez pour les détails',
property: 'propriété',
propertiesPlural: 'propriétés',
bedsCount: '{{count}} ch.',
bedsCount_other: '{{count}} ch.',
bathsCount: '{{count}} sdb',
bathsCount_other: '{{count}} sdb',
places: 'lieux',
noData: 'Aucune donnée',
allLow: 'Tout est faible',
connectingToServer: 'Connexion au serveur...',
closePane: 'Fermer le panneau',
yes: 'Oui',
no: 'Non',
},
// ── Header / Nav ───────────────────────────────────
@ -318,7 +325,7 @@ const fr: Translations = {
"Oui. La recherche adaptée à l'école peut être combinée avec la criminalité, les parcs, les déplacements domicile-travail, le prix, la taille de la propriété et les services locaux.",
'Is Ofsted the only school signal?': 'Ofsted est-il le seul signal scolaire ?',
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
"Aucun score isolé ne devrait décider dun déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur lécole.",
'Aucun score isolé ne devrait décider dun déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur lécole.',
'See where education, property, transport, and environment data comes from.':
"Découvrez d'où proviennent les données sur l'éducation, l'immobilier, les transports et l'environnement.",
'Explore school-aware searches': "Explorez les recherches adaptées à l'école",
@ -337,7 +344,7 @@ const fr: Translations = {
'Compare postcodes consistently across England.':
'Comparez les codes postaux de manière cohérente dans toute lAngleterre.',
'Check the street before spending a viewing slot':
"Vérifiez la rue avant dy consacrer un créneau de visite",
'Vérifiez la rue avant dy consacrer un créneau de visite',
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
"Utilisez le vérificateur de code postal pour examiner l'historique des prix, le contexte local, les commodités, les écoles et les signaux environnementaux avant de consacrer du temps à votre visite.",
'Compare neighbouring postcodes': 'Comparez les codes postaux voisins',
@ -702,6 +709,8 @@ const fr: Translations = {
ethnicity: 'Origine ethnique',
poiType: 'Type de POI',
party: 'Parti',
travelTimeKeywords:
'temps trajet déplacement navette domicile-travail voiture vélo bicyclette cyclisme marche à pied piéton transports en commun public station gare train métro tramway bus RER itinéraire route travel time journey commute car bicycle bike walking transit transport station tube train',
},
// ── Philosophy Popup ───────────────────────────────
@ -839,12 +848,13 @@ const fr: Translations = {
showAllStatsFallback:
'Passez à toutes les propriétés pour inspecter cette zone sans les filtres actifs.',
showAllStats: 'Afficher toutes les propriétés',
closestStations: 'Stations les plus proches',
noNearbyStations: 'Aucune gare ou station de métro à moins de 2 km',
closestBlockingFilters: 'Modifications les plus proches pour inclure cette zone',
lowerMinTo: 'Abaisser le minimum à {{value}}',
raiseMaxTo: 'Augmenter le maximum à {{value}}',
allowCategory: 'Autoriser {{value}}',
missingFilterValue:
'Aucune valeur pour ce filtre ; supprimez-le',
missingFilterValue: 'Aucune valeur pour ce filtre ; supprimez-le',
noFilterDataShort: 'Aucune donnée',
travelTo: 'Trajet vers {{destination}}',
viewProperties: 'Voir {{count}} propriétés',
@ -1297,7 +1307,8 @@ const fr: Translations = {
// ── Invites Page ───────────────────────────────────
invitesPage: {
inviteLinksLicensed: 'Les liens dinvitation sont disponibles pour les utilisateurs sous licence.',
inviteLinksLicensed:
'Les liens dinvitation sont disponibles pour les utilisateurs sous licence.',
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
generateFreeInvite: 'Générer un lien dinvitation gratuit',

View file

@ -22,8 +22,9 @@ const hi: Translations = {
none: 'कोई नहीं',
viewDataSource: 'डेटा स्रोत देखें',
total: 'कुल',
min: 'िनट',
min: 'न्यूनतम',
max: 'अधिकतम',
minute: 'मिनट',
or: 'या',
area: 'क्षेत्र',
properties: 'संपत्तियां',
@ -34,11 +35,17 @@ const hi: Translations = {
clickForDetails: 'विवरण के लिए क्लिक करें',
property: 'संपत्ति',
propertiesPlural: 'संपत्तियां',
bedsCount: '{{count}} बेड',
bedsCount_other: '{{count}} बेड',
bathsCount: '{{count}} बाथ',
bathsCount_other: '{{count}} बाथ',
places: 'स्थान',
noData: 'कोई डेटा नहीं',
allLow: 'सभी कम',
connectingToServer: 'सर्वर से कनेक्ट हो रहा है...',
closePane: 'पैन बंद करें',
yes: 'हाँ',
no: 'नहीं',
},
header: {
@ -670,6 +677,8 @@ const hi: Translations = {
ethnicity: 'जातीय समूह',
poiType: 'POI प्रकार',
party: 'पार्टी',
travelTimeKeywords:
'यात्रा यात्रा समय सफर आवागमन कार गाड़ी साइकिल बाइक पैदल चलना सार्वजनिक परिवहन परिवहन यातायात स्टेशन ट्रेन रेल मेट्रो ट्यूब बस मार्ग travel time journey commute car bicycle bike walking transit transport station tube train',
},
philosophy: {
@ -797,6 +806,8 @@ const hi: Translations = {
showAllStatsFallback:
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStats: 'सभी संपत्तियां दिखाएं',
closestStations: 'निकटतम स्टेशन',
noNearbyStations: '2 किमी के भीतर कोई ट्रेन या ट्यूब स्टेशन नहीं',
closestBlockingFilters: 'इस क्षेत्र को शामिल करने के निकटतम बदलाव',
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',

View file

@ -23,8 +23,9 @@ const hu: Translations = {
none: 'Egyik sem',
viewDataSource: 'Adatforrás megtekintése',
total: 'Összesen',
min: 'perc',
min: 'min.',
max: 'max.',
minute: 'perc',
or: 'vagy',
area: 'Terület',
properties: 'Ingatlanok',
@ -35,11 +36,17 @@ const hu: Translations = {
clickForDetails: 'Kattints a részletekhez',
property: 'ingatlan',
propertiesPlural: 'ingatlanok',
bedsCount: '{{count}} hsz.',
bedsCount_other: '{{count}} hsz.',
bathsCount: '{{count}} fsz.',
bathsCount_other: '{{count}} fsz.',
places: 'helyek',
noData: 'Nincs adat',
allLow: 'Mind alacsony',
connectingToServer: 'Kapcsolódás a szerverhez...',
closePane: 'Panel bezárása',
yes: 'Igen',
no: 'Nem',
},
// ── Header / Nav ───────────────────────────────────
@ -446,7 +453,8 @@ const hu: Translations = {
'Make commute constraints explicit': 'Tegye egyértelművé az ingázási korlátozásokat',
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
'Ha fontos a központ, állomás, kórház, egyetem vagy üzleti park elérése, először használja az utazási idő szűrőit, majd hasonlítsa össze a fennmaradó irányítószámokat ingatlanadatok alapján.',
'Compare value, not just headline price': 'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
'Compare value, not just headline price':
'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
'Használja együtt az ár-, ingatlantípus- és alapterület-szűrőket. Ez segít megkülönböztetni az alacsonyabb költségű területeket azoktól a területektől, amelyek egyszerűen kisebb vagy eltérő otthonokat tartalmaznak.',
'Screen environmental and local-service signals':
@ -686,6 +694,8 @@ const hu: Translations = {
ethnicity: 'Etnikai csoport',
poiType: 'POI-típus',
party: 'Párt',
travelTimeKeywords:
'utazási idő utazás ingázás menetidő autó kocsi kerékpár bicikli biciklizés gyaloglás gyalog séta tömegközlekedés közlekedés közösségi közlekedés állomás vonat metró villamos busz HÉV útvonal travel time journey commute car bicycle bike walking transit transport station tube train',
},
// ── Philosophy Popup ───────────────────────────────
@ -820,6 +830,8 @@ const hu: Translations = {
showAllStatsFallback:
'Váltson az összes ingatlanra, hogy aktív szűrők nélkül tekintse át ezt a területet.',
showAllStats: 'Összes ingatlan mutatása',
closestStations: 'Legközelebbi állomások',
noNearbyStations: 'Nincs vonat- vagy metróállomás 2 km-en belül',
closestBlockingFilters: 'A terület bevonásához legközelebbi módosítások',
lowerMinTo: 'Minimum csökkentése erre: {{value}}',
raiseMaxTo: 'Maximum növelése erre: {{value}}',

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,17 @@ button:not(:disabled),
cursor: pointer;
}
.area-pane-group-header {
box-shadow: inset 0 -1px 0 #e7e5e4;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
html.dark .area-pane-group-header {
box-shadow: inset 0 -1px 0 #1e2d50;
}
/* Smooth theme transitions (scoped to avoid map performance issues) */
body,
div,

View file

@ -18,7 +18,9 @@ function AppErrorFallback() {
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
<div>
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">Refresh the page to try again.</p>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
Refresh the page to try again.
</p>
</div>
</div>
);

View file

@ -74,13 +74,13 @@ export function initBugsink(): boolean {
),
release:
nonempty(runtimeConfig.release) ??
readBuildTimeString(typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined),
readBuildTimeString(
typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined
),
sendDefaultPii:
runtimeConfig.sendDefaultPii ??
readBuildTimeBoolean(
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean'
? __BUGSINK_SEND_DEFAULT_PII__
: undefined
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean' ? __BUGSINK_SEND_DEFAULT_PII__ : undefined
),
tracesSampleRate: 0,
});

View file

@ -132,6 +132,7 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
export const POI_CATEGORY_LOGOS: Record<string, string> = {
Airport: '/assets/twemoji/2708.png',
Aldi: '/assets/poi-icons/logos/aldi.svg',
'Allendale Co-operative Society': '/assets/poi-icons/logos/coop.svg',
Amazon: '/assets/poi-icons/brands_2024/amazon_fresh.svg',
Asda: '/assets/poi-icons/logos/asda.svg',
'Asda Express': '/assets/poi-icons/logos/asda.svg',
@ -147,18 +148,26 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
'Bus stop': '/assets/twemoji/1f68f.png',
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
Centra: '/assets/poi-icons/logos/centra.svg',
'Central England Co-operative': '/assets/poi-icons/logos/coop.svg',
'Chelmsford Star Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'Clydebank Co-operative': '/assets/poi-icons/logos/coop.svg',
'Co-op': '/assets/poi-icons/logos/coop.svg',
'Coniston Co-operative Society': '/assets/poi-icons/logos/coop.svg',
COOK: '/assets/poi-icons/brands_2024/cook.svg',
'Convenience Store': '/assets/twemoji/1f3ea.png',
Costco: '/assets/poi-icons/logos/costco.svg',
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
'Dunnes Stores': '/assets/poi-icons/brands_2024/dunnes_stores.svg',
'East of England Co-operative': '/assets/poi-icons/logos/coop.svg',
Farmfoods: '/assets/poi-icons/brands_2023/supermarkets/farmfoods.svg',
Ferry: '/assets/twemoji/26f4.png',
Greengrocer: '/assets/twemoji/1f96c.png',
'Heart of England Co-operative': '/assets/poi-icons/logos/coop.svg',
'Heron Foods': '/assets/poi-icons/brands_2023/supermarkets/heron_foods.svg',
Iceland: '/assets/poi-icons/brands_2024/iceland.svg',
Lidl: '/assets/poi-icons/logos/lidl.svg',
'Langdale Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'Lincolnshire Co-operative': '/assets/poi-icons/logos/coop.svg',
Makro: '/assets/poi-icons/brands_2024/makro.svg',
'M&S': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Clothing': '/assets/poi-icons/brands_2024/mns.svg',
@ -166,6 +175,7 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
'M&S Hospital': '/assets/poi-icons/brands_2024/mns.svg',
'M&S MSA': '/assets/poi-icons/brands_2024/mns.svg',
'M&S Outlet': '/assets/poi-icons/brands_2024/mns.svg',
'Midcounties Co-operative': '/assets/poi-icons/logos/coop.svg',
Morrisons: '/assets/poi-icons/logos/morrisons.svg',
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
@ -173,12 +183,16 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
'Rail station': '/assets/twemoji/1f686.png',
"Sainsbury's": '/assets/poi-icons/logos/sainsburys.svg',
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
'Scottish Midland Co-operative': '/assets/poi-icons/logos/coop.svg',
Spar: '/assets/poi-icons/logos/spar.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
'Tamworth Co-operative Society': '/assets/poi-icons/logos/coop.svg',
Tesco: '/assets/poi-icons/logos/tesco.svg',
'Tesco Express': '/assets/poi-icons/logos/tesco_express.svg',
'Tesco Extra': '/assets/poi-icons/logos/tesco_extra.svg',
'Taxi rank': '/assets/twemoji/1f695.png',
'The Radstock Co-operative Society': '/assets/poi-icons/logos/coop.svg',
'The Southern Co-operative': '/assets/poi-icons/logos/coop.svg',
'The Food Warehouse': '/assets/poi-icons/logos/the_food_warehouse.png',
'Tube station': '/assets/poi-icons/public_transport/london_tube.svg',
Waitrose: '/assets/poi-icons/logos/waitrose.svg',

View file

@ -71,7 +71,7 @@ describe('map utilities', () => {
expect(enumIndexToColor(ENUM_PALETTE.length, ENUM_PALETTE)).toEqual(ENUM_PALETTE[0]);
});
it('resolves POI category logos and rejects unknown icon categories', () => {
it('resolves POI category logos and generates a fallback for unknown chains', () => {
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/logos/waitrose.svg');
expect(getPoiIconUrl('Iceland', '🛒', 'The Food Warehouse')).toBe(
'/assets/poi-icons/logos/the_food_warehouse.png'
@ -83,8 +83,8 @@ describe('map utilities', () => {
expect(getPoiIconUrl('M&S', '🛒', undefined, 'M&S Simply Food')).toBe(
'/assets/poi-icons/visuals/mns.svg'
);
expect(() => getPoiIconUrl('Unknown category', '🛒')).toThrow(
"Missing POI icon for category 'Unknown category'"
expect(getPoiIconUrl('Tian Tian', '🛒')).toMatch(
/^data:image\/svg\+xml;charset=utf-8,/
);
});

View file

@ -309,9 +309,67 @@ function inferPoiIconCategory(category: string, name?: string): string | undefin
}
}
export function getPoiIconUrl(
const GENERATED_POI_LOGO_COLORS: [string, string][] = [
['#0f766e', '#ccfbf1'],
['#1d4ed8', '#dbeafe'],
['#b45309', '#fef3c7'],
['#be123c', '#ffe4e6'],
['#6d28d9', '#ede9fe'],
['#047857', '#d1fae5'],
['#9d174d', '#fce7f3'],
['#334155', '#e2e8f0'],
];
const generatedPoiLogoCache = new Map<string, string>();
function hashLabel(label: string): number {
let hash = 0;
for (let i = 0; i < label.length; i += 1) {
hash = (hash * 31 + label.charCodeAt(i)) >>> 0;
}
return hash;
}
function escapeSvgText(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function getPoiLogoInitials(label: string): string {
const words = label.match(/[A-Za-z0-9]+/g) ?? [];
const significantWords = words.filter(
(word) => !['and', 'of', 'the'].includes(word.toLowerCase())
);
const selectedWords = significantWords.length > 0 ? significantWords : words;
if (selectedWords.length === 0) return 'POI';
if (selectedWords.length === 1) return selectedWords[0].slice(0, 3).toUpperCase();
return selectedWords
.slice(0, 3)
.map((word) => word[0])
.join('')
.toUpperCase();
}
function getGeneratedPoiLogoUrl(label: string): string {
const key = label.trim() || 'POI';
const cached = generatedPoiLogoCache.get(key);
if (cached) return cached;
const [background, foreground] = GENERATED_POI_LOGO_COLORS[
hashLabel(key) % GENERATED_POI_LOGO_COLORS.length
];
const initials = escapeSvgText(getPoiLogoInitials(key));
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" rx="48" fill="${background}"/><text x="128" y="144" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="82" font-weight="800" fill="${foreground}">${initials}</text></svg>`;
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
generatedPoiLogoCache.set(key, url);
return url;
}
export function getPoiCategoryLogoUrl(
category: string,
_emoji: string,
iconCategory?: string,
name?: string
): string {
@ -319,11 +377,16 @@ export function getPoiIconUrl(
if (resolvedIconCategory && POI_CATEGORY_LOGOS[resolvedIconCategory]) {
return POI_CATEGORY_LOGOS[resolvedIconCategory];
}
const categoryLogo = POI_CATEGORY_LOGOS[category];
if (!categoryLogo) {
throw new Error(`Missing POI icon for category '${category}'`);
}
return categoryLogo;
return POI_CATEGORY_LOGOS[category] ?? getGeneratedPoiLogoUrl(resolvedIconCategory || category);
}
export function getPoiIconUrl(
category: string,
_emoji: string,
iconCategory?: string,
name?: string
): string {
return getPoiCategoryLogoUrl(category, iconCategory, name);
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import type { POI } from '../types';
import {
formatStationDistance,
selectNearbyStations,
stationSearchBounds,
} from './nearby-stations';
function poi(name: string, lat: number, lng: number): POI {
return {
id: name,
name,
category: 'Rail station',
icon_category: 'Rail station',
group: 'Public Transport',
lat,
lng,
emoji: '',
};
}
describe('selectNearbyStations', () => {
const origin = { lat: 51.5, lon: -0.1 };
it('returns every station within 1km when any station is inside 1km', () => {
const stations = selectNearbyStations(
[poi('Close A', 51.501, -0.1), poi('Close B', 51.502, -0.1), poi('Fallback', 51.511, -0.1)],
origin
);
expect(stations.map((station) => station.name)).toEqual(['Close A', 'Close B']);
});
it('falls back to the nearest three stations within 2km when none are inside 1km', () => {
const stations = selectNearbyStations(
[
poi('Fourth', 51.516, -0.1),
poi('First', 51.51, -0.1),
poi('Outside', 51.53, -0.1),
poi('Third', 51.514, -0.1),
poi('Second', 51.512, -0.1),
],
origin
);
expect(stations.map((station) => station.name)).toEqual(['First', 'Second', 'Third']);
});
it('formats sub-kilometre and kilometre distances', () => {
expect(formatStationDistance(0.234)).toBe('234m');
expect(formatStationDistance(1.234)).toBe('1.2km');
});
});
describe('stationSearchBounds', () => {
it('builds a box around the origin', () => {
const bounds = stationSearchBounds({ lat: 51.5, lon: -0.1 });
expect(bounds.south).toBeLessThan(51.5);
expect(bounds.north).toBeGreaterThan(51.5);
expect(bounds.west).toBeLessThan(-0.1);
expect(bounds.east).toBeGreaterThan(-0.1);
});
});

View file

@ -0,0 +1,71 @@
import type { Bounds, POI } from '../types';
export const STATION_CATEGORIES = ['Rail station', 'Tube station'] as const;
export const STATION_SEARCH_RADIUS_KM = 2;
const PRIMARY_STATION_RADIUS_KM = 1;
const FALLBACK_STATION_LIMIT = 3;
const EARTH_RADIUS_KM = 6371;
const KM_PER_DEGREE_LAT = 111.32;
export interface NearbyStation extends POI {
distanceKm: number;
}
export interface GeoPoint {
lat: number;
lon: number;
}
function toRadians(value: number): number {
return (value * Math.PI) / 180;
}
export function distanceKm(a: GeoPoint, b: GeoPoint): number {
const dLat = toRadians(b.lat - a.lat);
const dLon = toRadians(b.lon - a.lon);
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(h));
}
export function stationSearchBounds(origin: GeoPoint, radiusKm = STATION_SEARCH_RADIUS_KM): Bounds {
const latDelta = radiusKm / KM_PER_DEGREE_LAT;
const cosLat = Math.max(Math.cos(toRadians(origin.lat)), 0.01);
const lonDelta = radiusKm / (KM_PER_DEGREE_LAT * cosLat);
return {
south: origin.lat - latDelta,
west: origin.lon - lonDelta,
north: origin.lat + latDelta,
east: origin.lon + lonDelta,
};
}
export function selectNearbyStations(pois: POI[], origin: GeoPoint): NearbyStation[] {
const stations = pois
.map((poi) => ({
...poi,
distanceKm: distanceKm(origin, { lat: poi.lat, lon: poi.lng }),
}))
.filter((poi) => poi.distanceKm <= STATION_SEARCH_RADIUS_KM)
.sort((a, b) => a.distanceKm - b.distanceKm || a.name.localeCompare(b.name));
const withinPrimaryRadius = stations.filter(
(station) => station.distanceKm <= PRIMARY_STATION_RADIUS_KM
);
return withinPrimaryRadius.length > 0
? withinPrimaryRadius
: stations.slice(0, FALLBACK_STATION_LIMIT);
}
export function formatStationDistance(distanceKmValue: number): string {
if (distanceKmValue < 1) {
return `${Math.round(distanceKmValue * 1000)}m`;
}
return `${distanceKmValue.toFixed(1)}km`;
}

View file

@ -135,7 +135,6 @@ export interface ActualListing {
export interface ActualListingsResponse {
listings: ActualListing[];
total: number;
truncated: boolean;
}
export interface POICategoryGroup {
@ -198,14 +197,19 @@ export interface Property {
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
}
export interface HexagonPropertiesResponse {
/** Shared paginated list of `Property` records returned by both
* `/api/hexagon-properties` and `/api/postcode-properties`. */
export interface PropertyListResponse {
properties: Property[];
total: number;
limit: number;
offset: number;
truncated: boolean;
}
/** @deprecated Use `PropertyListResponse`. Kept as an alias during the
* rollout so consumers can migrate without breaking. */
export type HexagonPropertiesResponse = PropertyListResponse;
export interface NumericFeatureStats {
name: string;
count: number;

View file

@ -4,6 +4,9 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const zlib = require('zlib');
const sharp = require('sharp');
const webpack = require('webpack');
const packageJson = require('./package.json');
@ -150,11 +153,49 @@ module.exports = (env, argv) => {
filename: '[name].[contenthash:8].css',
chunkFilename: '[name].[contenthash:8].css',
}),
new CompressionPlugin({
filename: '[path][base].gz',
algorithm: 'gzip',
test: /\.(js|css|html|svg|json|wasm)$/,
threshold: 1024,
minRatio: 0.8,
}),
new CompressionPlugin({
filename: '[path][base].br',
algorithm: 'brotliCompress',
test: /\.(js|css|html|svg|json|wasm)$/,
compressionOptions: {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
},
},
threshold: 1024,
minRatio: 0.8,
}),
]
: [new ReactRefreshWebpackPlugin()]),
],
optimization: isProduction
? {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
extractComments: false,
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
passes: 2,
},
format: {
comments: false,
},
keep_classnames: true,
keep_fnames: false,
},
}),
],
splitChunks: {
chunks: 'all',
cacheGroups: {

View file

@ -7,15 +7,19 @@ Reuses the same england-latest.osm.pbf as pois.py.
"""
import argparse
import logging
from pathlib import Path
import osmium
import polars as pl
from pyproj import Transformer
from shapely import wkb
from shapely.errors import GEOSException
from shapely.geometry import MultiPolygon, Polygon
from tqdm import tqdm
logger = logging.getLogger(__name__)
MIN_AREA_SQM = 5_000 # ~70m x 70m — skip pocket parks and small ponds
@ -68,6 +72,7 @@ class GreenspaceHandler(osmium.SimpleHandler):
self._wkb_factory = osmium.geom.WKBFactory()
self._progress = progress
self.geometries = []
self.skipped_areas = 0
def area(self, a):
self._progress.update(1)
@ -76,7 +81,14 @@ class GreenspaceHandler(osmium.SimpleHandler):
try:
wkb_data = self._wkb_factory.create_multipolygon(a)
geom = wkb.loads(wkb_data, hex=True)
except Exception:
except (RuntimeError, GEOSException, ValueError) as exc:
self.skipped_areas += 1
logger.warning(
"Failed to assemble multipolygon for area orig_id=%s (%s)",
getattr(a, "orig_id", lambda: "?")(),
type(exc).__name__,
exc_info=True,
)
return
if geom.is_empty or not geom.is_valid:
@ -113,6 +125,11 @@ def main():
print(
f"Found {len(handler.geometries)} greenspace/water polygons >= {MIN_AREA_SQM} sqm"
)
if handler.skipped_areas:
logger.warning(
"Skipped %d areas due to geometry assembly errors",
handler.skipped_areas,
)
# Merge overlapping geometries per 10km grid cell for efficiency
if handler.geometries:

View file

@ -14,6 +14,7 @@ License: Open Government Licence v3.0
"""
import argparse
import logging
import tempfile
from pathlib import Path
@ -21,10 +22,13 @@ import numpy as np
import polars as pl
import shapefile as shp
from pyproj import Transformer
from shapely.errors import GEOSException
from shapely.geometry import shape as to_shapely
from pipeline.utils.download import download, extract_zip
logger = logging.getLogger(__name__)
URL = "https://api.os.uk/downloads/v1/products/OpenGreenspace/downloads?area=GB&format=ESRI%C2%AE+Shapefile&redirect"
_to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True)
@ -76,6 +80,7 @@ def _read_access_points(
lngs: list[float] = []
categories: list[str] = []
skipped = 0
error_skipped = 0
for sr in reader.shapeRecords():
site_id = sr.record[ref_idx]
@ -89,7 +94,13 @@ def _read_access_points(
if geom.is_empty:
continue
lng, lat = _to_wgs84.transform(geom.x, geom.y)
except Exception:
except (GEOSException, ValueError, AttributeError, TypeError):
error_skipped += 1
logger.warning(
"Failed to process access point geometry for site_id=%s",
site_id,
exc_info=True,
)
continue
lats.append(lat)
@ -98,6 +109,11 @@ def _read_access_points(
if skipped:
print(f" Skipped {skipped:,} access points with unknown site ID")
if error_skipped:
logger.warning(
"Skipped %d access point records due to geometry/transform errors",
error_skipped,
)
return lats, lngs, categories
@ -116,6 +132,7 @@ def _read_site_centroids(
lats: list[float] = []
lngs: list[float] = []
categories: list[str] = []
error_skipped = 0
for sr in reader.shapeRecords():
site_id = sr.record[id_idx]
@ -129,13 +146,25 @@ def _read_site_centroids(
continue
centroid = geom.centroid
lng, lat = _to_wgs84.transform(centroid.x, centroid.y)
except Exception:
except (GEOSException, ValueError, AttributeError, TypeError):
error_skipped += 1
logger.warning(
"Failed to compute centroid for site_id=%s",
site_id,
exc_info=True,
)
continue
lats.append(lat)
lngs.append(lng)
categories.append(func)
if error_skipped:
logger.warning(
"Skipped %d site centroid records due to geometry/transform errors",
error_skipped,
)
return lats, lngs, categories

View file

@ -1,10 +1,12 @@
import argparse
import logging
from pathlib import Path
from tempfile import mkdtemp
import osmium
import polars as pl
from shapely import make_valid
from shapely.errors import GEOSException
from shapely.geometry import Point
from shapely.wkb import loads as load_wkb
from tqdm import tqdm
@ -17,6 +19,8 @@ from pipeline.utils.england_geometry import (
load_england_polygon,
)
logger = logging.getLogger(__name__)
BATCH_SIZE = 50_000
MIN_OCCURENCE_COUNT = 20
@ -57,6 +61,7 @@ class POIHandler(osmium.SimpleHandler):
self._tmp_dir = tmp_dir
self._batch_num = 0
self.poi_count = 0
self.skipped_areas = 0
self._progress = progress
self._england = england_polygon
self._wkb_factory = osmium.geom.WKBFactory()
@ -120,7 +125,14 @@ class POIHandler(osmium.SimpleHandler):
def _point_from_area(self, area: osmium.osm.Area) -> tuple[float, float] | None:
try:
geom = load_wkb(self._wkb_factory.create_multipolygon(area), hex=True)
except Exception:
except (RuntimeError, GEOSException, ValueError) as exc:
self.skipped_areas += 1
logger.warning(
"Failed to build multipolygon WKB for area orig_id=%s (%s)",
getattr(area, "orig_id", lambda: "?")(),
type(exc).__name__,
exc_info=True,
)
return None
return _representative_lat_lon(geom, self._england)
@ -185,6 +197,11 @@ def main() -> None:
handler._flush_batch() # write any remaining POIs
print(f"Extracted {handler.poi_count:,} POIs")
if handler.skipped_areas:
logger.warning(
"Skipped %d areas due to geometry assembly errors",
handler.skipped_areas,
)
batch_files = sorted(tmp_dir.glob("batch_*.parquet"))
df = pl.concat([pl.scan_parquet(f) for f in batch_files])

View file

@ -15,7 +15,7 @@ def test_transform_grocery_retail_points_outputs_chain_categories():
}
)
pois = transform_grocery_retail_points(raw)
pois = transform_grocery_retail_points(raw, min_chain_locations=1)
assert pois.select(
"id", "name", "category", "icon_category", "group", "emoji"
@ -69,7 +69,7 @@ def test_transform_grocery_retail_points_keeps_fascia_icon_category():
}
)
pois = transform_grocery_retail_points(raw)
pois = transform_grocery_retail_points(raw, min_chain_locations=1)
assert pois.select("category", "icon_category").to_dicts() == [
{"category": "Tesco", "icon_category": "Tesco Express"},
@ -96,7 +96,7 @@ def test_transform_grocery_retail_points_accepts_base_fascias():
}
)
pois = transform_grocery_retail_points(raw)
pois = transform_grocery_retail_points(raw, min_chain_locations=1)
assert pois.select("category", "icon_category").to_dicts() == [
{"category": "Aldi", "icon_category": "Aldi"},
@ -118,6 +118,29 @@ def test_transform_grocery_retail_points_drops_invalid_rows():
}
)
pois = transform_grocery_retail_points(raw)
pois = transform_grocery_retail_points(raw, min_chain_locations=1)
assert pois["category"].to_list() == ["Waitrose"]
def test_transform_grocery_retail_points_includes_unmapped_chains_with_five_locations():
raw = pl.DataFrame(
{
"id": list(range(1, 10)),
"retailer": ["Tian Tian"] * 5 + ["Corner Shop"] * 4,
"fascia": ["Tian Tian Market"] * 5 + ["Corner Shop"] * 4,
"store_name": [f"Store {i}" for i in range(1, 10)],
"long_wgs": [-0.1] * 9,
"lat_wgs": [51.5] * 9,
}
)
pois = transform_grocery_retail_points(raw)
assert pois.select("id", "category", "icon_category").to_dicts() == [
{"id": "glx-1", "category": "Tian Tian", "icon_category": "Tian Tian"},
{"id": "glx-2", "category": "Tian Tian", "icon_category": "Tian Tian"},
{"id": "glx-3", "category": "Tian Tian", "icon_category": "Tian Tian"},
{"id": "glx-4", "category": "Tian Tian", "icon_category": "Tian Tian"},
{"id": "glx-5", "category": "Tian Tian", "icon_category": "Tian Tian"},
]

View file

@ -5,7 +5,6 @@ import polars as pl
from pipeline.utils.england_geometry import in_england_mask
DROP_CATEGORIES = {
# Street furniture & infrastructure
"amenity/advice",
@ -1165,49 +1164,44 @@ COOP_RETAILERS = {
"The Southern Co-operative",
}
GROCERY_RETAILER_DISPLAY_NAMES: dict[str, str] = {
"Aldi": "Aldi",
"Asda": "Asda",
"Booths": "Booths",
"Budgens": "Budgens",
"Centra": "Centra",
MIN_GROCERY_CHAIN_LOCATIONS = 5
GROCERY_RETAILER_DISPLAY_NAME_OVERRIDES: dict[str, str] = {
"Cook": "COOK",
"Costco": "Costco",
"Dunnes Stores": "Dunnes Stores",
"Farmfoods": "Farmfoods",
"Heron": "Heron Foods",
"Iceland": "Iceland",
"Lidl": "Lidl",
"Makro": "Makro",
"Marks and Spencer": "M&S",
"Morrisons": "Morrisons",
"Planet Organic": "Planet Organic",
"Sainsburys": "Sainsbury's",
"Spar": "Spar",
"Tesco": "Tesco",
"Waitrose": "Waitrose",
"Whole Foods Market": "Whole Foods Market",
**{retailer: "Co-op" for retailer in COOP_RETAILERS},
"The Co-operative Group": "Co-op",
}
GROCERY_FASCIA_ICON_NAMES: dict[str, str] = {
**GROCERY_RETAILER_DISPLAY_NAMES,
"Aldi": "Aldi",
"Aldi Local": "Aldi",
"Asda": "Asda",
"Asda Express": "Asda Express",
"Asda Living": "Asda Living",
"Asda PFS": "Asda PFS",
"Asda PFS": "Asda",
"Asda Supercentre": "Asda Supercentre",
"Asda Supermarket": "Asda Supermarket",
"Asda Superstore": "Asda Superstore",
"Booths": "Booths",
"Budgens": "Budgens",
"Centra": "Centra",
"Cooltrader": "Heron Foods",
"Co-op Food": "Co-op",
"Cook": "COOK",
"Costco": "Costco",
"Dunnes Stores": "Dunnes Stores",
"Eurospar": "Spar",
"Eurospar PFS": "Spar",
"Farmfoods": "Farmfoods",
"Heron": "Heron Foods",
"Iceland": "Iceland",
"Lidl": "Lidl",
"Little Waitrose": "Little Waitrose",
"Little Waitrose Shell": "Little Waitrose",
"Makro": "Makro",
"Marks and Spencer": "M&S",
"Marks and Spencer BP": "M&S Food",
"Marks and Spencer Clothing": "M&S Clothing",
@ -1221,41 +1215,44 @@ GROCERY_FASCIA_ICON_NAMES: dict[str, str] = {
"Marks and Spencer Travel SF": "M&S Food",
"Morrisons Daily": "Morrisons Daily",
"Morrisons Select": "Morrisons",
"Planet Organic": "Planet Organic",
"Sainsbury's Local": "Sainsbury's Local",
"Sainsburys": "Sainsbury's",
"Sainsburys Local": "Sainsbury's Local",
"Spar": "Spar",
"Spar PFS": "Spar",
"Tesco": "Tesco",
"Tesco Express": "Tesco Express",
"Tesco Express Esso": "Tesco Express",
"Tesco Extra": "Tesco Extra",
"The Co-operative Food": "Co-op",
"The Co-operative Food PFS": "Co-op",
"The Food Warehouse": "The Food Warehouse",
"Waitrose": "Waitrose",
"Waitrose MSA": "Waitrose",
"Whole Foods Market": "Whole Foods Market",
}
def normalize_grocery_retailer(retailer: str | None) -> str:
if retailer is None:
return ""
display_name = GROCERY_RETAILER_DISPLAY_NAMES.get(retailer)
if display_name is None:
raise ValueError(f"Missing grocery retailer display name for {retailer!r}")
return display_name
retailer = retailer.strip()
return GROCERY_RETAILER_DISPLAY_NAME_OVERRIDES.get(retailer, retailer)
def normalize_grocery_icon_category(fascia: str | None, retailer: str | None) -> str:
if fascia:
icon_name = GROCERY_FASCIA_ICON_NAMES.get(fascia)
if icon_name is None:
raise ValueError(f"Missing grocery fascia icon name for {fascia!r}")
return icon_name
icon_name = GROCERY_FASCIA_ICON_NAMES.get(fascia.strip())
if icon_name is not None:
return icon_name
return normalize_grocery_retailer(retailer)
def transform_grocery_retail_points(
grocery_df: pl.DataFrame,
boundary_path: Path | None = None,
min_chain_locations: int = MIN_GROCERY_CHAIN_LOCATIONS,
) -> pl.DataFrame:
"""Convert GEOLYTIX Grocery Retail Points into the POI parquet schema."""
required = {"id", "retailer", "fascia", "store_name", "long_wgs", "lat_wgs"}
@ -1272,6 +1269,11 @@ def transform_grocery_retail_points(
pl.col("lat_wgs").cast(pl.Float64).alias("lat"),
pl.col("long_wgs").cast(pl.Float64).alias("lng"),
)
.with_columns(
pl.col("retailer").str.strip_chars(),
pl.col("fascia").str.strip_chars(),
pl.col("store_name").str.strip_chars(),
)
.drop_nulls(["id", "retailer", "lat", "lng"])
.filter(pl.col("retailer").str.len_chars() > 0)
)
@ -1284,6 +1286,14 @@ def transform_grocery_retail_points(
)
df = df.filter(pl.Series(mask))
eligible_retailers = (
df.group_by("retailer")
.len()
.filter(pl.col("len") >= min_chain_locations)
.select("retailer")
)
df = df.join(eligible_retailers, on="retailer", how="semi")
return df.with_columns(
pl.concat_str([pl.lit("glx-"), pl.col("id")]).alias("id"),
pl.coalesce(["store_name", "fascia", "retailer"])

2
property-data2/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -11,7 +11,7 @@ dependencies = [
"numpy>=1.26.0",
"pandas>=2.0.0",
"plotly>=6.5.2",
"polars>=1.37.1",
"polars>=1.37.1,<2.0.0",
"pyarrow>=15.0.0",
"tqdm>=4.67.1",
"fastexcel>=0.19.0",
@ -26,8 +26,6 @@ dependencies = [
"pillow>=12.0.0",
"folium>=0.20.0",
"pyogrio>=0.12.1",
"httpx",
"polars",
]
[tool.uv]

View file

@ -47,4 +47,7 @@ lto = "thin"
[profile.production]
inherits = "release"
lto = true
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"

View file

@ -0,0 +1,99 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use serde::Serialize;
/// Uniform API error type. Implements `IntoResponse` and serializes as JSON so
/// every endpoint returns a structurally-identical error body the frontend
/// can rely on, regardless of which route raised it.
#[derive(Debug, Clone)]
pub enum ApiError {
BadRequest(String),
Unauthorized,
Forbidden(String),
NotFound(String),
Conflict(String),
Internal(String),
BadGateway(String),
ServiceUnavailable(String),
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
message: String,
}
impl ApiError {
fn status(&self) -> StatusCode {
match self {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::BadGateway(_) => StatusCode::BAD_GATEWAY,
Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}
fn code(&self) -> &'static str {
match self {
Self::BadRequest(_) => "bad_request",
Self::Unauthorized => "unauthorized",
Self::Forbidden(_) => "forbidden",
Self::NotFound(_) => "not_found",
Self::Conflict(_) => "conflict",
Self::Internal(_) => "internal_error",
Self::BadGateway(_) => "upstream_error",
Self::ServiceUnavailable(_) => "service_unavailable",
}
}
fn message(&self) -> String {
match self {
Self::Unauthorized => "Authentication required".to_string(),
Self::BadRequest(m)
| Self::Forbidden(m)
| Self::NotFound(m)
| Self::Conflict(m)
| Self::Internal(m)
| Self::BadGateway(m)
| Self::ServiceUnavailable(m) => m.clone(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.status();
let body = ErrorBody {
error: self.code().to_string(),
message: self.message(),
};
(status, Json(body)).into_response()
}
}
/// Bridge from the legacy `(StatusCode, String)` tuples to the new error type
/// so partially-migrated routes keep compiling while the migration progresses.
impl From<(StatusCode, String)> for ApiError {
fn from((status, message): (StatusCode, String)) -> Self {
match status {
StatusCode::BAD_REQUEST => Self::BadRequest(message),
StatusCode::UNAUTHORIZED => Self::Unauthorized,
StatusCode::FORBIDDEN => Self::Forbidden(message),
StatusCode::NOT_FOUND => Self::NotFound(message),
StatusCode::CONFLICT => Self::Conflict(message),
StatusCode::BAD_GATEWAY => Self::BadGateway(message),
StatusCode::SERVICE_UNAVAILABLE => Self::ServiceUnavailable(message),
_ => Self::Internal(message),
}
}
}
impl From<String> for ApiError {
fn from(message: String) -> Self {
Self::Internal(message)
}
}

View file

@ -13,8 +13,10 @@ pub const GRID_CELL_SIZE: f32 = 0.01;
pub const MAX_CELLS_PER_REQUEST: usize = 200000;
pub const MAX_POIS_PER_REQUEST: usize = 3000;
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
pub const PROPERTIES_LIMIT: usize = 100;
pub const ACTUAL_LISTINGS_LIMIT: usize = 500;
pub const PLACES_LIMIT: usize = 20;
pub const PRICE_HISTORY_POINTS_LIMIT: usize = 5000;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;

View file

@ -268,6 +268,32 @@ fn extract_opt_datetime_iso(df: &DataFrame, name: &str) -> Result<Vec<Option<Str
.collect())
}
fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let list = column
.list()
.with_context(|| format!("Column '{name}' is not a list column"))?;
let mut out = Vec::with_capacity(list.len());
for series_opt in list.into_iter() {
let entries = match series_opt {
Some(series) => {
let strings = series.str().with_context(|| {
format!("Column '{name}' list inner is not a string column")
})?;
strings
.into_iter()
.filter_map(|value| value.map(ToString::to_string))
.collect()
}
None => Vec::new(),
};
out.push(entries);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@ -298,29 +324,3 @@ mod tests {
assert!(!any_listing.listing_url.is_empty());
}
}
fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let list = column
.list()
.with_context(|| format!("Column '{name}' is not a list column"))?;
let mut out = Vec::with_capacity(list.len());
for series_opt in list.into_iter() {
let entries = match series_opt {
Some(series) => {
let strings = series.str().with_context(|| {
format!("Column '{name}' list inner is not a string column")
})?;
strings
.into_iter()
.filter_map(|value| value.map(ToString::to_string))
.collect()
}
None => Vec::new(),
};
out.push(entries);
}
Ok(out)
}

View file

@ -331,7 +331,10 @@ impl PlaceData {
let lon = extract_f32_col(&df, "lon")?;
let population: Vec<u32> = if df.column("population").is_ok() {
let pop_f32 = extract_f32_col(&df, "population")?;
pop_f32.iter().map(|&val| val.max(0.0) as u32).collect()
pop_f32
.iter()
.map(|&val| val.max(0.0).min(u32::MAX as f32) as u32)
.collect()
} else {
vec![0; row_count]
};
@ -419,11 +422,11 @@ mod tests {
fn test_city_rows() -> [(&'static str, f32, f32, u32); 5] {
[
("London", 51.5074456, -0.1277653, 8_908_083),
("Westminster", 51.4973206, -0.137149, 211_365),
("City of London", 51.5156177, -0.0919983, 10_847),
("Cambridge", 52.2055314, 0.1186637, 145_818),
("Oxford", 51.7520131, -1.2578499, 165_000),
("London", 51.507_446, -0.1277653, 8_908_083),
("Westminster", 51.497_322, -0.137149, 211_365),
("City of London", 51.515_617, -0.0919983, 10_847),
("Cambridge", 52.205_532, 0.1186637, 145_818),
("Oxford", 51.752_014, -1.2578499, 165_000),
]
}
@ -503,7 +506,7 @@ mod tests {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(51.3713049, -0.101957, &cities),
nearest_display_city(51.371_304, -0.101957, &cities),
Some("London")
);
}
@ -513,7 +516,7 @@ mod tests {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(52.1277704, -0.0813098, &cities),
nearest_display_city(52.127_77, -0.0813098, &cities),
Some("Cambridge")
);
}

View file

@ -30,6 +30,16 @@ const GROCERY_DASHBOARD_CATEGORIES: &[&str] = &[
"Budgens",
"Centra",
"Co-op",
"Central England Co-operative",
"Chelmsford Star Co-operative Society",
"East of England Co-operative",
"Heart of England Co-operative",
"Lincolnshire Co-operative",
"Midcounties Co-operative",
"Scottish Midland Co-operative",
"Tamworth Co-operative Society",
"The Radstock Co-operative Society",
"The Southern Co-operative",
"COOK",
"Costco",
"Dunnes Stores",

View file

@ -1014,6 +1014,22 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
];
/// Feature names that describe an individual property (price, size, type, etc.) rather
/// than the surrounding area. Use this to skip filters that should not exclude live
/// listings on the map even though they hide aggregated property rows.
pub fn property_level_feature_names() -> Vec<&'static str> {
const PROPERTY_GROUPS: &[&str] = &["Properties", "Property prices"];
FEATURE_GROUPS
.iter()
.filter(|group| PROPERTY_GROUPS.contains(&group.name))
.flat_map(|group| group.features.iter())
.map(|feature| match feature {
Feature::Numeric(c) => c.name,
Feature::Enum(c) => c.name,
})
.collect()
}
/// Flat ordered list of all numeric feature names (follows group order).
pub fn all_numeric_feature_names() -> Vec<&'static str> {
FEATURE_GROUPS

View file

@ -1,6 +1,7 @@
#![allow(clippy::min_ident_chars)]
mod aggregation;
mod api_error;
mod auth;
mod bugsink;
mod checkout_sessions;

View file

@ -1,80 +1,230 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use rustc_hash::FxHashSet;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::api_error::ApiError;
use crate::consts::ACTUAL_LISTINGS_LIMIT;
use crate::data::ActualListing;
use crate::parsing::require_bounds;
use crate::state::SharedState;
use crate::features::property_level_feature_names;
use crate::parsing::{
parse_filters_with_poi, require_bounds, row_passes_filters, row_passes_poi_filters,
};
use crate::state::{AppState, SharedState};
const MAX_RESULTS: usize = 5000;
use super::travel_time::{parse_optional_travel, row_passes_travel_filters, TravelEntry};
#[derive(Deserialize)]
pub struct ActualListingsParams {
bounds: Option<String>,
/// `;;`-separated filters: `name:min:max;;...`
filters: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`
travel: Option<String>,
/// Number of results to skip. Defaults to 0.
offset: Option<usize>,
}
#[derive(Serialize)]
pub struct ActualListingsResponse {
pub listings: Vec<ActualListing>,
pub total: usize,
pub offset: usize,
pub truncated: bool,
}
pub async fn get_actual_listings(
State(shared): State<Arc<SharedState>>,
Query(params): Query<ActualListingsParams>,
) -> Result<Json<ActualListingsResponse>, (StatusCode, String)> {
) -> Result<Json<ActualListingsResponse>, ApiError> {
let state = shared.load_state();
let limit = ACTUAL_LISTINGS_LIMIT;
let offset = params.offset.unwrap_or(0);
let Some(actual_listings) = state.actual_listings.clone() else {
return Ok(Json(ActualListingsResponse {
listings: Vec::new(),
total: 0,
offset,
truncated: false,
}));
};
let (south, west, north, east) = require_bounds(params.bounds)?;
let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
let response = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
let row_indices = actual_listings.grid.query(south, west, north, east);
let total = row_indices.len();
let truncated = total > MAX_RESULTS;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (mut parsed_filters, mut parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
&state.data.poi_metrics.name_to_index,
&poi_quant,
)
.map_err(ApiError::BadRequest)?;
let mut listings: Vec<ActualListing> = row_indices
.iter()
.take(MAX_RESULTS)
.map(|&row| actual_listings.listing_at(row as usize))
.collect();
// Drop property-level filters (price, sqm, build year, beds, type, etc.) so they
// don't hide live listings — those are individual-property concerns the user can
// judge from the pin itself. We only keep area/postcode-level filters here.
let property_level_idxs: FxHashSet<usize> = property_level_feature_names()
.into_iter()
.filter_map(|name| state.feature_name_to_index.get(name).copied())
.collect();
parsed_filters.retain(|f| !property_level_idxs.contains(&f.feat_idx));
parsed_enum_filters.retain(|f| !property_level_idxs.contains(&f.feat_idx));
// Sort newest first so the most relevant pins win when the viewport is busy.
listings.sort_by(|left, right| {
right
.listing_date_iso
.cmp(&left.listing_date_iso)
.then_with(|| right.asking_price.cmp(&left.asking_price))
});
let travel_entries =
parse_optional_travel(params.travel.as_deref()).map_err(ApiError::BadRequest)?;
let elapsed = t0.elapsed();
info!(
results = listings.len(),
total,
truncated,
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/actual-listings"
);
let has_area_filters = !parsed_filters.is_empty()
|| !parsed_enum_filters.is_empty()
|| !parsed_poi_filters.is_empty()
|| !travel_entries.is_empty();
ActualListingsResponse {
listings,
total,
truncated,
}
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
let state_clone = state.clone();
let response =
tokio::task::spawn_blocking(move || -> Result<ActualListingsResponse, String> {
let t0 = std::time::Instant::now();
let passing_postcodes = if has_area_filters {
Some(compute_passing_postcodes(
&state_clone,
south,
west,
north,
east,
&parsed_filters,
&parsed_enum_filters,
&parsed_poi_filters,
&travel_entries,
)?)
} else {
None
};
let row_indices = actual_listings.grid.query(south, west, north, east);
let total_in_bounds = row_indices.len();
// Build (row, sort_key) pairs so we can sort by index without
// materializing the full ActualListing for every matching row.
let mut matching_rows: Vec<usize> = row_indices
.iter()
.filter_map(|&row_idx| {
let row = row_idx as usize;
if let Some(allowed) = passing_postcodes.as_ref() {
if !allowed.contains(actual_listings.postcode[row].as_str()) {
return None;
}
}
Some(row)
})
.collect();
let total_matching = matching_rows.len();
matching_rows.sort_by(|&left, &right| {
actual_listings.listing_date_iso[right]
.cmp(&actual_listings.listing_date_iso[left])
.then_with(|| {
actual_listings.asking_price[right].cmp(&actual_listings.asking_price[left])
})
});
let truncated = total_matching > offset.saturating_add(limit);
let listings: Vec<ActualListing> = matching_rows
.iter()
.skip(offset)
.take(limit)
.map(|&row| actual_listings.listing_at(row))
.collect();
let elapsed = t0.elapsed();
info!(
results = listings.len(),
total = total_matching,
total_in_bounds,
offset,
filtered = passing_postcodes.is_some(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/actual-listings"
);
Ok(ActualListingsResponse {
listings,
total: total_matching,
offset,
truncated,
})
})
.await
.map_err(|error| ApiError::Internal(error.to_string()))?
.map_err(ApiError::Internal)?;
Ok(Json(response))
}
#[allow(clippy::too_many_arguments)]
fn compute_passing_postcodes(
state: &AppState,
south: f64,
west: f64,
north: f64,
east: f64,
parsed_filters: &[crate::parsing::ParsedFilter],
parsed_enum_filters: &[crate::parsing::ParsedEnumFilter],
parsed_poi_filters: &[crate::parsing::ParsedPoiFilter],
travel_entries: &[TravelEntry],
) -> Result<FxHashSet<String>, String> {
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let poi_metrics = &state.data.poi_metrics;
let has_poi_filters = !parsed_poi_filters.is_empty();
let travel_data = if travel_entries.is_empty() {
Vec::new()
} else {
let store = &state.travel_time_store;
travel_entries
.iter()
.map(|entry| {
store
.get(&entry.mode, &entry.slug)
.map_err(|err| format!("Failed to load travel data: {}", err))
})
.collect::<Result<Vec<_>, _>>()?
};
let has_travel = !travel_entries.is_empty();
let mut passing: FxHashSet<String> = FxHashSet::default();
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
let row = row_idx as usize;
if !row_passes_filters(
row,
parsed_filters,
parsed_enum_filters,
feature_data,
num_features,
) {
return;
}
if has_poi_filters && !row_passes_poi_filters(row, parsed_poi_filters, poi_metrics) {
return;
}
let postcode = state.data.postcode(row);
if has_travel && !row_passes_travel_filters(postcode, travel_entries, &travel_data) {
return;
}
// Property postcodes share the same canonical "OUT IN" format used by
// ActualListingData::load (normalize_postcode), so we can match by string.
if !passing.contains(postcode) {
passing.insert(postcode.to_string());
}
});
Ok(passing)
}

View file

@ -1,6 +1,8 @@
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use std::sync::{Arc, Once};
static OUT_OF_RANGE_WARN: Once = Once::new();
use axum::extract::{Query, State};
use axum::http::StatusCode;
@ -260,6 +262,14 @@ pub(super) fn top_filter_exclusions(
continue;
};
let Some(category) = values.get(raw as usize) else {
OUT_OF_RANGE_WARN.call_once(|| {
warn!(
feature = %data.feature_names[filter.feat_idx],
raw,
max = values.len(),
"Enum value index out of range (logged once)"
);
});
continue;
};
@ -372,10 +382,10 @@ pub(super) fn top_filter_exclusions(
.unwrap_or(f32::INFINITY);
let replace = path_score < current_score
|| (path_score == current_score
|| (path_score.total_cmp(&current_score) == std::cmp::Ordering::Equal
&& best_path
.as_ref()
.map_or(true, |current| path.len() < current.len()));
.is_none_or(|current| path.len() < current.len()));
if replace {
best_path = Some(path);
}
@ -394,8 +404,7 @@ pub(super) fn top_filter_exclusions(
exclusions.sort_by(|a, b| {
a.relative_difference
.partial_cmp(&b.relative_difference)
.unwrap_or(std::cmp::Ordering::Equal)
.total_cmp(&b.relative_difference)
.then_with(|| b.rejected_count.cmp(&a.rejected_count))
.then_with(|| a.name.cmp(&b.name))
});
@ -524,6 +533,27 @@ pub async fn get_hexagon_stats(
// for the requested journey destination (so it has journey data). Fall back
// to geographic proximity to the hexagon center.
let central_postcode = if !matching_rows.is_empty() {
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
let lat = state.data.lat.as_slice();
let lon = state.data.lon.as_slice();
let distance_sq = |row: usize| -> Option<f32> {
match (lat.get(row), lon.get(row)) {
(Some(&la), Some(&lo)) if la.is_finite() && lo.is_finite() => {
Some((la - center_lat).powi(2) + (lo - center_lon).powi(2))
}
_ => {
OUT_OF_RANGE_WARN.call_once(|| {
warn!(
"matching_rows index out of range or non-finite lat/lon (logged once)"
);
});
None
}
}
};
if let Some(ref travel_data) = journey_travel_data {
// Find the row with the shortest travel time in the travel data
let best_row = matching_rows
@ -537,40 +567,24 @@ pub async fn get_hexagon_stats(
.map(|(row, _)| row);
// Fall back to geographic center if no row has travel data
let row = best_row.unwrap_or_else(|| {
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
let row = best_row.or_else(|| {
matching_rows
.iter()
.copied()
.min_by(|&a, &b| {
let da = (state.data.lat[a] - center_lat).powi(2)
+ (state.data.lon[a] - center_lon).powi(2);
let db = (state.data.lat[b] - center_lat).powi(2)
+ (state.data.lon[b] - center_lon).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty")
.filter_map(|row| distance_sq(row).map(|d| (row, d)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(row, _)| row)
});
Some(state.data.postcode(row).to_string())
row.map(|row| state.data.postcode(row).to_string())
} else {
// No journey destination requested — use geographic center
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
let closest_row = matching_rows
.iter()
.copied()
.min_by(|&a, &b| {
let da = (state.data.lat[a] - center_lat).powi(2)
+ (state.data.lon[a] - center_lon).powi(2);
let db = (state.data.lat[b] - center_lat).powi(2)
+ (state.data.lon[b] - center_lon).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty");
Some(state.data.postcode(closest_row).to_string())
.filter_map(|row| distance_sq(row).map(|d| (row, d)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(row, _)| row);
closest_row.map(|row| state.data.postcode(row).to_string())
}
} else {
None

View file

@ -292,6 +292,47 @@ async fn mark_invite_used(
return Err(StatusCode::BAD_GATEWAY.into_response());
}
// Defense in depth: PocketBase has no atomic compare-and-swap for record
// updates, and our local + distributed locks could in principle fail (lock
// server timeout, server restart mid-redemption). Re-read the record and
// confirm WE actually own it — if a concurrent redemption beat us to the
// PATCH, both writes succeeded but the loser's user_id is overwritten and
// we must NOT grant a license.
let verify_url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
let verify_resp = match state
.http_client
.get(&verify_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to verify invite redemption: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !verify_resp.status().is_success() {
return Err(StatusCode::BAD_GATEWAY.into_response());
}
let body: serde_json::Value = match verify_resp.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse invite verify response: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
let actual_user = body["used_by_id"].as_str().unwrap_or("");
if actual_user != user_id {
warn!(
invite_id,
expected = user_id,
actual = actual_user,
"Invite redemption race lost — invite already claimed by another user"
);
return Err((StatusCode::CONFLICT, "Invite was already redeemed").into_response());
}
Ok(())
}
@ -512,11 +553,16 @@ pub async fn get_invite(
.await
{
Ok(resp) if resp.status().is_success() => {
let user_body: serde_json::Value = resp.json().await.unwrap_or_default();
user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.and_then(sanitize_invited_by)
match resp.json::<serde_json::Value>().await {
Ok(user_body) => user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.and_then(sanitize_invited_by),
Err(err) => {
tracing::error!("Failed to parse inviter user record JSON: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
}
}
_ => None,
}
@ -689,26 +735,6 @@ pub async fn post_redeem_invite(
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
assert_eq!(
filter,
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
);
}
#[test]
fn redeemable_invite_filter_rejects_unsafe_values() {
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
}
}
/// List invites. Users only see invites they created, including admins.
pub async fn get_invites(
State(shared): State<Arc<SharedState>>,
@ -787,3 +813,23 @@ pub async fn get_invites(
Json(InviteListResponse { invites }).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
assert_eq!(
filter,
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
);
}
#[test]
fn redeemable_invite_filter_rejects_unsafe_values() {
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
}
}

View file

@ -1,11 +1,12 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::api_error::ApiError;
use crate::consts::PLACES_LIMIT;
use crate::data::{normalize_search_text, slugify};
use crate::state::SharedState;
@ -41,7 +42,6 @@ pub struct PlacesResponse {
#[allow(clippy::min_ident_chars)]
pub struct PlacesParams {
q: String,
limit: Option<usize>,
/// If set, only return places that have travel time data for this mode.
mode: Option<String>,
}
@ -96,15 +96,15 @@ fn postcode_starts_with_compact(postcode: &str, compact_query: &str) -> bool {
pub async fn get_places(
State(shared): State<Arc<SharedState>>,
Query(params): Query<PlacesParams>,
) -> Result<Json<PlacesResponse>, (StatusCode, String)> {
) -> Result<Json<PlacesResponse>, ApiError> {
let state = shared.load_state();
let query = if params.q.is_empty() {
return Err((StatusCode::BAD_REQUEST, "'q' must not be empty".into()));
return Err(ApiError::BadRequest("'q' must not be empty".into()));
} else {
params.q
};
let limit = params.limit.unwrap_or(7).min(20);
let limit = PLACES_LIMIT;
let mode_filter = params.mode;
let places = tokio::task::spawn_blocking(move || {
@ -264,7 +264,7 @@ pub async fn get_places(
(results, postcodes, addresses)
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
.map_err(|error| ApiError::Internal(error.to_string()))?;
Ok(Json(PlacesResponse {
places: places.0,

View file

@ -1,11 +1,11 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::api_error::ApiError;
use crate::consts::MAX_POIS_PER_REQUEST;
use crate::data::{resolve_poi_category_filter, POICategoryGroup};
use crate::parsing::require_bounds;
@ -39,9 +39,9 @@ pub struct POIParams {
pub async fn get_pois(
State(shared): State<Arc<SharedState>>,
Query(params): Query<POIParams>,
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
) -> Result<Json<POIsResponse>, ApiError> {
let state = shared.load_state();
let (south, west, north, east) = require_bounds(params.bounds)?;
let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
let category_filter: Option<rustc_hash::FxHashSet<u16>> = params
.categories
@ -109,7 +109,7 @@ pub async fn get_pois(
pois
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
.map_err(|error| ApiError::Internal(error.to_string()))?;
Ok(Json(POIsResponse { pois }))
}

View file

@ -8,13 +8,13 @@ use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET};
use crate::consts::{POSTCODE_SEARCH_OFFSET, PROPERTIES_LIMIT};
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
use super::properties::{HexagonPropertiesResponse, Property};
use super::properties::{Property, PropertyListResponse};
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
@ -24,7 +24,6 @@ pub struct PostcodePropertiesParams {
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
/// Exact address to rank first when opening properties from address search.
pub focus_address: Option<String>,
@ -36,7 +35,7 @@ pub async fn get_postcode_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodePropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
let state = shared.load_state();
let normalized = normalize_postcode(&params.postcode);
@ -151,7 +150,7 @@ pub async fn get_postcode_properties(
});
let total = matching_rows.len();
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
let limit = PROPERTIES_LIMIT;
let page_offset = params.offset.unwrap_or(0);
let truncated = total > page_offset + limit;
@ -183,10 +182,9 @@ pub async fn get_postcode_properties(
"GET /api/postcode-properties"
);
Ok(HexagonPropertiesResponse {
Ok(PropertyListResponse {
properties,
total,
limit,
offset: page_offset,
truncated,
})

View file

@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::DEFAULT_PROPERTIES_LIMIT;
use crate::consts::PROPERTIES_LIMIT;
use crate::data::RenovationEvent;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
@ -29,7 +29,6 @@ pub struct HexagonPropertiesParams {
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
@ -62,11 +61,13 @@ pub struct Property {
pub features: FxHashMap<String, f32>,
}
/// Shared paginated list of `Property` records. Used by both
/// `/api/hexagon-properties` (lookup by H3 cell) and `/api/postcode-properties`
/// (lookup by postcode) so the frontend can render either result the same way.
#[derive(Serialize)]
pub struct HexagonPropertiesResponse {
pub struct PropertyListResponse {
pub properties: Vec<Property>,
pub total: usize,
pub limit: usize,
pub offset: usize,
pub truncated: bool,
}
@ -183,7 +184,7 @@ pub async fn get_hexagon_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
let state = shared.load_state();
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
@ -273,7 +274,7 @@ pub async fn get_hexagon_properties(
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
let total = matching_rows.len();
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
let limit = PROPERTIES_LIMIT;
let offset = params.offset.unwrap_or(0);
let truncated = total > offset + limit;
@ -306,10 +307,9 @@ pub async fn get_hexagon_properties(
"GET /api/hexagon-properties"
);
Ok(HexagonPropertiesResponse {
Ok(PropertyListResponse {
properties,
total,
limit,
offset,
truncated,
})

View file

@ -4,7 +4,7 @@ use metrics::counter;
use rustc_hash::FxHashMap;
use tracing::error;
use crate::consts::MAX_PRICE_HISTORY_POINTS;
use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
use super::hexagon_stats::{EnumFeatureStats, HistogramStats, NumericFeatureStats, PricePoint};
@ -32,9 +32,9 @@ pub fn extract_price_history(
}
})
.collect();
if points.len() > MAX_PRICE_HISTORY_POINTS {
let step = points.len() as f64 / MAX_PRICE_HISTORY_POINTS as f64;
points = (0..MAX_PRICE_HISTORY_POINTS)
if points.len() > PRICE_HISTORY_POINTS_LIMIT {
let step = points.len() as f64 / PRICE_HISTORY_POINTS_LIMIT as f64;
points = (0..PRICE_HISTORY_POINTS_LIMIT)
.map(|i| {
let idx = (i as f64 * step) as usize;
PricePoint {

4
uv.lock generated
View file

@ -1401,7 +1401,6 @@ dev = [
requires-dist = [
{ name = "fastexcel", specifier = ">=0.19.0" },
{ name = "folium", specifier = ">=0.20.0" },
{ name = "httpx" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "ipywidgets", specifier = ">=8.0.0" },
{ name = "jupyter", specifier = ">=1.0.0" },
@ -1411,8 +1410,7 @@ requires-dist = [
{ name = "pandas", specifier = ">=2.0.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "plotly", specifier = ">=6.5.2" },
{ name = "polars" },
{ name = "polars", specifier = ">=1.37.1" },
{ name = "polars", specifier = ">=1.37.1,<2.0.0" },
{ name = "pyarrow", specifier = ">=15.0.0" },
{ name = "pyogrio", specifier = ">=0.12.1" },
{ name = "pyproj", specifier = ">=3.7.2" },