Compare commits

...

4 commits

Author SHA1 Message Date
c4423b6c9a Fix CI
Some checks failed
CI / Frontend (lint + typecheck) (push) Failing after 2m45s
CI / Python (lint + test) (push) Failing after 4m6s
CI / Rust (lint + test) (push) Successful in 4m6s
Build and publish Docker image / build-and-push (push) Failing after 4m52s
2026-03-24 22:30:53 +00:00
1dfa0e0009 fix zoopla bug 2026-03-24 22:30:49 +00:00
96dfdd7491 checkpoint finder 2026-03-24 22:30:37 +00:00
8616837c01 UI changes 2026-03-24 20:50:24 +00:00
19 changed files with 478 additions and 124 deletions

View file

@ -26,6 +26,13 @@ jobs:
- name: Download arcgis data for finder
run: uv run python -m pipeline.download.arcgis --output property-data/arcgis_data.parquet
- name: Install Docker CLI
run: |
ARCH=$(uname -m)
curl -fsSL "https://download.docker.com/linux/static/stable/${ARCH}/docker-27.5.1.tgz" \
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
docker --version
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3

View file

@ -4,12 +4,13 @@ from pathlib import Path
ARCGIS_PATH = os.environ.get("ARCGIS_PATH", "/data/arcgis_data.parquet")
DATA_DIR = Path("/app/data")
PAGE_SIZE = 24
DELAY_BETWEEN_PAGES = 0.5
DELAY_BETWEEN_OUTCODES = 1.0
DELAY_BETWEEN_PAGES = 0.3
DELAY_BETWEEN_OUTCODES = 0.5
MAX_RETRIES = 3
RETRY_BASE_DELAY = 2.0
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
SEED = 42
CHECKPOINT_INTERVAL = int(os.environ.get("CHECKPOINT_INTERVAL", "900")) # seconds
# Schedule: hour of day (UTC) to auto-run scrape. Set to -1 to disable.
SCHEDULE_HOUR = int(os.environ.get("SCHEDULE_HOUR", "3"))

View file

@ -351,7 +351,7 @@ def parse_search_results(html: str) -> list[dict]:
<div class="fw-medium text-primary fs-3">1 Bed Flat, Location, SW1Y</div>
<ul>...<li>1 Bed</li><li>1 Bath</li><li>Furnished</li>...</ul>
"""
soup = BeautifulSoup(html, "html.parser")
soup = BeautifulSoup(html, "lxml")
properties = []
# Property cards: <a class="pli search-property-card">
@ -486,7 +486,7 @@ def parse_property_detail(html: str) -> dict:
- Tables have "Rent PCM", "Deposit", "Bills Included", etc. (NOT bedrooms)
- Description in elements with class containing "description"
"""
soup = BeautifulSoup(html, "html.parser")
soup = BeautifulSoup(html, "lxml")
details: dict = {}
# --- Title from h1 ---
@ -624,9 +624,13 @@ def _resolve_outcode_postcodes(
pc_coords: dict[str, tuple[float, float]],
) -> list[str]:
"""Get all postcodes for an outcode from the postcode coordinates lookup."""
# ONSPD 7-char format: 4-char outcodes have no space before incode
# (e.g., "BH191AB"), while shorter outcodes do (e.g., "E14 5AB").
prefix = outcode + " "
# Also try without space for non-standard format (e.g. "SW1Y" matches "SW1Y 4AA")
return [pcd for pcd in pc_coords if pcd.startswith(prefix)]
results = [pcd for pcd in pc_coords if pcd.startswith(prefix)]
if not results and len(outcode) >= 4:
results = [pcd for pcd in pc_coords if pcd.startswith(outcode) and len(pcd) > len(outcode)]
return results
def transform_property(
@ -810,7 +814,7 @@ def search_outcode(
if detail_html:
detail_data = parse_property_detail(detail_html)
# Shorter delay for detail pages (within same outcode)
time.sleep(DELAY_BETWEEN_PAGES * 0.5)
time.sleep(0.15)
transformed = transform_property(
search_data,

View file

@ -10,6 +10,7 @@ dependencies = [
"fake-useragent>=2.2.0",
"prometheus-client",
"beautifulsoup4",
"lxml",
"playwright>=1.58.0",
"playwright-stealth>=2.0.2",
"camoufox>=0.4.11",

View file

@ -1,3 +1,4 @@
import json
import logging
import random
import threading
@ -11,6 +12,7 @@ import httpx
from constants import (
ARCGIS_PATH,
CHANNELS,
CHECKPOINT_INTERVAL,
DATA_DIR,
DELAY_BETWEEN_OUTCODES,
RELOAD_URL,
@ -233,6 +235,135 @@ def _merge_channel(
return all_properties, counts, total_dedup
# ---------------------------------------------------------------------------
# Checkpointing — save/resume partial results across crashes
# ---------------------------------------------------------------------------
def _checkpoint_meta_path():
return DATA_DIR / "checkpoint.json"
def _checkpoint_results_path(source: str, channel: str):
return DATA_DIR / f"checkpoint_{source}_{channel}.json"
def _save_checkpoint(
shuffled: list[str],
progress: _Progress,
source_results: dict[str, dict[str, list]],
active_sources: list[str],
) -> None:
"""Save per-source progress indices and partial results to disk.
Writes atomically (temp + rename) so a crash mid-write leaves the previous
checkpoint intact.
"""
snap = progress.snapshot()
meta = {
"seed": SEED,
"num_outcodes": len(shuffled),
"sources": {s: snap.get(s, 0) for s in active_sources},
"timestamp": time.time(),
}
# Write result files per source per channel
for source in active_sources:
results = source_results.get(source, {})
for ch_key in ("BUY", "RENT"):
props = results.get(ch_key, [])
path = _checkpoint_results_path(source, ch_key.lower())
tmp = path.with_suffix(".tmp")
try:
with open(tmp, "w") as f:
json.dump(props, f, default=str)
tmp.rename(path)
except Exception as e:
log.warning("Failed to write checkpoint %s: %s", path.name, e)
# Write metadata atomically
tmp = _checkpoint_meta_path().with_suffix(".tmp")
try:
with open(tmp, "w") as f:
json.dump(meta, f)
tmp.rename(_checkpoint_meta_path())
except Exception as e:
log.warning("Failed to write checkpoint metadata: %s", e)
return
total = sum(len(source_results.get(s, {}).get(ch, []))
for s in active_sources for ch in ("BUY", "RENT"))
log.info(
"Checkpoint saved: %s (%d properties)",
{s: snap.get(s, 0) for s in active_sources},
total,
)
def _load_checkpoint(
shuffled: list[str],
) -> tuple[dict[str, int], dict[str, dict[str, list]]] | None:
"""Load checkpoint if it exists and matches the current outcode list.
Returns (start_indices, loaded_results) or None if no valid checkpoint.
"""
path = _checkpoint_meta_path()
if not path.exists():
return None
try:
with open(path) as f:
meta = json.load(f)
except Exception:
log.warning("Checkpoint file corrupt, starting fresh")
_clear_checkpoint()
return None
if meta.get("seed") != SEED or meta.get("num_outcodes") != len(shuffled):
log.info("Checkpoint from different run configuration, discarding")
_clear_checkpoint()
return None
start_indices: dict[str, int] = {}
loaded_results: dict[str, dict[str, list]] = {}
for source, completed in meta.get("sources", {}).items():
start_indices[source] = completed
loaded_results[source] = {"BUY": [], "RENT": []}
for channel in ("buy", "rent"):
rpath = _checkpoint_results_path(source, channel)
if rpath.exists():
try:
with open(rpath) as f:
loaded_results[source][channel.upper()] = json.load(f)
except Exception:
log.warning(
"Checkpoint results for %s/%s corrupt, restarting %s",
source, channel, source,
)
start_indices[source] = 0
loaded_results[source] = {"BUY": [], "RENT": []}
break
elapsed_since = time.time() - meta.get("timestamp", 0)
log.info(
"Resuming from checkpoint (saved %.0fm ago): %s",
elapsed_since / 60,
start_indices,
)
return start_indices, loaded_results
def _clear_checkpoint() -> None:
"""Remove all checkpoint files after successful completion."""
for path in DATA_DIR.glob("checkpoint*"):
try:
path.unlink()
except Exception:
pass
def run_scrape(
outcodes: list[str],
pc_index: PostcodeSpatialIndex,
@ -293,15 +424,40 @@ def run_scrape(
progress = _Progress()
# --- Resume from checkpoint if available ---
start_indices: dict[str, int] = {}
checkpoint = _load_checkpoint(shuffled)
if checkpoint:
start_indices, loaded = checkpoint
source_to_results = {"rm": rm_results, "hk": hk_results, "or": or_results, "zp": zp_results}
for src, data in loaded.items():
if src in source_to_results:
for ch in ("BUY", "RENT"):
source_to_results[src][ch] = data.get(ch, [])
# Reassign in case references changed
rm_results = source_to_results["rm"]
hk_results = source_to_results["hk"]
or_results = source_to_results["or"]
zp_results = source_to_results["zp"]
# Pre-set progress for resumed sources
for src, idx in start_indices.items():
if idx > 0:
progress.update(src, idx)
# --- Source worker closures ---
# Each worker owns its client lifecycle and iterates all outcodes for both
# channels. On auth failure, it refreshes cookies and continues. On fatal
# failure, it marks itself as done and returns partial results.
def rm_worker():
rm_start = start_indices.get("rm", 0)
if rm_start > 0:
log.info("Rightmove resuming from outcode %d/%d", rm_start, len(shuffled))
client = make_client()
try:
for i, outcode in enumerate(shuffled):
if i < rm_start:
continue
try:
outcode_id = resolve_outcode_id(client, outcode)
except Exception as e:
@ -344,11 +500,16 @@ def run_scrape(
homecouk_enabled.set(0)
progress.update("hk", len(shuffled))
return
hk_start = start_indices.get("hk", 0)
if hk_start > 0:
log.info("home.co.uk resuming from outcode %d/%d", hk_start, len(shuffled))
client = make_homecouk_client(*hk_result)
log.info("home.co.uk scraping ENABLED")
homecouk_enabled.set(1)
try:
for i, outcode in enumerate(shuffled):
if i < hk_start:
continue
for ch_cfg in CHANNELS:
ch = ch_cfg["channel"]
try:
@ -403,11 +564,16 @@ def run_scrape(
openrent_enabled.set(0)
progress.update("or", len(shuffled))
return
or_start = start_indices.get("or", 0)
if or_start > 0:
log.info("OpenRent resuming from outcode %d/%d", or_start, len(shuffled))
client = make_openrent_client(*or_result)
log.info("OpenRent scraping ENABLED")
openrent_enabled.set(1)
try:
for i, outcode in enumerate(shuffled):
if i < or_start:
continue
# OpenRent is RENT-only
try:
props = openrent_search_outcode(
@ -470,14 +636,31 @@ def run_scrape(
progress.update("zp", len(shuffled))
return
zp_start = start_indices.get("zp", 0)
if zp_start > 0:
log.info("Zoopla resuming from outcode %d/%d", zp_start, len(shuffled))
try:
for i, outcode in enumerate(shuffled):
if i < zp_start:
continue
search_url = None
for ch_cfg in CHANNELS:
ch = ch_cfg["channel"]
# Build direct URL for second channel by swapping path
direct_url = None
if search_url:
if ch == "BUY":
direct_url = search_url.replace("/to-rent/", "/for-sale/")
else:
direct_url = search_url.replace("/for-sale/", "/to-rent/")
try:
props = zoopla_search_outcode(
page, outcode, ch, pc_index, pc_coords
props, result_url = zoopla_search_outcode(
page, outcode, ch, pc_index, pc_coords,
base_search_url=direct_url,
)
if result_url:
search_url = result_url
zp_results[ch].extend(props)
if props:
log.info("Zoopla %s: +%d properties", outcode, len(props))
@ -548,8 +731,15 @@ def run_scrape(
# --- Monitor progress while workers run ---
# Map source names to result dicts for checkpointing
source_results_map = {
"rm": rm_results, "hk": hk_results,
"or": or_results, "zp": zp_results,
}
scrape_start = time.time()
last_log = 0.0
last_checkpoint = time.time()
try:
while any(t.is_alive() for t in threads):
@ -577,8 +767,9 @@ def run_scrape(
status.zp_properties = len(zp_results["BUY"]) + len(zp_results["RENT"])
_sync_gauges()
# Log progress every 30 seconds
now = time.time()
# Log progress every 30 seconds
if now - last_log >= 30:
elapsed = now - scrape_start
per_source = ", ".join(
@ -595,10 +786,26 @@ def run_scrape(
)
last_log = now
# Save checkpoint periodically
if now - last_checkpoint >= CHECKPOINT_INTERVAL:
try:
_save_checkpoint(
shuffled, progress, source_results_map, active_sources,
)
except Exception as e:
log.warning("Checkpoint save failed: %s", e)
last_checkpoint = now
time.sleep(5)
except Exception as e:
log.exception("Monitor loop error: %s", e)
# Save final checkpoint before joining (in case merge/write fails)
try:
_save_checkpoint(shuffled, progress, source_results_map, active_sources)
except Exception:
pass
for t in threads:
t.join()
@ -645,6 +852,9 @@ def run_scrape(
total_dedup,
)
# Scrape completed successfully — clear checkpoint
_clear_checkpoint()
with status_lock:
status.state = "done"
status.finished_at = time.time()

View file

@ -263,6 +263,46 @@ def _ensure_not_challenged(page) -> None:
# ---------------------------------------------------------------------------
def _navigate_direct(page, url: str) -> bool:
"""Navigate directly to a Zoopla search URL (skipping the homepage flow).
Used to load the second channel (e.g., RENT after BUY) for the same outcode
by swapping the path component. Falls back gracefully returns False if
the page has no listings, so the caller can retry via the full search flow.
"""
try:
page.goto(url, wait_until="domcontentloaded", timeout=30000)
except Exception as e:
log.debug("Direct navigation failed: %s", e)
return False
_ensure_not_challenged(page)
# Wait for listing content to hydrate
try:
page.wait_for_function(
"""() => {
const cards = document.querySelectorAll(
'[data-testid="regular-listings"] > div'
);
if (cards.length === 0) return false;
for (const card of cards) {
const t = card.innerText || '';
if (t.includes('\\u00a3') && t.length > 50) return true;
}
return false;
}""",
timeout=8000,
)
except Exception:
# Check if the page has any listings at all
has_listings = page.query_selector('a[href*="/details/"]')
if not has_listings:
return False
time.sleep(1.5)
return True
def _navigate_search(page, outcode: str, channel: str) -> bool:
"""Navigate to search results for an outcode via the homepage search flow.
@ -270,12 +310,12 @@ def _navigate_search(page, outcode: str, channel: str) -> bool:
Raises TurnstileError if Cloudflare blocks us."""
# Navigate to homepage to reset search state
page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
time.sleep(0.5)
_ensure_not_challenged(page)
# Dismiss cookie consent (may reappear after navigation)
page.evaluate(_DISMISS_COOKIES_JS)
time.sleep(1)
time.sleep(0.3)
# Select Buy/Rent tab
if channel == "RENT":
@ -284,7 +324,7 @@ def _navigate_search(page, outcode: str, channel: str) -> bool:
)
if rent_tab:
rent_tab.click()
time.sleep(0.5)
time.sleep(0.2)
# Find and fill search input
search_input = page.query_selector(
@ -295,10 +335,10 @@ def _navigate_search(page, outcode: str, channel: str) -> bool:
return False
search_input.click()
time.sleep(0.3)
time.sleep(0.1)
search_input.fill("")
search_input.type(outcode, delay=60)
time.sleep(2)
time.sleep(1.2)
# Select first autocomplete suggestion
first_option = page.query_selector('[role="option"]')
@ -307,7 +347,7 @@ def _navigate_search(page, outcode: str, channel: str) -> bool:
return False
first_option.click()
time.sleep(0.5)
time.sleep(0.2)
# Click search button
search_btn = page.query_selector('button:has-text("Search")')
@ -326,6 +366,29 @@ def _navigate_search(page, outcode: str, channel: str) -> bool:
time.sleep(4)
_ensure_not_challenged(page)
# Wait for client-side hydration to populate listing content (prices, addresses).
# The structural container appears in server-rendered HTML before React hydrates
# the actual card content — extracting too early yields empty price/address fields.
try:
page.wait_for_function(
"""() => {
const cards = document.querySelectorAll(
'[data-testid="regular-listings"] > div'
);
if (cards.length === 0) return false;
for (const card of cards) {
const t = card.innerText || '';
if (t.includes('\\u00a3') && t.length > 50) return true;
}
return false;
}""",
timeout=8000,
)
except Exception:
# Content never appeared — extraction will likely fail but let it try
log.debug("Listing content hydration wait timed out — prices may not have rendered")
time.sleep(2)
return True
@ -437,8 +500,25 @@ def _paginate(page, total_results: int, channel: str) -> list[dict]:
try:
page.goto(next_url, wait_until="domcontentloaded", timeout=30000)
time.sleep(4)
_ensure_not_challenged(page)
# Wait for listing content instead of fixed sleep
try:
page.wait_for_function(
"""() => {
const cards = document.querySelectorAll(
'[data-testid="regular-listings"] > div'
);
if (cards.length === 0) return false;
for (const card of cards) {
const t = card.innerText || '';
if (t.includes('\\u00a3') && t.length > 50) return true;
}
return false;
}""",
timeout=8000,
)
except Exception:
time.sleep(1.5)
except TurnstileError:
raise
except Exception as e:
@ -546,9 +626,16 @@ def transform_property(
# Try outcode-level fallback
outcode = _extract_outcode(address)
if outcode:
# ONSPD 7-char format: 4-char outcodes have no space before incode
# (e.g., "BH191AB"), while shorter outcodes do (e.g., "E14 5AB").
# Check both formats to handle all outcode lengths.
prefix = outcode + " "
for pcd, coords in pc_coords.items():
if pcd.startswith(prefix):
if pcd.startswith(prefix) or (
len(outcode) >= 4
and pcd.startswith(outcode)
and len(pcd) > len(outcode)
):
postcode = pcd
lat, lng = coords
break
@ -608,17 +695,32 @@ def search_outcode(
channel: str,
pc_index: PostcodeSpatialIndex,
pc_coords: dict[str, tuple[float, float]],
) -> list[dict]:
base_search_url: str | None = None,
) -> tuple[list[dict], str | None]:
"""Search Zoopla for properties in one outcode.
Takes a live Camoufox Page (from launch_browser). Navigates through the
search flow, extracts listings from rendered DOM, and transforms to the
standard output schema.
If base_search_url is provided (from a previous channel search for the same
outcode), tries direct URL navigation first skipping the slow homepage
search flow. Falls back to full navigation if direct fails.
Returns (properties, search_url) where search_url can be passed to the next
channel call for this outcode.
Raises TurnstileError if Cloudflare blocks us mid-session.
"""
if not _navigate_search(page, outcode, channel):
return []
navigated = False
if base_search_url:
navigated = _navigate_direct(page, base_search_url)
if navigated:
log.debug("Zoopla %s %s: used direct URL navigation", outcode, channel)
if not navigated:
if not _navigate_search(page, outcode, channel):
return [], None
total_results = _get_result_count(page)
@ -632,7 +734,7 @@ def search_outcode(
"DOM selectors may need updating",
outcode, channel, total_results,
)
return []
return [], None
channel_label = "buy" if channel == "BUY" else "rent"
properties = []
@ -646,10 +748,13 @@ def search_outcode(
dropped += 1
if dropped and not properties:
# Log a sample raw listing to diagnose which fields are missing
sample = raw_listings[0] if raw_listings else {}
log.debug(
"Zoopla %s %s: extracted %d raw listings but all %d dropped in transform "
"(no price/postcode/coords)",
"(no price/postcode/coords). Sample raw: price=%s address=%r",
outcode, channel, len(raw_listings), dropped,
sample.get("price"), sample.get("address", ""),
)
elif dropped > len(raw_listings) // 2:
log.debug(
@ -657,4 +762,4 @@ def search_outcode(
outcode, channel, dropped, len(raw_listings),
)
return properties
return properties, page.url

View file

@ -130,12 +130,7 @@ export default function FeatureBrowser({
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
<FeatureLabel feature={f} size="sm" description={f.description} />
</div>
<FeatureActions
feature={f}
@ -174,15 +169,16 @@ export default function FeatureBrowser({
<IconButton
onClick={() => setTravelInfoMode(mode)}
title="Feature info"
size="md"
>
<InfoIcon className="w-3.5 h-3.5" />
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
</div>
</div>

View file

@ -112,8 +112,8 @@ function SliderLabels({
onValueChange?: (v: [number, number]) => void;
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
const rightPct = ((value[1] - min) / range) * 100;
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
const labels = displayValues || value;
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
@ -133,7 +133,7 @@ function SliderLabels({
<EditableLabel
value={labels[0]}
formatted={minLabel}
onCommit={(v) => onValueChange([Math.min(v, labels[1]), labels[1]])}
onCommit={(v) => onValueChange([v, Math.max(v, labels[1])])}
prefix={feature.prefix}
suffix={feature.suffix}
style={{ left: `${leftPct}%`, transform: leftTranslate }}
@ -141,7 +141,7 @@ function SliderLabels({
<EditableLabel
value={labels[1]}
formatted={maxLabel}
onCommit={(v) => onValueChange([labels[0], Math.max(v, labels[0])])}
onCommit={(v) => onValueChange([Math.min(labels[0], v), v])}
prefix={feature.prefix}
suffix={feature.suffix}
style={{ left: `${rightPct}%`, transform: rightTranslate }}
@ -184,6 +184,7 @@ interface FiltersProps {
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
@ -220,6 +221,7 @@ export default memo(function Filters({
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest,
aiFilterLoading,
aiFilterError,
@ -415,7 +417,7 @@ export default memo(function Filters({
</div>
</div>
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto overflow-x-hidden">
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}
@ -470,9 +472,14 @@ export default memo(function Filters({
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
@ -592,7 +599,7 @@ export default memo(function Filters({
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={scale ? displayValue : undefined}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}

View file

@ -1,5 +1,5 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
@ -212,6 +212,9 @@ export default memo(function Map({
maxBounds={MAP_BOUNDS}
>
<DeckOverlay layers={layers} getTooltip={null} />
{!screenshotMode && (
<ScaleControl position="bottom-left" maxWidth={100} unit="metric" />
)}
</MapGL>
{screenshotMode ? (
ogMode ? (

View file

@ -105,8 +105,8 @@ export default function MapPage({
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 500, 'right');
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
@ -141,6 +141,7 @@ export default function MapPage({
handleDragStart,
handleDragChange,
handleDragEnd,
handleDragEndNoCommit,
handleTogglePin,
handleSetPin,
handleCancelPin,
@ -204,12 +205,8 @@ export default function MapPage({
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string) => {
travelTime.handleSetDestination(index, slug, label);
const entry = travelTime.entries[index];
if (entry) {
handleSetPin(`tt_${entry.mode}_${slug}`);
}
},
[travelTime.handleSetDestination, travelTime.entries, handleSetPin]
[travelTime.handleSetDestination]
);
const handleTravelTimeRemoveEntry = useCallback(
@ -223,6 +220,14 @@ export default function MapPage({
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
);
const handleTravelTimeDragEnd = useCallback(
(index: number) => {
const dv = handleDragEndNoCommit();
if (dv) travelTime.handleTimeRangeChange(index, dv);
},
[handleDragEndNoCommit, travelTime.handleTimeRangeChange]
);
const license = useLicense();
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
@ -571,6 +576,7 @@ export default function MapPage({
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}

View file

@ -19,9 +19,14 @@ interface TravelTimeCardProps {
timeRange: [number, number] | null;
useBest: boolean;
isPinned: boolean;
isActive: boolean;
dragValue: [number, number] | null;
onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void;
onDragStart: () => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onToggleBest: () => void;
onRemove: () => void;
}
@ -33,9 +38,14 @@ export function TravelTimeCard({
timeRange,
useBest,
isPinned,
isActive,
dragValue,
onTogglePin,
onSetDestination,
onTimeRangeChange,
onDragStart,
onDragChange,
onDragEnd,
onToggleBest,
onRemove,
}: TravelTimeCardProps) {
@ -52,13 +62,13 @@ export function TravelTimeCard({
const sliderMin = 0;
const sliderMax = 120;
const displayRange = timeRange ?? [sliderMin, sliderMax];
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
const ModeIcon = MODE_ICONS[mode];
return (
<div
className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-2 px-2 py-2 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
{/* Header */}
<div className="flex items-center justify-between">
@ -130,7 +140,9 @@ export function TravelTimeCard({
max={sliderMax}
step={1}
value={[displayRange[0], displayRange[1]]}
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart()}
onPointerUp={() => onDragEnd()}
/>
<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])} min</span>

View file

@ -23,7 +23,7 @@ export function FeatureActions({
<div className="flex items-center gap-0.5 shrink-0">
{feature.detail && onShowInfo && (
<IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md">
<InfoIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
)}
<IconButton
@ -32,7 +32,7 @@ export function FeatureActions({
active={isPinned}
size="md"
>
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
<EyeIcon filled={isPinned} className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
{onAdd && (
<button
@ -40,7 +40,7 @@ export function FeatureActions({
title="Add filter"
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
)}
{onRemove && (

View file

@ -14,6 +14,7 @@ interface FeatureLabelProps {
onShowInfo?: (feature: FeatureMeta) => void;
className?: string;
size?: 'xs' | 'sm';
description?: string;
}
export function FeatureLabel({
@ -21,6 +22,7 @@ export function FeatureLabel({
onShowInfo,
className = '',
size = 'xs',
description,
}: FeatureLabelProps) {
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const iconClass = 'w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0';
@ -31,12 +33,8 @@ export function FeatureLabel({
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
: null;
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
>
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}
const nameContent = (
<>
<span
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
>
@ -56,6 +54,23 @@ export function FeatureLabel({
<InfoIcon className="w-3.5 h-3.5" />
</button>
)}
</>
);
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
>
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}
{description ? (
<div className="min-w-0">
<div className="flex items-center gap-1">{nameContent}</div>
<span className="text-xs text-warm-400 dark:text-warm-500 block">{description}</span>
</div>
) : (
nameContent
)}
</div>
);
}

View file

@ -24,7 +24,7 @@ import {
POI_CLUSTER_MAX_ZOOM,
} from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
import type { TravelTimeEntry } from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
interface UseDeckLayersProps {
@ -120,9 +120,6 @@ export function useDeckLayers({
const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode;
const travelTimeEntriesRef = useRef(travelTimeEntries);
travelTimeEntriesRef.current = travelTimeEntries;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
@ -302,28 +299,6 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const entries = travelTimeEntriesRef.current;
// Dim-filter: all travel entries with timeRange dim hexagons outside range
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
const fk = travelFieldKey(entry);
const modeVal = d[`avg_${fk}`];
if (
modeVal == null ||
(modeVal as number) < entry.timeRange[0] ||
(modeVal as number) > entry.timeRange[1]
) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
number,
number,
number,
number,
];
}
}
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
@ -425,28 +400,6 @@ export function useDeckLayers({
getFillColor: (f) => {
const d = f.properties;
const dark = isDarkRef.current;
const entries = travelTimeEntriesRef.current;
// Dim-filter: all travel entries with timeRange dim postcodes outside range
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
const fk = travelFieldKey(entry);
const modeVal = d[`avg_${fk}`];
if (
modeVal == null ||
(modeVal as number) < entry.timeRange[0] ||
(modeVal as number) > entry.timeRange[1]
) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
number,
number,
number,
number,
];
}
}
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;

View file

@ -120,6 +120,20 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
dragValueRef.current = null;
}, []);
/** End drag without committing to filters — caller handles the commit (e.g. travel time). */
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
if (pendingDragRef.current) {
pendingDragRef.current = null;
return null;
}
const dv = dragValueRef.current;
setActiveFeature(null);
setDragValue(null);
dragActiveRef.current = null;
dragValueRef.current = null;
return dv;
}, []);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(newFilters);
setActiveFeature(null);
@ -159,6 +173,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
handleDragStart,
handleDragChange,
handleDragEnd,
handleDragEndNoCommit,
handleTogglePin,
handleSetPin,
handleCancelPin,

View file

@ -81,25 +81,34 @@ export function useMapData({
);
// Build the travel param string from entries with destinations.
// timeRange is NOT included — range filtering is handled purely client-side
// (dimming in useDeckLayers) so slider changes never trigger server refetches.
const travelParam = useMemo((): string => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
segments.push(seg);
}
return segments.join('|');
}, [travelTimeEntries]);
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
// When excludeFieldKey is set, that entry's time range is omitted (for drag preview).
const buildTravelParam = useCallback(
(excludeFieldKey?: string): string => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
if (entry.timeRange && !isExcluded) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
segments.push(seg);
}
return segments.join('|');
},
[travelTimeEntries]
);
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
// Keep activeFeatureRef in sync
useEffect(() => {
activeFeatureRef.current = activeFeature;
}, [activeFeature]);
// Drag prefetch: when activeFeature starts, fetch data excluding that filter
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
// For regular filters: excludes the filter from the filter string.
// For travel time: excludes the time range from that entry's travel param segment.
useEffect(() => {
if (!activeFeature || !bounds) return;
@ -108,11 +117,14 @@ export function useMapData({
const filtersStr = buildFilterString(filters, features, activeFeature);
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const isTravelTimeDrag = activeFeature.startsWith('tt_');
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', activeFeature);
if (dragTravelParam) params.set('travel', dragTravelParam);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -129,7 +141,7 @@ export function useMapData({
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', activeFeature);
if (travelParam) params.set('travel', travelParam);
if (dragTravelParam) params.set('travel', dragTravelParam);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
@ -147,7 +159,7 @@ export function useMapData({
dragAbortRef.current = null;
}
};
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam]);
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam, buildTravelParam]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {

View file

@ -24,10 +24,11 @@ export function usePaneResize(
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current) return;
const resolvedMax = maxWidth <= 1 ? window.innerWidth * maxWidth : maxWidth;
const newWidth =
side === 'left'
? Math.min(maxWidth, Math.max(minWidth, e.clientX))
: Math.min(maxWidth, Math.max(minWidth, window.innerWidth - e.clientX));
? Math.min(resolvedMax, Math.max(minWidth, e.clientX))
: Math.min(resolvedMax, Math.max(minWidth, window.innerWidth - e.clientX));
setWidth(newWidth);
},
[side, minWidth, maxWidth]

View file

@ -201,6 +201,13 @@ h3 {
}
}
/* MapLibre scale control — dark mode */
.dark .maplibregl-ctrl-scale {
border-color: #d6d3d1;
color: #d6d3d1;
background-color: rgba(28, 25, 23, 0.5);
}
/* Hide scrollbar for pill groups on mobile */
.scrollbar-hide {
-ms-overflow-style: none;

View file

@ -10,7 +10,6 @@ import {
BUFFER_MULTIPLIER,
ENUM_PALETTE,
} from './consts';
const ROAD_OPACITY = 0.4;
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {