Compare commits

..

No commits in common. "2f149503bb52398401ea146137c6c892ced0ccfc" and "ebe7bbb51d3a8ae8961e8b588c665594b2a40d7d" have entirely different histories.

95 changed files with 155082 additions and 25891 deletions

Binary file not shown.

Before

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": "Other",
"House of Multiple Occupation": "Flats/Maisonettes",
"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": "Other",
"Retirement Property": "Flats/Maisonettes",
"Parking": "Other",
"Plot": "Other",
"Garages": "Other",
"Mews": "Terraced",
"Property": "Other",
"Flat Share": "Other",
"Block of Apartments": "Other",
"Private Halls": "Other",
"Block of Apartments": "Flats/Maisonettes",
"Private Halls": "Flats/Maisonettes",
"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": "Other",
"Serviced Apartments": "Flats/Maisonettes",
# 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": "Other",
"Block Of Apartments": "Flats/Maisonettes",
# 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": "Other",
"retirement-property": "Flats/Maisonettes",
"equestrian-facility": "Other",
"flat": "Flats/Maisonettes",
"detached": "Detached",

View file

@ -19,12 +19,7 @@ from constants import (
RETRY_BASE_DELAY,
)
from spatial import PostcodeSpatialIndex
from transform import (
normalize_postcode,
normalize_sub_type,
parse_int_value,
validate_floor_area,
)
from transform import normalize_postcode, normalize_sub_type, validate_floor_area
log = logging.getLogger("homecouk")
@ -175,19 +170,11 @@ 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|square\s+feet|ft(?:\^?2|²))",
description,
re.IGNORECASE,
)
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", 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|square\s+met(?:er|re)s?|m(?:\^?2|²))",
description,
re.IGNORECASE,
)
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", description, re.IGNORECASE)
if m:
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
return None
@ -250,15 +237,6 @@ 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
@ -291,10 +269,8 @@ def transform_property(
log.debug("Coords outside England: lat=%.4f lng=%.4f — skipping", lat, lng)
return None
price = parse_int_value(prop.get("price")) or parse_int_value(
prop.get("latest_price")
)
if not price or price <= 0:
price = prop.get("price") or prop.get("latest_price")
if not price or int(price) <= 0:
return None
# Home.co.uk provides postcodes directly, but fall back to spatial index
@ -305,10 +281,10 @@ def transform_property(
log.debug("No postcode for property at %.4f, %.4f — skipping", lat, lng)
return None
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
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
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"home.co.uk %s: implausible beds=%d baths=%d (capped to 0)",
@ -342,7 +318,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": price,
"price": int(price),
"price_frequency": "",
"Price qualifier": price_qualifier,
"Total floor area (sqm)": parse_floor_area(prop.get("description")),
@ -386,16 +362,7 @@ def search_outcode(
break
for prop in raw_props:
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
transformed = transform_property(prop, pc_index)
if transformed:
properties.append(transformed)
if max_properties is not None and len(properties) >= max_properties:

View file

@ -1,63 +0,0 @@
"""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,15 +10,6 @@ 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
@ -31,23 +22,12 @@ outcode_cache: dict[str, str] = {}
# Requesting index >= 1008 returns HTTP 400.
_MAX_INDEX = 1008
_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
# 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",
]
def resolve_outcode_id(client: httpx.Client, outcode: str) -> str | None:
@ -112,18 +92,8 @@ def _paginate(
break
for prop in raw_props:
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):
transformed = transform_property(prop, outcode, pc_index)
if transformed:
properties.append(transformed)
if max_properties is not None and len(properties) >= max_properties:
return properties, result_count
@ -135,15 +105,6 @@ 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)
@ -160,20 +121,54 @@ def search_outcode(
) -> list[dict]:
"""Paginate through search results for one outcode+channel. Returns transformed properties.
Search requests set the supported Rightmove filters directly: flats,
2-5 bedrooms, 2-3 bathrooms, 969-1830 sq ft, and asking price below £1m.
When the unfiltered result count exceeds 1008 (Rightmove's hard pagination cap),
re-queries per property type to recover listings beyond the cap.
"""
properties, _ = _paginate(
client,
outcode_id,
outcode,
channel_cfg,
pc_index,
extra_params=_buy_search_params(),
max_properties=max_properties,
properties, result_count = _paginate(
client, outcode_id, outcode, channel_cfg, pc_index, 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,7 +19,6 @@ 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
@ -182,11 +181,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]
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)
unknown = sorted(set(requested) - set(SOURCE_ORDER))
if unknown:
raise ValueError(f"Unknown source(s): {', '.join(unknown)}")
return [source for source in SOURCE_ORDER if source in requested]
@ -197,28 +196,19 @@ 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")
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)}"
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)}"
merged[storage_key] = prop
seen_keys.add(key)
counts[source] += 1
return list(merged.values()), counts, deduped
@ -251,22 +241,13 @@ def _store_properties(
if remaining == 0:
return 0
londonish = [prop for prop in props if _property_is_londonish(prop)]
dropped_outside_area = len(props) - len(londonish)
if dropped_outside_area:
eligible = [prop for prop in props if _property_is_londonish(prop)]
dropped = len(props) - len(eligible)
if dropped:
log.debug(
"%s dropped %d properties outside the Greater London-ish postcode filter",
source,
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,
dropped,
)
selected = eligible if remaining is None else eligible[:remaining]
@ -386,16 +367,20 @@ 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=None,
max_properties=remaining,
)
added = _store_properties(
results,
@ -457,17 +442,19 @@ 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=None,
max_properties=remaining,
)
added = _store_properties(
results,
@ -519,6 +506,9 @@ 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()
@ -549,8 +539,7 @@ def run_scrape(
)
if "zoopla" in selected_sources:
if pc_coords is None:
pc_coords = build_postcode_coords()
assert pc_coords is not None
_scrape_zoopla(
selected_outcodes,
pc_index,
@ -562,36 +551,19 @@ def run_scrape(
merged, source_counts, deduped = _merge_properties(results)
output_path = output_base / "online_listings_buy.parquet"
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)
write_parquet(merged, 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, %d strict-filtered (%s deduped:%d)",
"Sale scrape complete: %d unique (rightmove:%d homecouk:%d zoopla:%d deduped:%d)",
len(merged),
len(filtered),
source_summary,
source_counts["rightmove"],
source_counts["homecouk"],
source_counts["zoopla"],
deduped,
)
@ -603,7 +575,6 @@ 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,10 +45,9 @@ def write_parquet(properties: list[dict], path: Path) -> None:
remapped = 0
for p in properties:
sub_type = p.get("Property sub-type", "")
current_type = p.get("Property type")
if sub_type and sub_type != "Unknown" and current_type in (None, "", "Other"):
if sub_type and sub_type != "Unknown":
new_type = map_property_type(sub_type)
if new_type != current_type:
if new_type != p.get("Property type"):
p["Property type"] = new_type
remapped += 1
if remapped:

View file

@ -1,5 +1,4 @@
import logging
import math
import re
from constants import MAX_BEDROOMS, PROPERTY_TYPE_MAP, RIGHTMOVE_BASE
@ -30,43 +29,17 @@ 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|square\s+feet|ft(?:\^?2|²))",
display_size,
re.IGNORECASE,
)
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*ft", 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|square\s+met(?:er|re)s?|m(?:\^?2|²))",
display_size,
re.IGNORECASE,
)
m = re.search(r"([\d,]+(?:\.\d+)?)\s*sq\.?\s*m", display_size, re.IGNORECASE)
if m:
return validate_floor_area(round(float(m.group(1).replace(",", "")), 1))
return None
@ -113,21 +86,7 @@ def map_property_type(sub_type: str | None) -> str:
return canonical
# Keyword fallback for compound types not in the map
lower = sub_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
or "maisonette" in lower
or "studio" in lower
):
if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower:
return "Flats/Maisonettes"
if "semi" in lower and "detach" in lower:
return "Semi-Detached"
@ -199,10 +158,10 @@ def transform_property(
lat, lng = fix_coords(raw_lat, raw_lng)
price_obj = prop.get("price", {})
amount = parse_int_value(price_obj.get("amount"))
amount = price_obj.get("amount")
if not amount:
return None
price = amount
price = int(amount)
if price <= 0:
return None
@ -213,23 +172,14 @@ def transform_property(
# POA / Auction listings have unreliable prices — treat as no price
pq_lower = price_qualifier.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):
if "poa" in pq_lower or "auction" in pq_lower:
return None
sub_type = prop.get("propertySubType", "")
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
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
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"Rightmove %s: implausible beds=%d baths=%d (capped to 0)",
@ -247,15 +197,8 @@ 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": listing_id,
"id": prop.get("id"),
"Bedrooms": bedrooms,
"Bathrooms": bathrooms,
"Number of bedrooms & living rooms": bedrooms + bathrooms,
@ -270,7 +213,7 @@ def transform_property(
"price_frequency": "",
"Price qualifier": price_qualifier,
"Total floor area (sqm)": parse_display_size(prop.get("displaySize")),
"Listing URL": RIGHTMOVE_BASE + property_url if property_url else "",
"Listing URL": RIGHTMOVE_BASE + prop.get("propertyUrl", ""),
"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, parse_int_value, validate_floor_area
from transform import normalize_sub_type, validate_floor_area
log = logging.getLogger("zoopla")
@ -106,8 +106,7 @@ _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 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);
const areaMatch = text.match(/([\d,]+)\s*sq\.?\s*ft/i);
let tenure = '';
if (/leasehold/i.test(text)) tenure = 'Leasehold';
@ -142,8 +141,7 @@ _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: areaSqftMatch ? parseInt(areaSqftMatch[1].replace(/,/g, '')) : null,
floor_area_sqm: areaSqmMatch ? parseFloat(areaSqmMatch[1].replace(/,/g, '')) : null,
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
address, tenure, property_type,
});
}
@ -183,8 +181,7 @@ _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 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);
const areaMatch = text.match(/([\d,]+)\s*sq\.?\s*ft/i);
let address = '';
for (const line of lines) {
@ -228,8 +225,7 @@ _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: areaSqftMatch ? parseInt(areaSqftMatch[1].replace(/,/g, '')) : null,
floor_area_sqm: areaSqmMatch ? parseFloat(areaSqmMatch[1].replace(/,/g, '')) : null,
floor_area_sqft: areaMatch ? parseInt(areaMatch[1].replace(/,/g, '')) : null,
address, tenure, property_type,
});
}
@ -615,22 +611,7 @@ def _map_property_type(raw_type: str | None) -> str:
return canonical
# Keyword fallback
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
or "maisonette" in lower
or "studio" in lower
or "penthouse" in lower
):
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"
@ -653,8 +634,8 @@ def transform_property(
Zoopla search cards do not include coordinates, so we resolve lat/lng
from postcodes extracted from the address text."""
price = parse_int_value(raw.get("price"))
if not price or price <= 0:
price = raw.get("price")
if not price or int(price) <= 0:
return None
address = raw.get("address", "")
@ -689,10 +670,10 @@ def transform_property(
if not (49 <= lat <= 56 and -7 <= lng <= 2):
return None
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
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
if raw_beds > MAX_BEDROOMS or raw_baths > MAX_BEDROOMS:
log.warning(
"Zoopla %s: implausible beds=%d baths=%d (capped to 0)",
@ -702,13 +683,9 @@ def transform_property(
# Floor area: convert sq ft to sq m
floor_area_sqm = None
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))
sqft = raw.get("floor_area_sqft")
if sqft:
floor_area_sqm = validate_floor_area(round(sqft * 0.092903, 1))
listing_id = raw.get("id", "")
listing_url = raw.get("url", "")
@ -727,7 +704,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": price,
"price": int(price),
"price_frequency": "",
"Price qualifier": "",
"Total floor area (sqm)": floor_area_sqm,
@ -783,18 +760,7 @@ def search_outcode(
properties = []
dropped = 0
for raw in raw_listings:
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
transformed = transform_property(raw, pc_index, pc_coords, search_outcode=outcode)
if transformed:
properties.append(transformed)
else:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View file

@ -47,7 +47,6 @@
"@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",
@ -67,7 +66,6 @@
"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",
@ -8171,27 +8169,6 @@
"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,7 +54,6 @@
"@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",
@ -74,7 +73,6 @@
"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,13 +236,6 @@ 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',
@ -291,14 +284,14 @@ export default function App() {
async function refreshOnStartup() {
if (!returnedFromCheckout) {
// Always refresh auth on startup to pick up server-side subscription changes.
refreshAuthRef.current().catch(() => {});
refreshAuth().catch(() => {});
return;
}
setLicenseSuccessStatus('verifying');
for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) {
try {
const refreshedUser = await refreshAuthRef.current();
const refreshedUser = await refreshAuth();
if (cancelled) return;
if (hasFullAccess(refreshedUser)) {
trackEvent('Purchase');
@ -321,9 +314,7 @@ export default function App() {
return () => {
cancelled = true;
};
// Mount-only: this is a startup auth refresh / license verification handshake
// that must fire exactly once on initial load. refreshAuth is read via ref.
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
@ -390,17 +381,20 @@ 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);
@ -457,25 +451,13 @@ 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 initialActivePage = initialPageRef.current;
const hash = initialRouteHashRef.current || normalizeHash(window.location.hash);
const hash = routeHash || normalizeHash(window.location.hash);
window.history.replaceState(
{ page: initialActivePage, hash },
{ page: activePage, hash },
'',
buildPageUrl(
initialActivePage,
initialInviteCodeRef.current ?? undefined,
window.location.search,
hash
)
buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash)
);
}
const handlePopState = (e: PopStateEvent) => {
@ -505,10 +487,7 @@ export default function App() {
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
// 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.
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
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 ? t('common.yes') : t('common.no');
const statusLabel = has ? 'Yes' : 'No';
return (
<td
key={j}
@ -520,11 +520,11 @@ export default function HomePage({
);
})}
<td
aria-label={t('common.yes')}
aria-label="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">{t('common.yes')}</span>
<span className="sr-only">Yes</span>
</td>
</tr>
))}

File diff suppressed because it is too large Load diff

View file

@ -1,26 +0,0 @@
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,42 +30,6 @@ 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,
@ -74,8 +38,6 @@ export function DualHistogram({
globalMean,
meanLabel,
formatLabel,
compact = false,
integerAxisLabels = false,
}: {
localCounts: number[];
globalCounts: number[];
@ -84,15 +46,9 @@ export function DualHistogram({
globalMean?: number;
meanLabel?: string;
formatLabel?: (value: number) => string;
compact?: boolean;
integerAxisLabels?: boolean;
}) {
const { t } = useTranslation();
const showCompactAxisLabels =
compact &&
isLowCardinalityHistogram(localCounts, p1, p99) &&
isLowCardinalityHistogram(globalCounts, p1, p99);
const targetBars = compact ? (showCompactAxisLabels ? localCounts.length : 16) : 25;
const targetBars = 25;
const localBars = downsampleBars(localCounts, targetBars);
const globalBars = downsampleBars(globalCounts, targetBars);
@ -103,8 +59,6 @@ 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);
@ -143,60 +97,6 @@ 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'}>
@ -252,29 +152,35 @@ export function DualHistogram({
function SkeletonHistogram() {
return (
<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 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
key={i}
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}%` }}
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}%` }}
/>
))}
</div>
<div className="h-3 w-10 justify-self-end rounded bg-warm-200 dark:bg-warm-700" />
<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>
);
}
export function LoadingSkeleton() {
return (
<div className="space-y-4 p-3">
<div className="p-3 space-y-4">
{[0, 1, 2].map((groupIdx) => (
<div key={groupIdx}>
<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">
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
<div className="space-y-3">
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
<SkeletonHistogram key={i} />
))}

View file

@ -1,34 +1,16 @@
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
@ -46,71 +28,6 @@ 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,7 +86,10 @@ export default function FeatureBrowser({
const showTravelModes =
visibleModes.length > 0 &&
(!search || t('filters.travelTimeKeywords').toLowerCase().includes(search.toLowerCase()));
(!search ||
'travel time journey commute car bicycle walking transit transport station tube train'.includes(
search.toLowerCase()
));
// Keep "Transport" first because journey and transport proximity controls belong together.
const mergedGrouped = useMemo(() => {
@ -120,7 +123,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-30 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-10 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,18 +3,35 @@ import { useTranslation } from 'react-i18next';
export default function HistogramLegend() {
const { t } = useTranslation();
return (
<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 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>
</div>
);

View file

@ -9,7 +9,6 @@ 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.minute')}
{t('common.min')}
</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.minute')}
{leg.minutes} {t('common.min')}
</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.minute')}
{totalMin} {t('common.min')}
</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.minute')}
{t('common.min')}
</span>
</div>
{showGoogleMapsLink && (

View file

@ -1,7 +1,6 @@
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';
@ -86,10 +85,10 @@ function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
function formatListingHeadline(listing: ActualListing): string | null {
const parts: string[] = [];
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.bedrooms != null) parts.push(`${listing.bedrooms} bed`);
if (listing.bathrooms != null) parts.push(`${listing.bathrooms} bath`);
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;
@ -731,9 +730,9 @@ export default memo(function Map({
) : null}
</div>
)}
{formatListingHeadline(listingPopup.listing, t) && (
{formatListingHeadline(listingPopup.listing) && (
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
{formatListingHeadline(listingPopup.listing, t)}
{formatListingHeadline(listingPopup.listing)}
</div>
)}
{listingPopup.listing.address && (

View file

@ -6,7 +6,6 @@ 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';
@ -16,7 +15,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, buildFilterString } from '../../lib/api';
import { apiUrl, authHeaders } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
@ -147,8 +146,6 @@ 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;
@ -411,15 +408,10 @@ export default function MapPage({
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features),
[filters, features]
const { listings: actualListings } = useActualListings(
mapData.bounds,
mapData.currentView?.zoom ?? 0
);
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
const { listings: actualListings } = useActualListings(mapData.bounds, {
filterParam: actualListingsFilterParam,
travelParam: actualListingsTravelParam,
});
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
@ -472,7 +464,11 @@ 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;
@ -503,7 +499,15 @@ 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) => {
@ -560,11 +564,6 @@ export default function MapPage({
shareCode={shareCode}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
scrollTopRef={areaPaneScrollTopRef}
scrollRestoreKey={
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
}
scrollSaveDisabled={loadingAreaStats && areaStats == null}
/>
</Suspense>
);
@ -577,11 +576,6 @@ 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>
);
@ -658,7 +652,11 @@ 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;
@ -673,7 +671,9 @@ 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 { getPoiCategoryLogoUrl } from '../../lib/map-utils';
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
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 = getPoiCategoryLogoUrl(category);
const logo = POI_CATEGORY_LOGOS[category];
return (
<PillToggle
key={category}

View file

@ -1,9 +1,8 @@
import { useMemo, useState, useEffect, type MutableRefObject } from 'react';
import { useMemo, useState, useEffect } 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';
@ -18,9 +17,6 @@ interface PropertiesPaneProps {
hexagonId: string | null;
onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void;
scrollTopRef?: MutableRefObject<number>;
scrollRestoreKey?: string | null;
scrollSaveDisabled?: boolean;
}
export function PropertiesPane({
@ -30,18 +26,10 @@ 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('');
@ -72,68 +60,65 @@ export function PropertiesPane({
return (
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && properties.length > 0} />
<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 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 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>
</div>
);

View file

@ -12,7 +12,6 @@ interface StackedBarChartProps {
segments: Segment[];
total: number;
colorMap: Record<string, string>;
compact?: boolean;
}
/** Strip common suffixes/prefixes to produce short legend labels */
@ -29,27 +28,7 @@ function shortenLabel(name: string): string {
.trim();
}
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) {
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const { t } = useTranslation();
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
@ -76,53 +55,6 @@ export default function StackedBarChart({
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,7 +7,6 @@ interface StackedEnumChartProps {
components: { label: string; stats: EnumFeatureStats }[];
valueOrder: string[];
valueColors: string[];
compact?: boolean;
}
/** Strip common suffixes to produce short row labels */
@ -15,24 +14,10 @@ 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 }) => {
@ -50,63 +35,6 @@ 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-2">
<div className="flex items-center gap-1.5">
<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.minute')}
{formatFilterValue(displayRange[0])} {t('common.min')}
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} {t('common.minute')}
{formatFilterValue(displayRange[1])} {t('common.min')}
</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-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"
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"
>
<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-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"
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"
>
<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-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"
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"
>
<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">
{!collapsed && (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{!collapsed && (
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={allFeatures}
@ -136,37 +136,37 @@ export function AddFilterPanel({
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onAddTravelTimeEntry}
/>
</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>
)}
)}
{!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>
)}
</div>

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, type FormEvent } from 'react';
import { useEffect, type FormEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
@ -30,8 +30,6 @@ 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;
@ -42,41 +40,17 @@ 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}
role="presentation"
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
<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
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"
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"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2
id="clear-filters-dialog-title"
className="text-lg font-semibold text-navy-950 dark:text-white"
>
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
{t('filters.clearAllTitle')}
</h2>
<button
@ -93,7 +67,9 @@ 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,7 +3,6 @@ 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';
@ -35,20 +34,11 @@ 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);
@ -107,26 +97,14 @@ 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" 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"
>
<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">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 id="auth-modal-title" className="text-lg font-semibold text-navy-950 dark:text-white">
{title}
</h2>
<h2 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,7 +26,6 @@ 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);
@ -57,7 +56,7 @@ export function FeatureLabel({
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} ${gapClass} min-w-0 ${className}`}
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
>
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}

View file

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

View file

@ -1,7 +1,5 @@
import { useCallback, useEffect, useId, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useRef, useCallback, type ReactNode } from 'react';
import { useClickOutside } from '../../hooks/useClickOutside';
import { useModalA11y } from '../../hooks/useModalA11y';
import { CloseIcon } from './icons';
import { IconButton } from './IconButton';
@ -13,8 +11,7 @@ interface InfoPopupProps {
}
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
const popupRef = useModalA11y();
const titleId = useId();
const popupRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => {
onClose();
@ -22,31 +19,14 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
useClickOutside(popupRef, handleClose);
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"
>
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
<div
ref={popupRef}
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"
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"
>
<div className="flex items-start justify-between mb-3">
<h3 id={titleId} className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
{title}
</h3>
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
<IconButton onClick={onClose} className="shrink-0">
<CloseIcon />
</IconButton>
@ -63,8 +43,4 @@ 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,7 +1,6 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { useModalA11y } from '../../hooks/useModalA11y';
interface LicenseSuccessModalProps {
onClose: () => void;
@ -15,7 +14,6 @@ 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) => ({
@ -38,14 +36,6 @@ 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')
@ -66,12 +56,9 @@ export default function LicenseSuccessModal({
: t('licenseSuccess.description');
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
role="presentation"
>
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
{isSuccess && (
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<div
key={p.id}
@ -91,14 +78,7 @@ export default function LicenseSuccessModal({
</div>
)}
<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="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
<div className="h-14 mb-3 flex items-center justify-center">
{isVerifying ? (
@ -107,9 +87,7 @@ export default function LicenseSuccessModal({
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
)}
</div>
<h2 id="license-success-modal-title" className="text-2xl font-bold text-white">
{title}
</h2>
<h2 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-12 border-b border-navy-700">
<div className="flex items-center justify-between px-3 h-11 border-b border-navy-700">
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button
onClick={onClose}

View file

@ -3,7 +3,6 @@ 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,
@ -21,7 +20,6 @@ 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) => {
@ -46,32 +44,18 @@ export default function SaveSearchModal({
}, [onClose]);
return (
<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="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
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"
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"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2
id="save-search-modal-title"
className="text-lg font-semibold text-navy-950 dark:text-white"
>
<h2 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,7 +3,6 @@ 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;
@ -29,7 +28,6 @@ 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'))
@ -40,14 +38,6 @@ 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
? '...'
@ -69,23 +59,11 @@ export default function UpgradeModal({
};
return (
<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"
>
<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">
{/* 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" />
@ -93,9 +71,7 @@ export default function UpgradeModal({
{/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
{t('upgrade.title')}
</h2>
<h2 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,16 +4,9 @@ import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
interface UseActualListingsOptions {
filterParam?: string;
travelParam?: string;
}
export function useActualListings(
bounds: Bounds | null,
{ filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
) {
export function useActualListings(bounds: Bounds | null) {
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);
@ -25,6 +18,7 @@ export function useActualListings(
if (!bounds) {
abortControllerRef.current?.abort();
if (listings.length !== 0) setListings([]);
if (truncated) setTruncated(false);
return;
}
@ -36,8 +30,6 @@ export function useActualListings(
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 })
@ -46,6 +38,7 @@ export function useActualListings(
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);
}
@ -55,9 +48,9 @@ export function useActualListings(
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// listings intentionally excluded — it's internal state, not an input.
// listings/truncated intentionally excluded — they're internal state, not inputs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds, filterParam, travelParam]);
}, [bounds]);
return { listings };
return { listings, truncated };
}

View file

@ -111,6 +111,7 @@ export function useDeckLayers({
isDark,
hexagonData: data,
postcodeData,
resolution: usePostcodeView ? 0 : Math.round(zoom),
usePostcodeView,
});
@ -279,33 +280,21 @@ 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 = getRatios(d);
const r = distToRatios(d[distKey]);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (d: HexagonData) => {
const r = getRatios(d);
const r = distToRatios(d[distKey]);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
const r = getRatios(d);
const r = distToRatios(d[distKey]);
return [r[8], r[9]];
},
updateTriggers: {

View file

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

View file

@ -45,46 +45,31 @@ export function useListingLayers({
}: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
// 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 feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
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)));
}
const allowed = new Set<string>();
for (const cell of hexagonData) {
if (cell.count > 0) allowed.add(cell.h3);
}
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, resolutionForLookup));
return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
} catch {
return false;
}
});
}, [listings, hexagonData, usePostcodeView]);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? [];
}, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]);
}, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
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 });
const params = new URLSearchParams({ q: trimmed, limit: '20' });
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 typed array via linear interpolation. */
function percentile(sorted: Float64Array, p: number): number {
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = (p / 100) * (sorted.length - 1);
@ -262,20 +262,10 @@ 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();
// 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;
dragAbortRef.current = new AbortController();
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;
@ -288,15 +278,14 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('postcodes', params), authHeaders({ signal }))
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.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 = effectActiveFeature;
dragFeatureRef.current = activeFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
} else {
@ -310,36 +299,31 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('hexagons', params), authHeaders({ signal }))
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.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 = effectActiveFeature;
dragFeatureRef.current = activeFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
}
return () => {
// 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) {
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
// 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.
if (latestDragRequestKeyRef.current === requestKey) {
latestDragRequestKeyRef.current = '';
}
};
}, [
activeFeature,
activeDragRequest,
dataViewFeature,
resolution,
usePostcodeView,
viewFeatureIsEnum,
shareCode,
@ -554,14 +538,10 @@ export function useMapData({
}
if (vals.length === 0) return null;
// 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();
vals.sort((a, b) => a - b);
return [
percentile(sorted, COLOR_RANGE_LOW_PERCENTILE),
percentile(sorted, COLOR_RANGE_HIGH_PERCENTILE),
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [
bounds,

View file

@ -1,64 +0,0 @@
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

@ -1,59 +0,0 @@
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/') || url.startsWith('data:image/svg+xml');
return url.startsWith('/assets/poi-icons/');
}
function hasBundledPoiLogo(poi: POI): boolean {

View file

@ -1,80 +0,0 @@
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

@ -1,63 +0,0 @@
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,16 +12,8 @@ export interface SavedSearch {
created: string;
}
// 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)));
}
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_ATTEMPTS = 15;
export function useSavedSearches(userId: string | null) {
const [searches, setSearches] = useState<SavedSearch[]>([]);
@ -29,16 +21,14 @@ export function useSavedSearches(userId: string | null) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | 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) {
clearTimeout(pollTimerRef.current);
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
pollAttemptsRef.current = 0;
@ -47,15 +37,6 @@ 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',
@ -76,41 +57,28 @@ export function useSavedSearches(userId: string | null) {
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
pollAttemptsRef.current = 0;
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();
pollTimerRef.current = setInterval(async () => {
const uid = userIdRef.current;
if (!uid) {
stopPolling();
return;
}
const uid = userIdRef.current;
if (!uid) return;
pollAttemptsRef.current++;
if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return;
pollInFlightRef.current = true;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
stopPolling();
return;
}
try {
const mapped = await fetchRecords(uid);
if (!isMountedRef.current) return;
setSearches(mapped);
if (!mapped.some((s) => !s.screenshotUrl)) return;
scheduleNext();
if (!mapped.some((s) => !s.screenshotUrl)) {
stopPolling();
}
} catch {
// Silent — background poll errors don't surface to UI; keep trying.
if (isMountedRef.current) scheduleNext();
} finally {
pollInFlightRef.current = false;
// Silent — background poll errors don't surface to UI
}
};
scheduleNext();
}, [fetchRecords]);
}, POLL_INTERVAL_MS);
}, [stopPolling, fetchRecords]);
const fetchSearches = useCallback(async () => {
if (!userId) return;

View file

@ -25,28 +25,21 @@ 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:
'Klicken Sie auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
'Klicke 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 ───────────────────────────────────
@ -317,7 +310,8 @@ 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?':
@ -486,7 +480,8 @@ 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.':
@ -506,7 +501,8 @@ 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?':
@ -592,15 +588,15 @@ const de: Translations = {
createAccount: 'Konto erstellen',
resetPassword: 'Passwort zurücksetzen',
valueProp:
'Speichern Sie Suchen, merken Sie sich Immobilien und erstellen Sie eine Auswahlliste passender Gebiete.',
'Speichere Suchen, merke dir Immobilien und erstelle eine Auswahlliste passender Gebiete.',
continueWithGoogle: 'Weiter mit Google',
email: 'E-Mail',
emailPlaceholder: 'name@beispiel.de',
emailPlaceholder: 'du@beispiel.de',
password: 'Passwort',
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
passwordPlaceholderLogin: 'Ihr Passwort',
passwordPlaceholderLogin: 'Dein Passwort',
forgotPassword: 'Passwort vergessen?',
resetSent: 'Prüfen Sie Ihre E-Mails für einen Link zum Zurücksetzen.',
resetSent: 'Prüfe deine E-Mails für einen Link zum Zurücksetzen.',
pleaseWait: 'Bitte warten...',
sendResetLink: 'Link zum Zurücksetzen senden',
backToLogin: 'Zurück zur Anmeldung',
@ -610,7 +606,7 @@ const de: Translations = {
upgrade: {
title: 'Jede passende Postleitzahl finden',
description:
'Sie erkunden gerade das Demogebiet. Erhalten Sie lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
'Du erkundest gerade das Demogebiet. Erhalte 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.',
@ -622,7 +618,7 @@ const de: Translations = {
continueWithDemo: 'Mit Demo fortfahren',
backToSharedArea: 'Zurück zum geteilten Gebiet',
sharedAreaDescription:
'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.',
'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.',
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
},
@ -630,7 +626,7 @@ const de: Translations = {
saveSearch: {
title: 'Suche speichern',
saved: 'Suche gespeichert',
savedSuccess: 'Ihre Suche wurde erfolgreich gespeichert.',
savedSuccess: 'Deine Suche wurde erfolgreich gespeichert.',
viewSavedSearches: 'Gespeicherte Suchen ansehen',
name: 'Name',
namePlaceholder: 'Meine Suche',
@ -640,15 +636,15 @@ const de: Translations = {
// ── License Success ────────────────────────────────
licenseSuccess: {
verifyingTitle: 'Zugang wird geprüft',
verifyingSubtitle: 'Wir prüfen Ihr Konto, bevor wir die Karte freischalten.',
verifyingSubtitle: 'Wir prüfen dein 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. Aktualisieren Sie gleich noch einmal oder kontaktieren Sie den Support, falls der Zugang nicht erscheint.',
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisiere gleich noch einmal oder kontaktiere den Support, falls der Zugang nicht erscheint.',
stayOnPricing: 'Auf der Preisseite bleiben',
title: 'Sie sind dabei.',
subtitle: 'Ihr lebenslanger Zugang ist jetzt aktiv.',
title: 'Du bist dabei.',
subtitle: 'Dein lebenslanger Zugang ist jetzt aktiv.',
description: 'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
startExploring: 'Jetzt entdecken',
},
@ -659,18 +655,18 @@ const de: Translations = {
addFilter: 'Filter hinzufügen',
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
addFiltersHint:
'Fügen Sie unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die Ihren Kriterien entsprechen',
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
upgradePrompt:
'Finden Sie passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
'Finde 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:
'Klicken Sie auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
'Klicke 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: 'Versuchen Sie einen anderen Suchbegriff',
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
allFeaturesActive: 'Alle Filter sind aktiv',
removeFilterHint: 'Entfernen Sie einen Filter, um verfügbare Merkmale zu sehen',
removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
featureInfo: 'Über diese Daten',
aboutData: 'Über diese Daten',
aboutDataShort: 'Info',
@ -683,7 +679,7 @@ const de: Translations = {
replayTutorial: 'Interaktives Tutorial erneut abspielen',
clearAll: 'Alle löschen',
clearAllTitle: 'Alle Filter löschen?',
clearAllSavePrompt: 'Möchten Sie Ihre aktuellen Filter vor dem Löschen speichern?',
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
clearAllUpdatePrompt:
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
saveAndClear: 'Speichern & löschen',
@ -704,14 +700,12 @@ 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:
'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.',
'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.',
step1Title: 'Budget und Grundlagen',
step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
step2Title: 'Pendelweg',
@ -724,7 +718,7 @@ const de: Translations = {
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
step6Title: 'Energie',
step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
tip: 'Tipp: Wenn nichts passt, lockern Sie eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
},
// ── Travel Time ────────────────────────────────────
@ -761,14 +755,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: 'Verwenden Sie den Schieberegler, um Ihre maximale Pendelzeit festzulegen.',
sliderHint: 'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
},
// ── AI Filter ──────────────────────────────────────
aiFilter: {
describeIdealArea: 'Beschreiben Sie, wo Sie leben möchten',
describeIdealArea: 'Beschreibe, wo du leben möchtest',
aiSearch: 'KI-Suche',
describeHint: 'beschreiben Sie, wonach Sie suchen',
describeHint: 'beschreibe, wonach du suchst',
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',
@ -778,7 +772,7 @@ const de: Translations = {
generatingFilters: 'Filter werden generiert...',
refiningResults: 'Ergebnisse werden verfeinert...',
weeklyLimitReached:
'Sie haben das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
'Du hast das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
},
// ── Map Legend ─────────────────────────────────────
@ -842,8 +836,6 @@ 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',
@ -1276,7 +1268,7 @@ const de: Translations = {
upgrade: 'Upgraden',
redirecting: 'Weiterleitung…',
receiveNewsletter: 'Newsletter-E-Mails erhalten',
needHelp: 'Brauchen Sie Hilfe? Schreiben Sie uns an',
needHelp: 'Brauchst du Hilfe? Schreib uns an',
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
shareLinksTitle: 'Geteilte Links',
noShareLinksYet: 'Noch keine geteilten Links',
@ -1289,12 +1281,12 @@ const de: Translations = {
searches: 'Suchen',
noSavedSearches: 'Noch keine gespeicherten Suchen',
noSavedSearchesDesc:
'Speichern Sie Ihre Filter und Kartenansicht, um genau dort weiterzumachen, wo Sie aufgehört haben.',
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
clickToRename: 'Klicken zum Umbenennen',
notesPlaceholder: 'Notieren Sie Ihre Gedanken...',
notesPlaceholder: 'Notiere deine Gedanken...',
deleteSearch: 'Suche löschen',
deleteSearchConfirm:
'Möchten Sie diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
updating: 'Aktualisiere...',
},
@ -1309,7 +1301,7 @@ const de: Translations = {
copyInviteLink: 'Einladungslink kopieren',
adminInvitesTitle: 'Admin-Einladungen (100% Rabatt)',
referralInvitesTitle: 'Empfehlungseinladungen (30% Rabatt)',
yourInviteLinks: 'Ihre Einladungslinks',
yourInviteLinks: 'Deine Einladungslinks',
noInvitesYet: 'Noch keine Einladungen erstellt',
link: 'Link',
status: 'Status',
@ -1320,13 +1312,13 @@ const de: Translations = {
// ── Invite Page ────────────────────────────────────
invitePage: {
youreInvited: 'Sie sind eingeladen!',
youreInvited: 'Du bist eingeladen!',
specialOffer: 'Sonderangebot!',
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',
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',
propertyInfo: 'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
invalidInvite: 'Ungültige Einladung',
inviteAlreadyUsed: 'Einladung bereits verwendet',
@ -1334,13 +1326,13 @@ const de: Translations = {
invalidInviteLink: 'Ungültiger Einladungslink',
invalidInviteLinkDesc: 'Dieser Einladungslink ist ungültig oder abgelaufen.',
licenseActivated: 'Lizenz aktiviert!',
fullAccessGranted: 'Sie haben jetzt vollen Zugang zu Perfect Postcode.',
fullAccessGranted: 'Du hast jetzt vollen Zugang zu Perfect Postcode.',
activating: 'Wird aktiviert...',
activateLicense: 'Lizenz aktivieren',
claimDiscount: 'Rabatt einlösen',
registerToClaim: 'Registrieren zum Einlösen',
youAlreadyHaveLicense: 'Sie haben bereits eine Lizenz',
accountHasFullAccess: 'Ihr Konto hat bereits vollen Zugang.',
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
failedToValidate: 'Einladungslink konnte nicht validiert werden',
},

View file

@ -23,7 +23,6 @@ const en = {
total: 'Total',
min: 'min',
max: 'max',
minute: 'min',
or: 'or',
area: 'Area',
properties: 'Properties',
@ -34,17 +33,11 @@ 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 ───────────────────────────────────
@ -660,8 +653,7 @@ 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',
@ -680,8 +672,6 @@ 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 ───────────────────────────────
@ -816,8 +806,6 @@ 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,7 +25,6 @@ const fr: Translations = {
total: 'Total',
min: 'min',
max: 'max',
minute: 'min',
or: 'ou',
area: 'Zone',
properties: 'Propriétés',
@ -36,17 +35,11 @@ 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 ───────────────────────────────────
@ -325,7 +318,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",
@ -344,7 +337,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',
@ -709,8 +702,6 @@ 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 ───────────────────────────────
@ -848,13 +839,12 @@ 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',
@ -1307,8 +1297,7 @@ 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,9 +22,8 @@ const hi: Translations = {
none: 'कोई नहीं',
viewDataSource: 'डेटा स्रोत देखें',
total: 'कुल',
min: 'न्यूनतम',
min: 'िनट',
max: 'अधिकतम',
minute: 'मिनट',
or: 'या',
area: 'क्षेत्र',
properties: 'संपत्तियां',
@ -35,17 +34,11 @@ const hi: Translations = {
clickForDetails: 'विवरण के लिए क्लिक करें',
property: 'संपत्ति',
propertiesPlural: 'संपत्तियां',
bedsCount: '{{count}} बेड',
bedsCount_other: '{{count}} बेड',
bathsCount: '{{count}} बाथ',
bathsCount_other: '{{count}} बाथ',
places: 'स्थान',
noData: 'कोई डेटा नहीं',
allLow: 'सभी कम',
connectingToServer: 'सर्वर से कनेक्ट हो रहा है...',
closePane: 'पैन बंद करें',
yes: 'हाँ',
no: 'नहीं',
},
header: {
@ -677,8 +670,6 @@ const hi: Translations = {
ethnicity: 'जातीय समूह',
poiType: 'POI प्रकार',
party: 'पार्टी',
travelTimeKeywords:
'यात्रा यात्रा समय सफर आवागमन कार गाड़ी साइकिल बाइक पैदल चलना सार्वजनिक परिवहन परिवहन यातायात स्टेशन ट्रेन रेल मेट्रो ट्यूब बस मार्ग travel time journey commute car bicycle bike walking transit transport station tube train',
},
philosophy: {
@ -806,8 +797,6 @@ const hi: Translations = {
showAllStatsFallback:
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStats: 'सभी संपत्तियां दिखाएं',
closestStations: 'निकटतम स्टेशन',
noNearbyStations: '2 किमी के भीतर कोई ट्रेन या ट्यूब स्टेशन नहीं',
closestBlockingFilters: 'इस क्षेत्र को शामिल करने के निकटतम बदलाव',
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',

View file

@ -23,9 +23,8 @@ const hu: Translations = {
none: 'Egyik sem',
viewDataSource: 'Adatforrás megtekintése',
total: 'Összesen',
min: 'min.',
min: 'perc',
max: 'max.',
minute: 'perc',
or: 'vagy',
area: 'Terület',
properties: 'Ingatlanok',
@ -36,17 +35,11 @@ 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 ───────────────────────────────────
@ -453,8 +446,7 @@ 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':
@ -694,8 +686,6 @@ 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 ───────────────────────────────
@ -830,8 +820,6 @@ 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,17 +28,6 @@ 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,9 +18,7 @@ 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,7 +132,6 @@ 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',
@ -148,26 +147,18 @@ 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',
@ -175,7 +166,6 @@ 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',
@ -183,16 +173,12 @@ 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 generates a fallback for unknown chains', () => {
it('resolves POI category logos and rejects unknown icon categories', () => {
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('Tian Tian', '🛒')).toMatch(
/^data:image\/svg\+xml;charset=utf-8,/
expect(() => getPoiIconUrl('Unknown category', '🛒')).toThrow(
"Missing POI icon for category 'Unknown category'"
);
});

View file

@ -309,67 +309,9 @@ function inferPoiIconCategory(category: string, name?: string): string | undefin
}
}
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(
export function getPoiIconUrl(
category: string,
_emoji: string,
iconCategory?: string,
name?: string
): string {
@ -377,16 +319,11 @@ export function getPoiCategoryLogoUrl(
if (resolvedIconCategory && POI_CATEGORY_LOGOS[resolvedIconCategory]) {
return POI_CATEGORY_LOGOS[resolvedIconCategory];
}
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);
const categoryLogo = POI_CATEGORY_LOGOS[category];
if (!categoryLogo) {
throw new Error(`Missing POI icon for category '${category}'`);
}
return categoryLogo;
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */

View file

@ -1,65 +0,0 @@
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

@ -1,71 +0,0 @@
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,6 +135,7 @@ export interface ActualListing {
export interface ActualListingsResponse {
listings: ActualListing[];
total: number;
truncated: boolean;
}
export interface POICategoryGroup {
@ -197,19 +198,14 @@ export interface Property {
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
}
/** Shared paginated list of `Property` records returned by both
* `/api/hexagon-properties` and `/api/postcode-properties`. */
export interface PropertyListResponse {
export interface HexagonPropertiesResponse {
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,9 +4,6 @@ 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');
@ -153,49 +150,11 @@ 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,19 +7,15 @@ 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
@ -72,7 +68,6 @@ 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)
@ -81,14 +76,7 @@ class GreenspaceHandler(osmium.SimpleHandler):
try:
wkb_data = self._wkb_factory.create_multipolygon(a)
geom = wkb.loads(wkb_data, hex=True)
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,
)
except Exception:
return
if geom.is_empty or not geom.is_valid:
@ -125,11 +113,6 @@ 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,7 +14,6 @@ License: Open Government Licence v3.0
"""
import argparse
import logging
import tempfile
from pathlib import Path
@ -22,13 +21,10 @@ 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)
@ -80,7 +76,6 @@ 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]
@ -94,13 +89,7 @@ def _read_access_points(
if geom.is_empty:
continue
lng, lat = _to_wgs84.transform(geom.x, geom.y)
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,
)
except Exception:
continue
lats.append(lat)
@ -109,11 +98,6 @@ 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
@ -132,7 +116,6 @@ 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]
@ -146,25 +129,13 @@ def _read_site_centroids(
continue
centroid = geom.centroid
lng, lat = _to_wgs84.transform(centroid.x, centroid.y)
except (GEOSException, ValueError, AttributeError, TypeError):
error_skipped += 1
logger.warning(
"Failed to compute centroid for site_id=%s",
site_id,
exc_info=True,
)
except Exception:
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,12 +1,10 @@
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
@ -19,8 +17,6 @@ from pipeline.utils.england_geometry import (
load_england_polygon,
)
logger = logging.getLogger(__name__)
BATCH_SIZE = 50_000
MIN_OCCURENCE_COUNT = 20
@ -61,7 +57,6 @@ 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()
@ -125,14 +120,7 @@ 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 (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,
)
except Exception:
return None
return _representative_lat_lon(geom, self._england)
@ -197,11 +185,6 @@ 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, min_chain_locations=1)
pois = transform_grocery_retail_points(raw)
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, min_chain_locations=1)
pois = transform_grocery_retail_points(raw)
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, min_chain_locations=1)
pois = transform_grocery_retail_points(raw)
assert pois.select("category", "icon_category").to_dicts() == [
{"category": "Aldi", "icon_category": "Aldi"},
@ -118,29 +118,6 @@ def test_transform_grocery_retail_points_drops_invalid_rows():
}
)
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"},
]
assert pois["category"].to_list() == ["Waitrose"]

View file

@ -5,6 +5,7 @@ import polars as pl
from pipeline.utils.england_geometry import in_england_mask
DROP_CATEGORIES = {
# Street furniture & infrastructure
"amenity/advice",
@ -1164,44 +1165,49 @@ COOP_RETAILERS = {
"The Southern Co-operative",
}
MIN_GROCERY_CHAIN_LOCATIONS = 5
GROCERY_RETAILER_DISPLAY_NAME_OVERRIDES: dict[str, str] = {
"Cook": "COOK",
"Heron": "Heron Foods",
"Marks and Spencer": "M&S",
"Sainsburys": "Sainsbury's",
"The Co-operative Group": "Co-op",
}
GROCERY_FASCIA_ICON_NAMES: dict[str, str] = {
GROCERY_RETAILER_DISPLAY_NAMES: dict[str, str] = {
"Aldi": "Aldi",
"Aldi Local": "Aldi",
"Asda": "Asda",
"Asda Express": "Asda Express",
"Asda Living": "Asda Living",
"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",
"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},
}
GROCERY_FASCIA_ICON_NAMES: dict[str, str] = {
**GROCERY_RETAILER_DISPLAY_NAMES,
"Aldi Local": "Aldi",
"Asda Express": "Asda Express",
"Asda Living": "Asda Living",
"Asda PFS": "Asda PFS",
"Asda Supercentre": "Asda Supercentre",
"Asda Supermarket": "Asda Supermarket",
"Asda Superstore": "Asda Superstore",
"Cooltrader": "Heron Foods",
"Co-op Food": "Co-op",
"Cook": "COOK",
"Eurospar": "Spar",
"Eurospar PFS": "Spar",
"Heron": "Heron Foods",
"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",
@ -1215,44 +1221,41 @@ 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 ""
retailer = retailer.strip()
return GROCERY_RETAILER_DISPLAY_NAME_OVERRIDES.get(retailer, retailer)
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
def normalize_grocery_icon_category(fascia: str | None, retailer: str | None) -> str:
if fascia:
icon_name = GROCERY_FASCIA_ICON_NAMES.get(fascia.strip())
if icon_name is not None:
return icon_name
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
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"}
@ -1269,11 +1272,6 @@ 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)
)
@ -1286,14 +1284,6 @@ 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"])

View file

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

View file

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

View file

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

View file

@ -1,99 +0,0 @@
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,10 +13,8 @@ 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 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 DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;

View file

@ -268,32 +268,6 @@ 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::*;
@ -324,3 +298,29 @@ 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,10 +331,7 @@ 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).min(u32::MAX as f32) as u32)
.collect()
pop_f32.iter().map(|&val| val.max(0.0) as u32).collect()
} else {
vec![0; row_count]
};
@ -422,11 +419,11 @@ mod tests {
fn test_city_rows() -> [(&'static str, f32, f32, u32); 5] {
[
("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),
("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),
]
}
@ -506,7 +503,7 @@ mod tests {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(51.371_304, -0.101957, &cities),
nearest_display_city(51.3713049, -0.101957, &cities),
Some("London")
);
}
@ -516,7 +513,7 @@ mod tests {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(52.127_77, -0.0813098, &cities),
nearest_display_city(52.1277704, -0.0813098, &cities),
Some("Cambridge")
);
}

View file

@ -30,16 +30,6 @@ 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,22 +1014,6 @@ 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,7 +1,6 @@
#![allow(clippy::min_ident_chars)]
mod aggregation;
mod api_error;
mod auth;
mod bugsink;
mod checkout_sessions;

View file

@ -1,230 +1,80 @@
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::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};
use crate::parsing::require_bounds;
use crate::state::SharedState;
use super::travel_time::{parse_optional_travel, row_passes_travel_filters, TravelEntry};
const MAX_RESULTS: usize = 5000;
#[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>, ApiError> {
) -> Result<Json<ActualListingsResponse>, (StatusCode, String)> {
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).map_err(ApiError::from)?;
let (south, west, north, east) = require_bounds(params.bounds)?;
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 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;
// 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));
let mut listings: Vec<ActualListing> = row_indices
.iter()
.take(MAX_RESULTS)
.map(|&row| actual_listings.listing_at(row as usize))
.collect();
let travel_entries =
parse_optional_travel(params.travel.as_deref()).map_err(ApiError::BadRequest)?;
// 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 has_area_filters = !parsed_filters.is_empty()
|| !parsed_enum_filters.is_empty()
|| !parsed_poi_filters.is_empty()
|| !travel_entries.is_empty();
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 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)?;
ActualListingsResponse {
listings,
total,
truncated,
}
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
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,8 +1,6 @@
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::{Arc, Once};
static OUT_OF_RANGE_WARN: Once = Once::new();
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
@ -262,14 +260,6 @@ 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;
};
@ -382,10 +372,10 @@ pub(super) fn top_filter_exclusions(
.unwrap_or(f32::INFINITY);
let replace = path_score < current_score
|| (path_score.total_cmp(&current_score) == std::cmp::Ordering::Equal
|| (path_score == current_score
&& best_path
.as_ref()
.is_none_or(|current| path.len() < current.len()));
.map_or(true, |current| path.len() < current.len()));
if replace {
best_path = Some(path);
}
@ -404,7 +394,8 @@ pub(super) fn top_filter_exclusions(
exclusions.sort_by(|a, b| {
a.relative_difference
.total_cmp(&b.relative_difference)
.partial_cmp(&b.relative_difference)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.rejected_count.cmp(&a.rejected_count))
.then_with(|| a.name.cmp(&b.name))
});
@ -533,27 +524,6 @@ 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
@ -567,24 +537,40 @@ pub async fn get_hexagon_stats(
.map(|(row, _)| row);
// Fall back to geographic center if no row has travel data
let row = best_row.or_else(|| {
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;
matching_rows
.iter()
.copied()
.filter_map(|row| distance_sq(row).map(|d| (row, d)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(row, _)| row)
.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")
});
row.map(|row| state.data.postcode(row).to_string())
Some(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()
.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())
.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())
}
} else {
None

View file

@ -292,47 +292,6 @@ 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(())
}
@ -553,16 +512,11 @@ pub async fn get_invite(
.await
{
Ok(resp) if resp.status().is_success() => {
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();
}
}
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)
}
_ => None,
}
@ -735,6 +689,26 @@ 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>>,
@ -813,23 +787,3 @@ 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,12 +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::PLACES_LIMIT;
use crate::data::{normalize_search_text, slugify};
use crate::state::SharedState;
@ -42,6 +41,7 @@ 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>, ApiError> {
) -> Result<Json<PlacesResponse>, (StatusCode, String)> {
let state = shared.load_state();
let query = if params.q.is_empty() {
return Err(ApiError::BadRequest("'q' must not be empty".into()));
return Err((StatusCode::BAD_REQUEST, "'q' must not be empty".into()));
} else {
params.q
};
let limit = PLACES_LIMIT;
let limit = params.limit.unwrap_or(7).min(20);
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| ApiError::Internal(error.to_string()))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, 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>, ApiError> {
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
let state = shared.load_state();
let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
let (south, west, north, east) = require_bounds(params.bounds)?;
let category_filter: Option<rustc_hash::FxHashSet<u16>> = params
.categories
@ -109,7 +109,7 @@ pub async fn get_pois(
pois
})
.await
.map_err(|error| ApiError::Internal(error.to_string()))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, 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::{POSTCODE_SEARCH_OFFSET, PROPERTIES_LIMIT};
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET};
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::{Property, PropertyListResponse};
use super::properties::{HexagonPropertiesResponse, Property};
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
@ -24,6 +24,7 @@ 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>,
@ -35,7 +36,7 @@ pub async fn get_postcode_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodePropertiesParams>,
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
let state = shared.load_state();
let normalized = normalize_postcode(&params.postcode);
@ -150,7 +151,7 @@ pub async fn get_postcode_properties(
});
let total = matching_rows.len();
let limit = PROPERTIES_LIMIT;
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
let page_offset = params.offset.unwrap_or(0);
let truncated = total > page_offset + limit;
@ -182,9 +183,10 @@ pub async fn get_postcode_properties(
"GET /api/postcode-properties"
);
Ok(PropertyListResponse {
Ok(HexagonPropertiesResponse {
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::PROPERTIES_LIMIT;
use crate::consts::DEFAULT_PROPERTIES_LIMIT;
use crate::data::RenovationEvent;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
@ -29,6 +29,7 @@ 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>,
@ -61,13 +62,11 @@ 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 PropertyListResponse {
pub struct HexagonPropertiesResponse {
pub properties: Vec<Property>,
pub total: usize,
pub limit: usize,
pub offset: usize,
pub truncated: bool,
}
@ -184,7 +183,7 @@ pub async fn get_hexagon_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
) -> Result<Json<HexagonPropertiesResponse>, 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");
@ -274,7 +273,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 = PROPERTIES_LIMIT;
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
let offset = params.offset.unwrap_or(0);
let truncated = total > offset + limit;
@ -307,9 +306,10 @@ pub async fn get_hexagon_properties(
"GET /api/hexagon-properties"
);
Ok(PropertyListResponse {
Ok(HexagonPropertiesResponse {
properties,
total,
limit,
offset,
truncated,
})

View file

@ -4,7 +4,7 @@ use metrics::counter;
use rustc_hash::FxHashMap;
use tracing::error;
use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
use crate::consts::MAX_PRICE_HISTORY_POINTS;
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() > PRICE_HISTORY_POINTS_LIMIT {
let step = points.len() as f64 / PRICE_HISTORY_POINTS_LIMIT as f64;
points = (0..PRICE_HISTORY_POINTS_LIMIT)
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)
.map(|i| {
let idx = (i as f64 * step) as usize;
PricePoint {

4
uv.lock generated
View file

@ -1401,6 +1401,7 @@ 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" },
@ -1410,7 +1411,8 @@ requires-dist = [
{ name = "pandas", specifier = ">=2.0.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "plotly", specifier = ">=6.5.2" },
{ name = "polars", specifier = ">=1.37.1,<2.0.0" },
{ name = "polars" },
{ name = "polars", specifier = ">=1.37.1" },
{ name = "pyarrow", specifier = ">=15.0.0" },
{ name = "pyogrio", specifier = ">=0.12.1" },
{ name = "pyproj", specifier = ">=3.7.2" },