Can't even keep track anymore
This commit is contained in:
parent
dccc1e439d
commit
3a3f899ea2
50 changed files with 1144 additions and 560 deletions
|
|
@ -22,9 +22,10 @@ COPY --from=frontend /app/frontend/dist ./frontend/dist/
|
|||
|
||||
COPY property-data/wide.parquet ./data/
|
||||
COPY property-data/filtered_uk_pois.parquet ./data/
|
||||
COPY property-data/places.parquet ./data/
|
||||
COPY property-data/uk.pmtiles ./data/
|
||||
COPY manual-data/postcode_boundaries ./data/postcode_boundaries/
|
||||
|
||||
EXPOSE 8001
|
||||
ENTRYPOINT ["./property-map-server"]
|
||||
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries"]
|
||||
CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--places", "/app/data/places.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries"]
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ OFSTED := $(DATA_DIR)/ofsted.parquet
|
|||
NAPTAN := $(DATA_DIR)/naptan.parquet
|
||||
BROADBAND := $(DATA_DIR)/broadband.parquet
|
||||
SCHOOL_PROX := $(DATA_DIR)/school_proximity.parquet
|
||||
RENTAL := $(DATA_DIR)/rental_prices.parquet
|
||||
GEOSURE_DIR := $(DATA_DIR)/geosure
|
||||
GEOSURE := $(DATA_DIR)/geosure.parquet
|
||||
INSPIRE_DIR := $(DATA_DIR)/inspire
|
||||
|
|
@ -44,6 +45,7 @@ UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet
|
|||
PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries
|
||||
TRANSIT_DIR := $(DATA_DIR)/transit
|
||||
TRANSIT_STAMP := $(TRANSIT_DIR)/.done
|
||||
GREENSPACE := $(DATA_DIR)/greenspace_water.parquet
|
||||
|
||||
# Sentinel files for directory targets (Make doesn't track directories well)
|
||||
GEOSURE_STAMP := $(GEOSURE_DIR)/.done
|
||||
|
|
@ -55,9 +57,9 @@ PMTILES_VERSION := 1.22.3
|
|||
|
||||
.PHONY: prepare wide tiles \
|
||||
download-arcgis download-price-paid download-deprivation download-ethnicity \
|
||||
download-naptan download-pois download-ofsted download-broadband \
|
||||
download-naptan download-pois download-ofsted download-broadband download-rental-prices \
|
||||
download-postcodes download-geosure download-noise download-inspire \
|
||||
download-oa-boundaries download-uprn-lookup download-transit-network \
|
||||
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace \
|
||||
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
||||
transform-school-proximity transform-geosure transform-postcode-boundaries \
|
||||
generate-postcode-boundaries \
|
||||
|
|
@ -76,11 +78,13 @@ download-ofsted: $(OFSTED)
|
|||
download-broadband: $(BROADBAND)
|
||||
download-postcodes: $(POSTCODES)
|
||||
download-geosure: $(GEOSURE_STAMP)
|
||||
download-rental-prices: $(RENTAL)
|
||||
download-noise: $(NOISE)
|
||||
download-inspire: $(INSPIRE_STAMP)
|
||||
download-oa-boundaries: $(OA_BOUNDARIES)
|
||||
download-uprn-lookup: $(UPRN_LOOKUP)
|
||||
download-transit-network: $(TRANSIT_STAMP)
|
||||
download-greenspace: $(GREENSPACE)
|
||||
transform-pois: $(POIS_FILTERED)
|
||||
transform-epc-pp: $(EPC_PP)
|
||||
transform-crime: $(CRIME)
|
||||
|
|
@ -159,6 +163,12 @@ $(TRANSIT_STAMP):
|
|||
uv run python -m pipeline.download.transit_network --output $(TRANSIT_DIR)
|
||||
@touch $@
|
||||
|
||||
$(RENTAL):
|
||||
uv run python -m pipeline.download.rental_prices --output $@
|
||||
|
||||
$(GREENSPACE):
|
||||
uv run python -m pipeline.download.greenspace_water --output $@
|
||||
|
||||
# ── Journey times (requires TFL_API_KEY) ──────────────────────────────────────
|
||||
|
||||
$(JT_BANK):
|
||||
|
|
@ -231,7 +241,7 @@ $(PC_BOUNDARIES):
|
|||
# ── Final merge ───────────────────────────────────────────────────────────────
|
||||
|
||||
$(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \
|
||||
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE)
|
||||
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL)
|
||||
uv run python -m pipeline.transform.merge \
|
||||
--epc-pp $(EPC_PP) \
|
||||
--arcgis $(ARCGIS) \
|
||||
|
|
@ -245,6 +255,7 @@ $(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA)
|
|||
--school-proximity $(SCHOOL_PROX) \
|
||||
--broadband $(BROADBAND) \
|
||||
--geosure $(GEOSURE) \
|
||||
--rental-prices $(RENTAL) \
|
||||
--output $@
|
||||
|
||||
# ── Price estimation (post-merge) ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ tasks:
|
|||
cmds:
|
||||
- uv run python -m pipeline.download.map_assets --output frontend/public/assets
|
||||
|
||||
download:greenspace:
|
||||
desc: Extract park/water polygons from OSM PBF for postcode boundary trimming
|
||||
download:places:
|
||||
desc: Extract place names from OSM PBF
|
||||
cmds:
|
||||
- uv run python -m pipeline.download.greenspace_water --output data/greenspace_water.parquet {{.CLI_ARGS}}
|
||||
- uv run python -m pipeline.download.places --output ./property_data/places.parquet {{.CLI_ARGS}}
|
||||
|
||||
test:
|
||||
desc: Run all tests (Python and Rust)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
services:
|
||||
server:
|
||||
image: rust:1.84
|
||||
init: true
|
||||
working_dir: /app/server-rs
|
||||
command: >
|
||||
bash -c "
|
||||
cargo install cargo-watch &&
|
||||
cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries'
|
||||
cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries'
|
||||
"
|
||||
ports:
|
||||
- "8001:8001"
|
||||
|
|
@ -18,7 +19,6 @@ services:
|
|||
- cargo-registry:/usr/local/cargo/registry
|
||||
- cargo-target:/app/server-rs/target
|
||||
- ./property-data:/app/data:ro
|
||||
|
||||
environment:
|
||||
POCKETBASE_URL: http://pocketbase:8090
|
||||
POCKETBASE_ADMIN_EMAIL: ${POCKETBASE_ADMIN_EMAIL:-}
|
||||
|
|
@ -31,6 +31,7 @@ services:
|
|||
condition: service_healthy
|
||||
|
||||
screenshot:
|
||||
init: true
|
||||
build: /volumes/syncthing/Projects/property-map/screenshot
|
||||
environment:
|
||||
APP_URL: http://frontend:3001
|
||||
|
|
@ -54,6 +55,7 @@ services:
|
|||
count: 1
|
||||
|
||||
frontend:
|
||||
init: true
|
||||
image: node:22-slim
|
||||
working_dir: /app/frontend
|
||||
command: >
|
||||
|
|
@ -73,6 +75,7 @@ services:
|
|||
PB_PROXY_TARGET: http://pocketbase:8090
|
||||
|
||||
pocketbase:
|
||||
init: true
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
ports:
|
||||
- "8090:8090"
|
||||
|
|
@ -88,6 +91,7 @@ services:
|
|||
start_period: 5s
|
||||
|
||||
r5:
|
||||
init: true
|
||||
build: ./r5-java
|
||||
ports:
|
||||
- "8004:8003"
|
||||
|
|
@ -96,8 +100,10 @@ services:
|
|||
volumes:
|
||||
- r5-network:/data/network
|
||||
- ./property-data/transit:/data/transit:ro
|
||||
- ./property-data/transit/raw:/data/transit-raw:ro
|
||||
environment:
|
||||
DATA_DIR: /data/transit
|
||||
OSM_DIR: /data/transit-raw
|
||||
NETWORK_CACHE_DIR: /data/network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8003/health"]
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export default function App() {
|
|||
initialFilters={urlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories || new Set()}
|
||||
initialTab={urlState.tab || 'pois'}
|
||||
initialTab={urlState.tab || 'area'}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={null}
|
||||
|
|
@ -249,7 +249,7 @@ export default function App() {
|
|||
initialFilters={urlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories || new Set()}
|
||||
initialTab={urlState.tab || 'pois'}
|
||||
initialTab={urlState.tab || 'area'}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={pendingInfoFeature}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
export default function DataSources({ onNavigate }: { onNavigate: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onNavigate}
|
||||
className="absolute bottom-2 right-2 bg-white/90 dark:bg-warm-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
|
||||
>
|
||||
Data Sources
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
const DATA_SOURCES = [
|
||||
{
|
||||
id: 'price-paid',
|
||||
name: 'Price Paid Data',
|
||||
origin: 'HM Land Registry',
|
||||
use: 'Complete historical property sale prices for England and Wales. Used for the last known sale price of each property.',
|
||||
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'epc',
|
||||
name: 'Energy Performance Certificates (EPC)',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
|
||||
optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
|
||||
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'nspl',
|
||||
name: 'National Statistics Postcode Lookup (NSPL)',
|
||||
origin: 'ONS / ArcGIS',
|
||||
use: 'Maps postcodes to latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.',
|
||||
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'iod',
|
||||
name: 'English Indices of Deprivation 2025',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Relative deprivation scores for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.',
|
||||
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ethnicity',
|
||||
name: 'Population by Ethnicity (2021 Census)',
|
||||
origin: 'ONS',
|
||||
use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
|
||||
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'crime',
|
||||
name: 'Street-level Crime Data',
|
||||
origin: 'data.police.uk',
|
||||
use: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
|
||||
url: 'https://data.police.uk/data/',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'tfl-journey-times',
|
||||
name: 'TfL Journey Times',
|
||||
origin: 'Transport for London',
|
||||
use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.",
|
||||
url: 'https://api-portal.tfl.gov.uk/',
|
||||
license: 'Powered by TfL Open Data',
|
||||
},
|
||||
{
|
||||
id: 'osm-pois',
|
||||
name: 'OpenStreetMap POIs',
|
||||
origin: 'OpenStreetMap contributors / Geofabrik',
|
||||
use: 'Points of interest extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.',
|
||||
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
||||
license: 'Open Data Commons Open Database License (ODbL)',
|
||||
},
|
||||
{
|
||||
id: 'naptan',
|
||||
name: 'NaPTAN (Public Transport Stops)',
|
||||
origin: 'Department for Transport',
|
||||
use: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.',
|
||||
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'noise',
|
||||
name: 'Defra Noise Mapping',
|
||||
origin: 'Defra / Environment Agency',
|
||||
use: 'Strategic noise mapping Round 4 (2022) for road, rail, and airport sources. Lden (day-evening-night 24h weighted average) at 10m grid resolution, modelled at 4m above ground. Sampled at postcode centroids via WCS GeoTIFF tiles.',
|
||||
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ofsted',
|
||||
name: 'Ofsted School Inspections',
|
||||
origin: 'Ofsted',
|
||||
use: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
|
||||
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'broadband',
|
||||
name: 'Ofcom Broadband Performance',
|
||||
origin: 'Ofcom',
|
||||
use: 'Fixed broadband coverage and speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.',
|
||||
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'geosure',
|
||||
name: 'GeoSure Ground Stability',
|
||||
origin: 'Ordnance Survey',
|
||||
use: 'Ground stability hazard ratings on a 5km hex grid covering Great Britain. Six risk categories (collapsible deposits, compressible ground, landslides, running sand, shrink-swell, and soluble rocks) rated Low, Moderate, or Significant. Spatial-joined to postcodes via centroid intersection.',
|
||||
url: 'https://osdatahub.os.uk/downloads/open/GeoSure',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'council-tax',
|
||||
name: 'Council Tax Levels 2025-26',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.',
|
||||
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DataSourcesPage() {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
function handleHash() {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
if (hash && DATA_SOURCES.some((s) => s.id === hash)) {
|
||||
setHighlightedId(hash);
|
||||
// Scroll after a brief delay to allow render
|
||||
setTimeout(() => {
|
||||
cardRefs.current[hash]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
} else {
|
||||
setHighlightedId(null);
|
||||
}
|
||||
}
|
||||
handleHash();
|
||||
window.addEventListener('hashchange', handleHash);
|
||||
return () => window.removeEventListener('hashchange', handleHash);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">Data Sources</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
This application combines {DATA_SOURCES.length} open datasets covering property prices,
|
||||
energy performance, transport, demographics, crime, environment, and more.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{DATA_SOURCES.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
ref={(el) => {
|
||||
cardRefs.current[source.id] = el;
|
||||
}}
|
||||
className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${
|
||||
highlightedId === source.id
|
||||
? 'border-teal-400 ring-2 ring-teal-400'
|
||||
: 'border-warm-200 dark:border-navy-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
{source.name}
|
||||
</h2>
|
||||
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
|
||||
{source.license}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
||||
Source: {source.origin}
|
||||
</p>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">{source.use}</p>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
|
||||
>
|
||||
{source.url}
|
||||
</a>
|
||||
{'optOutUrl' in source && source.optOutUrl && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={source.optOutUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Opt out of public disclosure
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
||||
Attribution
|
||||
</h2>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
<li>Contains HM Land Registry data © Crown copyright and database right 2025.</li>
|
||||
<li>
|
||||
Contains public sector information licensed under the{' '}
|
||||
<a
|
||||
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Open Government Licence v3.0
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Contains OS data © Crown copyright and database rights 2025.</li>
|
||||
<li>Powered by TfL Open Data.</li>
|
||||
<li>
|
||||
Contains data from{' '}
|
||||
<a
|
||||
href="https://www.openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
© OpenStreetMap contributors
|
||||
</a>
|
||||
, available under the{' '}
|
||||
<a
|
||||
href="https://opendatacommons.org/licenses/odbl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Open Data Commons Open Database License (ODbL)
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,6 +117,14 @@ const DATA_SOURCES = [
|
|||
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ons-rental',
|
||||
name: 'Private Rental Market Statistics',
|
||||
origin: 'ONS / Valuation Office Agency',
|
||||
use: 'Median monthly private rental prices by local authority and bedroom category (Oct 2022 - Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
|
||||
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
];
|
||||
|
||||
interface FAQItem {
|
||||
|
|
|
|||
53
frontend/src/components/map/AiFilterInput.tsx
Normal file
53
frontend/src/components/map/AiFilterInput.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { memo, useState, useCallback } from 'react';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
interface AiFilterInputProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onSubmit: (query: string) => void;
|
||||
}
|
||||
|
||||
export default memo(function AiFilterInput({ loading, error, onSubmit }: AiFilterInputProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed || loading) return;
|
||||
onSubmit(trimmed);
|
||||
},
|
||||
[query, loading, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<form onSubmit={handleSubmit} className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Describe your ideal property..."
|
||||
className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5"
|
||||
>
|
||||
{loading ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'AI'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400 truncate" title={error}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { memo, useState, useMemo } from 'react';
|
||||
import { memo, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { LightbulbIcon } from '../ui/icons';
|
||||
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
|
|
@ -14,6 +14,7 @@ import InfoPopup from '../ui/InfoPopup';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import AiFilterInput from './AiFilterInput';
|
||||
import FeatureBrowser from './FeatureBrowser';
|
||||
import { TravelTimeCard } from './TravelTimeCard';
|
||||
import type { TransportMode } from '../../hooks/useTravelTime';
|
||||
|
|
@ -80,6 +81,9 @@ interface FiltersProps {
|
|||
onTravelTimeSetDestination: (lat: number, lon: number, label: string) => void;
|
||||
onTravelTimeModeChange: (mode: TransportMode) => void;
|
||||
onTravelTimeRangeChange: (range: [number, number]) => void;
|
||||
aiFilterLoading: boolean;
|
||||
aiFilterError: string | null;
|
||||
onAiFilterSubmit: (query: string) => void;
|
||||
}
|
||||
|
||||
export default memo(function Filters({
|
||||
|
|
@ -111,13 +115,27 @@ export default memo(function Filters({
|
|||
onTravelTimeSetDestination,
|
||||
onTravelTimeModeChange,
|
||||
onTravelTimeRangeChange,
|
||||
aiFilterLoading,
|
||||
aiFilterError,
|
||||
onAiFilterSubmit,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
|
||||
const handleAddAndScroll = useCallback(
|
||||
(name: string) => {
|
||||
onAddFilter(name);
|
||||
requestAnimationFrame(() => {
|
||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
},
|
||||
[onAddFilter]
|
||||
);
|
||||
const enabledGroups = useMemo(
|
||||
() => groupFeaturesByCategory(enabledFeatureList),
|
||||
[enabledFeatureList]
|
||||
|
|
@ -134,15 +152,18 @@ export default memo(function Filters({
|
|||
}, [features]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.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 flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
</button>
|
||||
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div className="shrink-0 border-b border-warm-200 dark:border-navy-700">
|
||||
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} onSubmit={onAiFilterSubmit} />
|
||||
<div className="flex items-center gap-2 px-3 pb-2">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.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 flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
|
|
@ -176,12 +197,9 @@ export default memo(function Filters({
|
|||
)}
|
||||
|
||||
{enabledFeatureList.length === 0 && !travelTimeEnabled && (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No active filters"
|
||||
description="Browse features below and click + to add a filter"
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
Browse features below and click + to add a filter
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enabledGroups.map((group) => {
|
||||
|
|
@ -304,7 +322,7 @@ export default memo(function Filters({
|
|||
availableFeatures={availableFeatures}
|
||||
allFeatures={features}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onAddFilter={onAddFilter}
|
||||
onAddFilter={handleAddAndScroll}
|
||||
onTogglePin={onTogglePin}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
|
|
|
|||
|
|
@ -15,26 +15,26 @@ import { usePOIData } from '../../hooks/usePOIData';
|
|||
import { useFilters } from '../../hooks/useFilters';
|
||||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||
import { useAiFilters } from '../../hooks/useAiFilters';
|
||||
import { useAreaSummary } from '../../hooks/useAreaSummary';
|
||||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import { useTravelTime, type TravelTimeInitial } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, buildFilterString } from '../../lib/api';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
|
||||
export interface ExportState {
|
||||
onExport: () => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
type MobileBottomTab = 'filters' | 'pois' | 'area';
|
||||
|
||||
interface MapPageProps {
|
||||
features: FeatureMeta[];
|
||||
poiCategoryGroups: POICategoryGroup[];
|
||||
initialFilters: FeatureFilters;
|
||||
initialViewState: ViewState;
|
||||
initialPOICategories: Set<string>;
|
||||
initialTab: 'pois' | 'properties' | 'area';
|
||||
initialTab: 'properties' | 'area';
|
||||
initialLoading: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
pendingInfoFeature: string | null;
|
||||
|
|
@ -73,9 +73,11 @@ export default function MapPage({
|
|||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
|
||||
|
||||
// Mobile state
|
||||
const [mobileBottomTab, setMobileBottomTab] = useState<MobileBottomTab>('filters');
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
|
||||
// POI floating panel state
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
|
||||
// Initialize filters first
|
||||
const {
|
||||
filters,
|
||||
|
|
@ -90,6 +92,7 @@ export default function MapPage({
|
|||
handleAddFilter,
|
||||
handleFilterChange,
|
||||
handleRemoveFilter,
|
||||
handleSetFilters,
|
||||
handleDragStart,
|
||||
handleDragChange,
|
||||
handleDragEnd,
|
||||
|
|
@ -101,6 +104,16 @@ export default function MapPage({
|
|||
features,
|
||||
});
|
||||
|
||||
// AI filters hook
|
||||
const aiFilters = useAiFilters();
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
const result = await aiFilters.fetchAiFilters(query);
|
||||
if (result) handleSetFilters(result);
|
||||
},
|
||||
[aiFilters.fetchAiFilters, handleSetFilters]
|
||||
);
|
||||
|
||||
// Travel time hook
|
||||
const travelTime = useTravelTime(initialTravelTime);
|
||||
|
||||
|
|
@ -161,7 +174,6 @@ export default function MapPage({
|
|||
handleHexagonClick(id, isPostcode);
|
||||
if (id) {
|
||||
setMobileDrawerOpen(true);
|
||||
setMobileBottomTab('area');
|
||||
}
|
||||
},
|
||||
[handleHexagonClick]
|
||||
|
|
@ -289,6 +301,10 @@ export default function MapPage({
|
|||
screenshotMode
|
||||
ogMode={ogMode}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEnabled={travelTime.enabled}
|
||||
travelTimeDestination={travelTime.destination}
|
||||
travelTimeColorRange={mapData.travelTimeColorRange}
|
||||
travelTimeRange={travelTime.timeRange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -368,6 +384,9 @@ export default function MapPage({
|
|||
onTravelTimeSetDestination={travelTime.handleSetDestination}
|
||||
onTravelTimeModeChange={travelTime.handleModeChange}
|
||||
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
||||
aiFilterLoading={aiFilters.loading}
|
||||
aiFilterError={aiFilters.error}
|
||||
onAiFilterSubmit={handleAiFilterSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -421,6 +440,19 @@ export default function MapPage({
|
|||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{/* Floating POI button */}
|
||||
<button
|
||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Floating POI panel */}
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
|
||||
{renderPOIPane()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom panel — 55% */}
|
||||
|
|
@ -466,27 +498,9 @@ export default function MapPage({
|
|||
inline
|
||||
/>
|
||||
)}
|
||||
{/* Tab bar */}
|
||||
<div className="flex shrink-0 border-b border-warm-200 dark:border-warm-700 text-sm">
|
||||
<TabButton
|
||||
label="Filters"
|
||||
isActive={mobileBottomTab === 'filters'}
|
||||
onClick={() => setMobileBottomTab('filters')}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
isActive={mobileBottomTab === 'pois'}
|
||||
onClick={() => setMobileBottomTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Filters content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{mobileBottomTab === 'pois' ? (
|
||||
<div className="h-full overflow-y-auto">{renderPOIPane()}</div>
|
||||
) : (
|
||||
renderFilters()
|
||||
)}
|
||||
{renderFilters()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -496,7 +510,6 @@ export default function MapPage({
|
|||
onClose={() => setMobileDrawerOpen(false)}
|
||||
renderArea={renderAreaPane}
|
||||
renderProperties={renderPropertiesPane}
|
||||
renderPOIs={renderPOIPane}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -565,6 +578,19 @@ export default function MapPage({
|
|||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{/* Floating POI button */}
|
||||
<button
|
||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Floating POI panel */}
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-14 right-4 z-10 w-80 max-h-[60vh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
|
||||
{renderPOIPane()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Pane */}
|
||||
|
|
@ -590,19 +616,12 @@ export default function MapPage({
|
|||
isActive={selection.rightPaneTab === 'properties'}
|
||||
onClick={selection.handlePropertiesTabClick}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
isActive={selection.rightPaneTab === 'pois'}
|
||||
onClick={() => selection.setRightPaneTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selection.rightPaneTab === 'area'
|
||||
? renderAreaPane()
|
||||
: selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderPOIPane()}
|
||||
{selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderAreaPane()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,20 +2,18 @@ import { useState, useEffect } from 'react';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
||||
type DrawerTab = 'area' | 'properties' | 'pois';
|
||||
type DrawerTab = 'area' | 'properties';
|
||||
|
||||
interface MobileDrawerProps {
|
||||
onClose: () => void;
|
||||
renderArea: () => React.ReactNode;
|
||||
renderProperties: () => React.ReactNode;
|
||||
renderPOIs: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function MobileDrawer({
|
||||
onClose,
|
||||
renderArea,
|
||||
renderProperties,
|
||||
renderPOIs,
|
||||
}: MobileDrawerProps) {
|
||||
const [tab, setTab] = useState<DrawerTab>('area');
|
||||
|
||||
|
|
@ -43,7 +41,6 @@ export default function MobileDrawer({
|
|||
isActive={tab === 'properties'}
|
||||
onClick={() => setTab('properties')}
|
||||
/>
|
||||
<TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto flex items-center justify-center w-10 h-10 rounded-lg hover:bg-warm-100 dark:hover:bg-navy-800"
|
||||
|
|
@ -55,7 +52,7 @@ export default function MobileDrawer({
|
|||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tab === 'area' ? renderArea() : tab === 'properties' ? renderProperties() : renderPOIs()}
|
||||
{tab === 'area' ? renderArea() : renderProperties()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,12 +80,31 @@ export default function POIPane({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 pt-4 pb-2 space-y-3">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
POIs
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
{selectedCount}/{allCategories.length}
|
||||
</span>
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
|
|
@ -118,34 +137,6 @@ export default function POIPane({
|
|||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{selectedCount}/{allCategories.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<div className="px-3 py-2 bg-teal-50 dark:bg-teal-900/30 rounded text-sm flex items-center justify-between">
|
||||
<span className="font-medium text-teal-900 dark:text-teal-300">
|
||||
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||
|
|
|
|||
|
|
@ -1,14 +1,36 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { PostcodeGeometry } from '../../types';
|
||||
import { authHeaders } from '../../lib/api';
|
||||
import type { PostcodeGeometry, PlaceResult } from '../../types';
|
||||
import { authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { SearchIcon } from '../ui/icons/SearchIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
|
||||
export interface SearchedPostcode {
|
||||
postcode: string;
|
||||
geometry: PostcodeGeometry;
|
||||
}
|
||||
|
||||
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
|
||||
function looksLikePostcode(s: string) {
|
||||
return POSTCODE_RE.test(s.trim());
|
||||
}
|
||||
|
||||
type SearchResult =
|
||||
| { type: 'postcode'; label: string }
|
||||
| { type: 'place'; name: string; place_type: string; lat: number; lon: number };
|
||||
|
||||
const ZOOM_FOR_TYPE: Record<string, number> = {
|
||||
city: 10,
|
||||
borough: 12,
|
||||
town: 13,
|
||||
suburb: 14,
|
||||
neighbourhood: 14,
|
||||
village: 14,
|
||||
locality: 14,
|
||||
hamlet: 15,
|
||||
isolated_dwelling: 16,
|
||||
};
|
||||
|
||||
export default function PostcodeSearch({
|
||||
onFlyTo,
|
||||
onPostcodeSearched,
|
||||
|
|
@ -17,24 +39,29 @@ export default function PostcodeSearch({
|
|||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Close on outside click (mobile only)
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!isMobile || !expanded) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||||
setExpanded(false);
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
if (isMobile) setExpanded(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isMobile, expanded]);
|
||||
}, [isMobile]);
|
||||
|
||||
// Focus input when expanding on mobile
|
||||
useEffect(() => {
|
||||
|
|
@ -43,16 +70,16 @@ export default function PostcodeSearch({
|
|||
}
|
||||
}, [isMobile, expanded]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const selectPostcode = useCallback(
|
||||
async (postcode: string) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setOpen(false);
|
||||
try {
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`, authHeaders());
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(postcode.trim())}`,
|
||||
authHeaders()
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
return;
|
||||
|
|
@ -66,6 +93,7 @@ export default function PostcodeSearch({
|
|||
onFlyTo(json.latitude, json.longitude, 16);
|
||||
onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry });
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
setError('Lookup failed');
|
||||
|
|
@ -73,9 +101,115 @@ export default function PostcodeSearch({
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[query, onFlyTo, onPostcodeSearched, isMobile]
|
||||
[onFlyTo, onPostcodeSearched, isMobile]
|
||||
);
|
||||
|
||||
const selectPlace = useCallback(
|
||||
(place: { name: string; place_type: string; lat: number; lon: number }) => {
|
||||
const zoom = ZOOM_FOR_TYPE[place.place_type] ?? 14;
|
||||
onFlyTo(place.lat, place.lon, zoom);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
if (isMobile) setExpanded(false);
|
||||
},
|
||||
[onFlyTo, isMobile]
|
||||
);
|
||||
|
||||
const selectResult = useCallback(
|
||||
(result: SearchResult) => {
|
||||
if (result.type === 'postcode') {
|
||||
selectPostcode(result.label);
|
||||
} else {
|
||||
selectPlace(result);
|
||||
}
|
||||
},
|
||||
[selectPostcode, selectPlace]
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setQuery(value);
|
||||
setError(null);
|
||||
setActiveIndex(-1);
|
||||
|
||||
// Cancel in-flight request
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (looksLikePostcode(trimmed)) {
|
||||
setResults([{ type: 'postcode', label: trimmed.toUpperCase() }]);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounced place search
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmed, limit: '7' });
|
||||
const res = await fetch(
|
||||
`/api/places?${params}`,
|
||||
authHeaders({ signal: controller.signal })
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const json: { places: PlaceResult[] } = await res.json();
|
||||
const placeResults: SearchResult[] = json.places.map((p) => ({
|
||||
type: 'place' as const,
|
||||
...p,
|
||||
}));
|
||||
setResults(placeResults);
|
||||
setOpen(placeResults.length > 0);
|
||||
} catch (err) {
|
||||
logNonAbortError('places search', err);
|
||||
}
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < results.length) {
|
||||
selectResult(results[activeIndex]);
|
||||
} else if (looksLikePostcode(query)) {
|
||||
selectPostcode(query);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[results, activeIndex, query, selectResult, selectPostcode]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mobile collapsed state: just a search icon button
|
||||
if (isMobile && !expanded) {
|
||||
return (
|
||||
|
|
@ -83,7 +217,7 @@ export default function PostcodeSearch({
|
|||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="absolute top-3 left-3 z-10 p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
|
||||
aria-label="Search postcode"
|
||||
aria-label="Search places or postcodes"
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
|
||||
</button>
|
||||
|
|
@ -91,36 +225,76 @@ export default function PostcodeSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
|
||||
>
|
||||
<div className="flex shadow-lg rounded overflow-hidden">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Search postcode..."
|
||||
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-white dark:placeholder-warm-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Go'}
|
||||
</button>
|
||||
<div ref={containerRef} className="absolute top-3 left-3 z-10 flex flex-col">
|
||||
<div className="relative">
|
||||
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
|
||||
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setOpen(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search places or postcodes..."
|
||||
className="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="mr-3 w-4 h-4 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 max-h-64 overflow-y-auto">
|
||||
{results.map((result, idx) => (
|
||||
<button
|
||||
key={
|
||||
result.type === 'postcode'
|
||||
? `pc-${result.label}`
|
||||
: `pl-${result.name}-${result.lat}`
|
||||
}
|
||||
type="button"
|
||||
className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm cursor-pointer ${
|
||||
idx === activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
selectResult(result);
|
||||
}}
|
||||
>
|
||||
{result.type === 'postcode' ? (
|
||||
<>
|
||||
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
|
||||
<span className="text-warm-400 dark:text-warm-500 text-xs ml-auto">
|
||||
postcode
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.name}</span>
|
||||
<span className="text-warm-400 dark:text-warm-500 text-xs ml-auto">
|
||||
{result.place_type}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
|
||||
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-warm-800/90 rounded px-2 py-0.5 shadow mt-1">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,23 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Renovations</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.renovation_history.map((reno, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{reno.event}
|
||||
<span className="text-warm-500 dark:text-warm-400">{reno.year}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import { authHeaders } from '../../lib/api';
|
|||
import type { TransportMode } from '../../hooks/useTravelTime';
|
||||
|
||||
const MODES: { value: TransportMode; label: string }[] = [
|
||||
{ value: 'transit', label: 'Transit' },
|
||||
{ value: 'car', label: 'Car' },
|
||||
{ value: 'bicycle', label: 'Bicycle' },
|
||||
{ value: 'walking', label: 'Walking' },
|
||||
{ value: 'transit', label: 'Transit' },
|
||||
];
|
||||
|
||||
interface TravelTimeCardProps {
|
||||
|
|
|
|||
55
frontend/src/hooks/useAiFilters.ts
Normal file
55
frontend/src/hooks/useAiFilters.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { FeatureFilters } from '../types';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||
|
||||
interface UseAiFiltersResult {
|
||||
fetchAiFilters: (query: string) => Promise<FeatureFilters | null>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useAiFilters(): UseAiFiltersResult {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchAiFilters = useCallback(async (query: string): Promise<FeatureFilters | null> => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = apiUrl('ai-filters');
|
||||
const response = await fetch(
|
||||
url,
|
||||
authHeaders({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
setLoading(false);
|
||||
return json.filters as FeatureFilters;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return null;
|
||||
logNonAbortError('ai-filters', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to generate filters';
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { fetchAiFilters, loading, error };
|
||||
}
|
||||
|
|
@ -11,12 +11,8 @@ import type {
|
|||
Bounds,
|
||||
} from '../types';
|
||||
import type { SearchedPostcode } from '../components/map/PostcodeSearch';
|
||||
import {
|
||||
emojiToTwemojiUrl,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
getFeatureFillColor,
|
||||
} from '../lib/map-utils';
|
||||
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
|
||||
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
||||
function osmIdToUrl(id: string): string | null {
|
||||
|
|
@ -242,7 +238,7 @@ export function useDeckLayers({
|
|||
}, []);
|
||||
|
||||
// --- Color triggers ---
|
||||
const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}`;
|
||||
const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}|${travelTimeDestination?.[1]}`;
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}|${ttTrigger}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,14 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}
|
||||
}, [activeFeature, dragValue]);
|
||||
|
||||
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
||||
setFilters(newFilters);
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
setDragData(null);
|
||||
setPinnedFeature(null);
|
||||
}, []);
|
||||
|
||||
const handleTogglePin = useCallback((name: string) => {
|
||||
setPinnedFeature((prev) => (prev === name ? null : name));
|
||||
}, []);
|
||||
|
|
@ -144,6 +152,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
handleAddFilter,
|
||||
handleFilterChange,
|
||||
handleRemoveFilter,
|
||||
handleSetFilters,
|
||||
handleDragStart,
|
||||
handleDragChange,
|
||||
handleDragEnd,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
|||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>('pois');
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type TransportMode = 'transit' | 'car' | 'bicycle';
|
||||
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
|
||||
|
||||
export interface TravelTimeState {
|
||||
enabled: boolean;
|
||||
|
|
@ -23,7 +23,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
initial?.destination ?? null
|
||||
);
|
||||
const [destinationLabel, setDestinationLabel] = useState(initial?.destinationLabel ?? '');
|
||||
const [mode, setMode] = useState<TransportMode>(initial?.mode ?? 'transit');
|
||||
const [mode, setMode] = useState<TransportMode>(initial?.mode ?? 'car');
|
||||
const [timeRange, setTimeRange] = useState<[number, number] | null>(
|
||||
initial?.timeRange ?? null
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function useUrlSync(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area',
|
||||
rightPaneTab: 'properties' | 'area',
|
||||
travelTime?: TravelTimeUrlState
|
||||
) {
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
|
|||
|
|
@ -10,14 +10,6 @@ import {
|
|||
BUFFER_MULTIPLIER,
|
||||
} from './consts';
|
||||
|
||||
// Re-export constants for backwards compatibility
|
||||
export {
|
||||
FEATURE_GRADIENT as GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from './consts';
|
||||
|
||||
const ROAD_OPACITY = 0.4;
|
||||
|
||||
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function parseUrlState(): {
|
|||
viewState?: ViewState;
|
||||
filters?: FeatureFilters;
|
||||
poiCategories?: Set<string>;
|
||||
tab?: 'pois' | 'properties' | 'area';
|
||||
tab?: 'properties' | 'area';
|
||||
travelTime?: TravelTimeInitial;
|
||||
} {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
|
@ -61,7 +61,7 @@ export function parseUrlState(): {
|
|||
|
||||
// Tab: full name
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'properties' || tab === 'pois' || tab === 'area') {
|
||||
if (tab === 'properties' || tab === 'area') {
|
||||
result.tab = tab;
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ export function stateToParams(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area',
|
||||
rightPaneTab: 'properties' | 'area',
|
||||
travelTime?: { enabled: boolean; destination: [number, number] | null; destinationLabel: string; mode: string; timeRange: [number, number] | null }
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -121,8 +121,6 @@ export function stateToParams(
|
|||
|
||||
if (rightPaneTab === 'properties') {
|
||||
params.set('tab', 'properties');
|
||||
} else if (rightPaneTab === 'area') {
|
||||
params.set('tab', 'area');
|
||||
}
|
||||
|
||||
if (travelTime?.enabled && travelTime.destination) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,18 @@ export interface POICategoriesResponse {
|
|||
groups: POICategoryGroup[];
|
||||
}
|
||||
|
||||
export interface PlaceResult {
|
||||
name: string;
|
||||
place_type: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface RenovationEvent {
|
||||
year: number;
|
||||
event: string;
|
||||
}
|
||||
|
||||
export interface Property {
|
||||
// String fields
|
||||
address?: string;
|
||||
|
|
@ -114,9 +126,10 @@ export interface Property {
|
|||
lon: number;
|
||||
|
||||
is_construction_date_approximate?: boolean;
|
||||
renovation_history?: RenovationEvent[];
|
||||
|
||||
// All other numeric features (dynamic, including construction_age_band)
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
[key: string]: string | number | boolean | RenovationEvent[] | undefined;
|
||||
}
|
||||
|
||||
export interface HexagonPropertiesResponse {
|
||||
|
|
|
|||
|
|
@ -99,26 +99,26 @@ def main():
|
|||
"--output", type=Path, required=True, help="Output parquet file path"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pbf", type=Path, default=None, help="Path to existing PBF file"
|
||||
"--pbf", type=Path, required=True, help="Path to existing PBF file"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.pbf and args.pbf.exists():
|
||||
if args.pbf.exists():
|
||||
pbf_file = args.pbf
|
||||
print(f"Using existing PBF: {pbf_file}")
|
||||
else:
|
||||
pbf_file = Path("data/great-britain-latest.osm.pbf")
|
||||
if not pbf_file.exists():
|
||||
download_pbf(pbf_file)
|
||||
else:
|
||||
print(f"Using cached PBF: {pbf_file}")
|
||||
download_pbf(args.pbf)
|
||||
|
||||
print("Extracting greenspace/water areas from PBF (two-pass area assembly)...")
|
||||
with tqdm(unit=" areas", unit_scale=True, desc="Processing", smoothing=0.05) as progress:
|
||||
with tqdm(
|
||||
unit=" areas", unit_scale=True, desc="Processing", smoothing=0.05
|
||||
) as progress:
|
||||
handler = GreenspaceHandler(progress)
|
||||
handler.apply_file(str(pbf_file), locations=True)
|
||||
|
||||
print(f"Found {len(handler.geometries)} greenspace/water polygons >= {MIN_AREA_SQM} sqm")
|
||||
print(
|
||||
f"Found {len(handler.geometries)} greenspace/water polygons >= {MIN_AREA_SQM} sqm"
|
||||
)
|
||||
|
||||
# Merge overlapping geometries per 10km grid cell for efficiency
|
||||
if handler.geometries:
|
||||
|
|
|
|||
99
pipeline/download/places.py
Normal file
99
pipeline/download/places.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Extract place=* nodes from OSM PBF → data/places.parquet.
|
||||
|
||||
Extracts named place nodes (cities, towns, suburbs, etc.) for typeahead search.
|
||||
Reuses the same great-britain-latest.osm.pbf as pois.py.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import osmium
|
||||
import polars as pl
|
||||
from tqdm import tqdm
|
||||
|
||||
from .pois import UK_BBOX_EAST, UK_BBOX_NORTH, UK_BBOX_SOUTH, UK_BBOX_WEST, download_pbf
|
||||
|
||||
PLACE_TYPES = {
|
||||
"city",
|
||||
"borough",
|
||||
"town",
|
||||
"suburb",
|
||||
"neighbourhood",
|
||||
"village",
|
||||
"hamlet",
|
||||
"locality",
|
||||
"isolated_dwelling",
|
||||
}
|
||||
|
||||
|
||||
class PlaceHandler(osmium.SimpleHandler):
|
||||
def __init__(self, progress: tqdm) -> None:
|
||||
super().__init__()
|
||||
self._progress = progress
|
||||
self.places: list[dict] = []
|
||||
|
||||
def node(self, n: osmium.osm.Node) -> None:
|
||||
self._progress.update(1)
|
||||
if not n.location.valid:
|
||||
return
|
||||
lat, lon = n.location.lat, n.location.lon
|
||||
if not (UK_BBOX_SOUTH <= lat <= UK_BBOX_NORTH and UK_BBOX_WEST <= lon <= UK_BBOX_EAST):
|
||||
return
|
||||
place_type = n.tags.get("place")
|
||||
if place_type not in PLACE_TYPES:
|
||||
return
|
||||
name = n.tags.get("name:en", n.tags.get("name", ""))
|
||||
if not name:
|
||||
return
|
||||
self.places.append(
|
||||
{"name": name, "place_type": place_type, "lat": lat, "lon": lon}
|
||||
)
|
||||
self._progress.set_postfix(places=f"{len(self.places):,}", refresh=False)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract place names from OSM PBF"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, required=True, help="Output parquet file path"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pbf", type=Path, default=None, help="Path to existing PBF file (skips download)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.pbf and args.pbf.exists():
|
||||
pbf_file = args.pbf
|
||||
print(f"Using existing PBF: {pbf_file}")
|
||||
else:
|
||||
pbf_file = Path("data/great-britain-latest.osm.pbf")
|
||||
if not pbf_file.exists():
|
||||
download_pbf(pbf_file)
|
||||
else:
|
||||
print(f"Using cached PBF: {pbf_file}")
|
||||
|
||||
print(f"Extracting place nodes: {sorted(PLACE_TYPES)}")
|
||||
with tqdm(
|
||||
unit=" elements",
|
||||
unit_scale=True,
|
||||
desc="Streaming",
|
||||
smoothing=0.05,
|
||||
mininterval=1.0,
|
||||
) as progress:
|
||||
handler = PlaceHandler(progress)
|
||||
handler.apply_file(str(pbf_file), locations=True)
|
||||
|
||||
print(f"Extracted {len(handler.places):,} place nodes")
|
||||
|
||||
if handler.places:
|
||||
df = pl.DataFrame(handler.places)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.write_parquet(args.output)
|
||||
print(f"Saved to {args.output}")
|
||||
else:
|
||||
print("No places found — skipping output")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
89
pipeline/download/rental_prices.py
Normal file
89
pipeline/download/rental_prices.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import argparse
|
||||
import tempfile
|
||||
|
||||
import polars as pl
|
||||
from pathlib import Path
|
||||
|
||||
from pipeline.utils import download
|
||||
|
||||
URL = "https://www.ons.gov.uk/file?uri=/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland/october2022toseptember2023/privaterentalmarketstatistics231220.xls"
|
||||
|
||||
# Sheets 12-16 are LA-level breakdowns: Studio, 1 Bed, 2 Bed, 3 Bed, 4+ Bed
|
||||
# (Sheet 11 is "Room" — shared house rooms, not self-contained, so skip it)
|
||||
BEDROOM_SHEETS = {
|
||||
12: 0, # Studio
|
||||
13: 1, # One Bedroom
|
||||
14: 2, # Two Bedrooms
|
||||
15: 3, # Three Bedrooms
|
||||
16: 4, # Four or more Bedrooms
|
||||
}
|
||||
|
||||
# Local authority district codes in England
|
||||
LA_PREFIXES = ("E06", "E07", "E08", "E09")
|
||||
|
||||
|
||||
def _read_sheet(xls_path: Path, sheet_id: int, bedrooms: int) -> pl.DataFrame:
|
||||
"""Read one bedroom category sheet, extract LA-level median rents."""
|
||||
df = pl.read_excel(xls_path, sheet_id=sheet_id)
|
||||
|
||||
# Columns are unnamed; positional:
|
||||
# 0=LA Code, 1=Area Code, 2=Area Name, 3=Count, 4=Mean, 5=LQ, 6=Median, 7=UQ
|
||||
# First 4 rows are headers (title, notes, bedroom label, column headers)
|
||||
df = df.slice(4)
|
||||
|
||||
area_code_col = df.columns[1]
|
||||
median_col = df.columns[6]
|
||||
|
||||
return (
|
||||
df.select(
|
||||
pl.col(area_code_col).alias("area_code"),
|
||||
pl.col(median_col).alias("median_monthly_rent"),
|
||||
)
|
||||
.filter(
|
||||
pl.col("area_code").is_not_null()
|
||||
& pl.col("area_code").str.starts_with("E06")
|
||||
| pl.col("area_code").str.starts_with("E07")
|
||||
| pl.col("area_code").str.starts_with("E08")
|
||||
| pl.col("area_code").str.starts_with("E09")
|
||||
)
|
||||
.with_columns(
|
||||
# Suppressed values are ".." — cast will turn them to null
|
||||
pl.col("median_monthly_rent").cast(pl.Float32, strict=False),
|
||||
pl.lit(bedrooms).cast(pl.UInt8).alias("bedrooms"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def convert_to_parquet(xls_path: Path, parquet_path: Path) -> None:
|
||||
frames = []
|
||||
for sheet_id, bedrooms in BEDROOM_SHEETS.items():
|
||||
df = _read_sheet(xls_path, sheet_id, bedrooms)
|
||||
print(f" Sheet {sheet_id} (bedrooms={bedrooms}): {df.height} rows")
|
||||
frames.append(df)
|
||||
|
||||
combined = pl.concat(frames)
|
||||
print(f"Combined: {combined.shape}")
|
||||
print(f"Non-null medians: {combined['median_monthly_rent'].drop_nulls().len()}")
|
||||
print(combined.head(10))
|
||||
|
||||
combined.write_parquet(parquet_path, compression="zstd")
|
||||
print(f"Saved to {parquet_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Download and convert ONS private rental market statistics"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, required=True, help="Output parquet file path"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with tempfile.TemporaryDirectory() as cache_dir:
|
||||
xls_path = Path(cache_dir) / "rental_prices.xls"
|
||||
download(URL, xls_path, timeout=60)
|
||||
convert_to_parquet(xls_path, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -6,6 +6,8 @@ from ..utils import fuzzy_join_on_postcode
|
|||
|
||||
pl.Config.set_tbl_cols(-1)
|
||||
|
||||
RATING_RANK = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fuzzy join EPC and Price Paid data")
|
||||
|
|
@ -20,7 +22,7 @@ def main():
|
|||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
epc = (
|
||||
epc_base = (
|
||||
pl.scan_csv(args.epc)
|
||||
.select(
|
||||
pl.col("ADDRESS").alias("epc_address"),
|
||||
|
|
@ -42,11 +44,90 @@ def main():
|
|||
.otherwise(pl.col("NUMBER_HABITABLE_ROOMS"))
|
||||
.alias("NUMBER_HABITABLE_ROOMS"),
|
||||
)
|
||||
.sort("INSPECTION_DATE", descending=True)
|
||||
)
|
||||
|
||||
# Dedup fork: keep latest certificate per property (existing logic)
|
||||
epc = (
|
||||
epc_base.sort("INSPECTION_DATE", descending=True)
|
||||
.group_by("epc_address", "POSTCODE")
|
||||
.first()
|
||||
)
|
||||
|
||||
# Events fork: detect renovation events between consecutive certificates
|
||||
# Collect eagerly because .over() window functions don't work in streaming
|
||||
# engine (fuzzy_join.py:50 uses sink_parquet which requires streaming).
|
||||
events = (
|
||||
epc_base.sort("INSPECTION_DATE")
|
||||
.with_columns(
|
||||
pl.col("CURRENT_ENERGY_RATING")
|
||||
.replace_strict(RATING_RANK, default=None, return_dtype=pl.Int32)
|
||||
.alias("_rating_rank"),
|
||||
)
|
||||
.with_columns(
|
||||
pl.col("NUMBER_HABITABLE_ROOMS")
|
||||
.shift(1)
|
||||
.over("epc_address", "POSTCODE")
|
||||
.alias("_prev_rooms"),
|
||||
pl.col("TOTAL_FLOOR_AREA")
|
||||
.shift(1)
|
||||
.over("epc_address", "POSTCODE")
|
||||
.alias("_prev_area"),
|
||||
pl.col("_rating_rank")
|
||||
.shift(1)
|
||||
.over("epc_address", "POSTCODE")
|
||||
.alias("_prev_rating_rank"),
|
||||
)
|
||||
.with_columns(
|
||||
pl.when(
|
||||
pl.col("NUMBER_HABITABLE_ROOMS").is_not_null()
|
||||
& pl.col("_prev_rooms").is_not_null()
|
||||
& (pl.col("NUMBER_HABITABLE_ROOMS") != pl.col("_prev_rooms"))
|
||||
)
|
||||
.then(pl.lit("Remodeling"))
|
||||
.when(
|
||||
pl.col("TOTAL_FLOOR_AREA").is_not_null()
|
||||
& pl.col("_prev_area").is_not_null()
|
||||
& (pl.col("TOTAL_FLOOR_AREA") > pl.col("_prev_area"))
|
||||
)
|
||||
.then(pl.lit("Extension"))
|
||||
.when(
|
||||
pl.col("_rating_rank").is_not_null()
|
||||
& pl.col("_prev_rating_rank").is_not_null()
|
||||
& (pl.col("_rating_rank") < pl.col("_prev_rating_rank"))
|
||||
)
|
||||
.then(pl.lit("Renovation"))
|
||||
.otherwise(pl.lit(None, dtype=pl.String))
|
||||
.alias("_event"),
|
||||
)
|
||||
.filter(pl.col("_event").is_not_null())
|
||||
.with_columns(
|
||||
pl.col("INSPECTION_DATE")
|
||||
.cast(pl.String)
|
||||
.str.slice(0, 4)
|
||||
.cast(pl.Int32)
|
||||
.alias("_event_year"),
|
||||
)
|
||||
.group_by("epc_address", "POSTCODE")
|
||||
.agg(
|
||||
pl.struct(
|
||||
pl.col("_event_year").alias("year"),
|
||||
pl.col("_event").alias("event"),
|
||||
).alias("renovation_history"),
|
||||
)
|
||||
.collect()
|
||||
)
|
||||
|
||||
event_counts = events["renovation_history"].explode().struct.field("event").value_counts()
|
||||
print(f"Renovation events: {events.height} properties with events")
|
||||
print(event_counts)
|
||||
|
||||
# Left-join events back onto dedup EPC
|
||||
epc = epc.join(
|
||||
events.lazy(),
|
||||
on=["epc_address", "POSTCODE"],
|
||||
how="left",
|
||||
)
|
||||
|
||||
print("EPC dataset")
|
||||
print(epc.head().collect())
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ def _build_wide(
|
|||
school_proximity_path: Path,
|
||||
broadband_path: Path,
|
||||
geosure_path: Path,
|
||||
rental_prices_path: Path,
|
||||
) -> pl.DataFrame:
|
||||
"""Build the wide dataframe by joining epc_pp with all auxiliary data."""
|
||||
wide = (
|
||||
|
|
@ -94,6 +95,21 @@ def _build_wide(
|
|||
how="left",
|
||||
)
|
||||
|
||||
# Derive bedroom count: habitable rooms - 1 (assuming 1 reception room), clipped to 0..4
|
||||
wide = wide.with_columns(
|
||||
(pl.col("number_habitable_rooms") - 1)
|
||||
.clip(0, 4)
|
||||
.cast(pl.UInt8)
|
||||
.alias("_bedrooms"),
|
||||
)
|
||||
rental = pl.scan_parquet(rental_prices_path)
|
||||
wide = wide.join(
|
||||
rental,
|
||||
left_on=["Local Authority District code (2024)", "_bedrooms"],
|
||||
right_on=["area_code", "bedrooms"],
|
||||
how="left",
|
||||
)
|
||||
|
||||
crime = pl.scan_parquet(crime_path)
|
||||
wide = wide.join(crime, left_on="lsoa21", right_on="LSOA code", how="left")
|
||||
|
||||
|
|
@ -208,6 +224,7 @@ def _build_wide(
|
|||
.drop(
|
||||
"inspection_date",
|
||||
"floor_height",
|
||||
"_bedrooms",
|
||||
"LSOA name (2021)",
|
||||
"Local Authority District code (2024)",
|
||||
"Local Authority District name (2024)",
|
||||
|
|
@ -258,6 +275,7 @@ def _build_wide(
|
|||
"running_sand_risk": "Running sand risk",
|
||||
"shrink_swell_risk": "Shrink-swell risk",
|
||||
"soluble_rocks_risk": "Soluble rocks risk",
|
||||
"median_monthly_rent": "Estimated monthly rent",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -332,6 +350,12 @@ def main():
|
|||
required=True,
|
||||
help="GeoSure ground stability parquet file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rental-prices",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="ONS rental prices by LA and bedroom count parquet file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, required=True, help="Output parquet file path"
|
||||
)
|
||||
|
|
@ -350,6 +374,7 @@ def main():
|
|||
school_proximity_path=args.school_proximity,
|
||||
broadband_path=args.broadband,
|
||||
geosure_path=args.geosure,
|
||||
rental_prices_path=args.rental_prices,
|
||||
)
|
||||
|
||||
print(f"Columns: {wide.columns}")
|
||||
|
|
|
|||
|
|
@ -1,26 +1,11 @@
|
|||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Download pre-built R5 fat JAR from GitHub Releases
|
||||
# Download pre-built R5 fat JAR from GitHub Releases (includes all R5 deps)
|
||||
ADD https://github.com/conveyal/r5/releases/download/v7.5/r5-v7.5-all.jar /app/lib/r5.jar
|
||||
|
||||
# Download Javalin + Gson + SLF4J
|
||||
ADD https://repo1.maven.org/maven2/io/javalin/javalin/6.4.0/javalin-6.4.0.jar /app/lib/javalin.jar
|
||||
# Gson for JSON (HTTP server is built into JDK)
|
||||
ADD https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar /app/lib/gson.jar
|
||||
ADD https://repo1.maven.org/maven2/org/slf4j/slf4j-simple/2.0.16/slf4j-simple-2.0.16.jar /app/lib/slf4j-simple.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-server/11.0.24/jetty-server-11.0.24.jar /app/lib/jetty-server.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-util/11.0.24/jetty-util-11.0.24.jar /app/lib/jetty-util.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-http/11.0.24/jetty-http-11.0.24.jar /app/lib/jetty-http.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-io/11.0.24/jetty-io-11.0.24.jar /app/lib/jetty-io.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-servlet/11.0.24/jetty-servlet-11.0.24.jar /app/lib/jetty-servlet.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-security/11.0.24/jetty-security-11.0.24.jar /app/lib/jetty-security.jar
|
||||
ADD https://repo1.maven.org/maven2/jakarta/servlet/jakarta.servlet-api/5.0.0/jakarta.servlet-api-5.0.0.jar /app/lib/servlet-api.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-jetty-server/11.0.24/websocket-jetty-server-11.0.24.jar /app/lib/ws-server.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-jetty-api/11.0.24/websocket-jetty-api-11.0.24.jar /app/lib/ws-api.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-core-server/11.0.24/websocket-core-server-11.0.24.jar /app/lib/ws-core-server.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-core-common/11.0.24/websocket-core-common-11.0.24.jar /app/lib/ws-core-common.jar
|
||||
ADD https://repo1.maven.org/maven2/org/eclipse/jetty/websocket/websocket-servlet/11.0.24/websocket-servlet-11.0.24.jar /app/lib/ws-servlet.jar
|
||||
ADD https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/2.1.0/kotlin-stdlib-2.1.0.jar /app/lib/kotlin-stdlib.jar
|
||||
|
||||
COPY src/ src/
|
||||
RUN javac -cp "lib/*" -d out src/main/java/propertymap/App.java
|
||||
|
|
@ -30,4 +15,6 @@ WORKDIR /app
|
|||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/lib/ /app/lib/
|
||||
COPY --from=build /app/out/ /app/out/
|
||||
ENTRYPOINT ["java", "-Xmx4g", "-cp", "out:lib/*", "propertymap.App"]
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
plugins {
|
||||
id 'java'
|
||||
id 'application'
|
||||
id 'com.github.johnrengelman.shadow' version '8.1.1'
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = 'propertymap.App'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.conveyal:r5:7.2'
|
||||
implementation 'io.javalin:javalin:6.4.0'
|
||||
implementation 'com.google.code.gson:gson:2.11.0'
|
||||
implementation 'org.slf4j:slf4j-simple:2.0.16'
|
||||
}
|
||||
|
||||
jar {
|
||||
manifest {
|
||||
attributes 'Main-Class': 'propertymap.App'
|
||||
}
|
||||
}
|
||||
|
||||
shadowJar {
|
||||
archiveClassifier = ''
|
||||
mergeServiceFiles()
|
||||
}
|
||||
18
r5-java/entrypoint.sh
Normal file
18
r5-java/entrypoint.sh
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
TRANSIT_DIR=$DATA_DIR
|
||||
NETWORK_DIR=$NETWORK_CACHE_DIR
|
||||
BUILD_DIR="$NETWORK_DIR/build"
|
||||
|
||||
# If no cached network yet, copy transit data to a writable location for the build.
|
||||
# R5 writes temp files (.mapdb) next to the OSM/GTFS files during network construction.
|
||||
if [ ! -f "$NETWORK_DIR/network.dat" ]; then
|
||||
echo "No cached network — copying transit data to writable build dir..."
|
||||
mkdir -p "$BUILD_DIR"
|
||||
cp "$OSM_DIR"/*.osm.pbf "$BUILD_DIR/" 2>/dev/null || true
|
||||
cp "$TRANSIT_DIR"/*.zip "$BUILD_DIR/" 2>/dev/null || true
|
||||
export DATA_DIR="$BUILD_DIR"
|
||||
fi
|
||||
|
||||
exec java -Xmx16g -cp "out:lib/*" propertymap.App
|
||||
|
|
@ -1 +0,0 @@
|
|||
rootProject.name = 'r5-service'
|
||||
|
|
@ -1,16 +1,26 @@
|
|||
package propertymap;
|
||||
|
||||
import com.conveyal.r5.OneOriginResult;
|
||||
import com.conveyal.r5.analyst.FreeFormPointSet;
|
||||
import com.conveyal.r5.analyst.PointSet;
|
||||
import com.conveyal.r5.analyst.TravelTimeComputer;
|
||||
import com.conveyal.r5.analyst.WebMercatorExtents;
|
||||
import com.conveyal.r5.analyst.cluster.RegionalTask;
|
||||
import com.conveyal.r5.analyst.cluster.TravelTimeResult;
|
||||
import com.conveyal.r5.api.util.LegMode;
|
||||
import com.conveyal.r5.api.util.TransitModes;
|
||||
import com.conveyal.r5.kryo.KryoNetworkSerializer;
|
||||
import com.conveyal.r5.transit.TransportNetwork;
|
||||
import com.google.gson.Gson;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.Context;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.EnumSet;
|
||||
|
||||
|
|
@ -30,10 +40,17 @@ public class App {
|
|||
|
||||
public static void main(String[] args) throws Exception {
|
||||
String dataDir = System.getenv("DATA_DIR");
|
||||
if (dataDir == null) dataDir = "/data/transit";
|
||||
|
||||
if (dataDir == null) {
|
||||
System.err.println("Error: DATA_DIR environment variable not set");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
String networkCacheDir = System.getenv("NETWORK_CACHE_DIR");
|
||||
if (networkCacheDir == null) networkCacheDir = "/data/network";
|
||||
if (networkCacheDir == null) {
|
||||
System.err.println("Error: NETWORK_CACHE_DIR environment variable not set");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
System.out.println("Loading transport network from " + dataDir);
|
||||
System.out.println("Network cache dir: " + networkCacheDir);
|
||||
|
|
@ -41,51 +58,80 @@ public class App {
|
|||
File cacheFile = new File(networkCacheDir, "network.dat");
|
||||
if (cacheFile.exists()) {
|
||||
System.out.println("Loading cached network from " + cacheFile);
|
||||
network = TransportNetwork.read(cacheFile);
|
||||
network = KryoNetworkSerializer.read(cacheFile);
|
||||
} else {
|
||||
System.out.println("Building network (first run, this takes a few minutes)...");
|
||||
network = TransportNetwork.fromDirectory(new File(dataDir));
|
||||
new File(networkCacheDir).mkdirs();
|
||||
network.write(cacheFile);
|
||||
KryoNetworkSerializer.write(network, cacheFile);
|
||||
System.out.println("Network cached to " + cacheFile);
|
||||
}
|
||||
|
||||
// Build stop-to-vertex distance tables (needed for egress routing in transit mode).
|
||||
// Not built by fromDirectory() and too large to fit in the Kryo cache with 4GB heap.
|
||||
System.out.println("Building stop-to-vertex distance tables...");
|
||||
network.transitLayer.buildDistanceTables(null);
|
||||
System.out.println("Distance tables built");
|
||||
|
||||
System.out.println("Transport network loaded successfully");
|
||||
|
||||
Javalin app = Javalin.create().start(8003);
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(8003), 0);
|
||||
|
||||
app.get("/health", ctx -> ctx.result("ok"));
|
||||
server.createContext("/health", exchange -> {
|
||||
sendResponse(exchange, 200, "ok");
|
||||
});
|
||||
|
||||
app.post("/travel-times", App::handleTravelTimes);
|
||||
server.createContext("/travel-times", exchange -> {
|
||||
if (!"POST".equals(exchange.getRequestMethod())) {
|
||||
sendResponse(exchange, 405, "Method not allowed");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handleTravelTimes(exchange);
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error handling travel-times: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
sendResponse(exchange, 500, "Internal server error: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
server.setExecutor(java.util.concurrent.Executors.newFixedThreadPool(4));
|
||||
server.start();
|
||||
System.out.println("R5 service listening on port 8003");
|
||||
}
|
||||
|
||||
private static void handleTravelTimes(Context ctx) {
|
||||
private static void sendResponse(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleTravelTimes(HttpExchange exchange) throws IOException {
|
||||
long t0 = System.currentTimeMillis();
|
||||
|
||||
TravelTimeRequest req = gson.fromJson(ctx.body(), TravelTimeRequest.class);
|
||||
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
TravelTimeRequest req = gson.fromJson(body, TravelTimeRequest.class);
|
||||
|
||||
if (req.origin == null || req.origin.length != 2) {
|
||||
ctx.status(400).result("origin must be [lat, lon]");
|
||||
sendResponse(exchange, 400, "{\"error\":\"origin must be [lat, lon]\"}");
|
||||
return;
|
||||
}
|
||||
if (req.destinations == null || req.destinations.length == 0) {
|
||||
ctx.status(400).result("destinations must be non-empty array of [lat, lon]");
|
||||
sendResponse(exchange, 400, "{\"error\":\"destinations must be non-empty\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
String mode = req.mode != null ? req.mode : "transit";
|
||||
|
||||
// Build destination point set
|
||||
double[] lats = new double[req.destinations.length];
|
||||
double[] lons = new double[req.destinations.length];
|
||||
// Build destination point set (Coordinate takes x=lon, y=lat)
|
||||
Coordinate[] coords = new Coordinate[req.destinations.length];
|
||||
for (int i = 0; i < req.destinations.length; i++) {
|
||||
lats[i] = req.destinations[i][0];
|
||||
lons[i] = req.destinations[i][1];
|
||||
coords[i] = new Coordinate(req.destinations[i][1], req.destinations[i][0]); // lon, lat
|
||||
}
|
||||
|
||||
FreeFormPointSet destinations = new FreeFormPointSet(lats, lons);
|
||||
FreeFormPointSet destinations = new FreeFormPointSet(coords);
|
||||
|
||||
// Build the regional task
|
||||
RegionalTask task = new RegionalTask();
|
||||
|
|
@ -93,7 +139,16 @@ public class App {
|
|||
task.fromLon = req.origin[1];
|
||||
task.date = LocalDate.now();
|
||||
task.percentiles = new int[]{50};
|
||||
task.monteCarloDraws = 1;
|
||||
task.recordTimes = true;
|
||||
task.destinationPointSets = new PointSet[]{ destinations };
|
||||
|
||||
// Set grid extents from destination point set (required by TravelTimeComputer)
|
||||
WebMercatorExtents extents = destinations.getWebMercatorExtents();
|
||||
task.zoom = extents.zoom;
|
||||
task.west = extents.west;
|
||||
task.north = extents.north;
|
||||
task.width = extents.width;
|
||||
task.height = extents.height;
|
||||
|
||||
switch (mode) {
|
||||
case "car":
|
||||
|
|
@ -131,24 +186,31 @@ public class App {
|
|||
task.accessModes = EnumSet.of(LegMode.WALK);
|
||||
task.egressModes = EnumSet.of(LegMode.WALK);
|
||||
task.directModes = EnumSet.of(LegMode.WALK);
|
||||
task.transitModes = EnumSet.allOf(TransitModes.class);
|
||||
task.transitModes = EnumSet.of(TransitModes.TRANSIT);
|
||||
break;
|
||||
}
|
||||
|
||||
// Compute travel times
|
||||
TravelTimeComputer computer = new TravelTimeComputer(task, network, destinations);
|
||||
int[][] results = computer.computeTravelTimes();
|
||||
TravelTimeComputer computer = new TravelTimeComputer(task, network);
|
||||
OneOriginResult result = computer.computeTravelTimes();
|
||||
|
||||
// results[percentileIdx][destinationIdx] — we only have 1 percentile (index 0)
|
||||
TravelTimeResponse response = new TravelTimeResponse();
|
||||
response.travel_times = new double[req.destinations.length];
|
||||
|
||||
int[] times = results[0]; // percentile 0 (the 50th percentile)
|
||||
for (int i = 0; i < req.destinations.length; i++) {
|
||||
if (i < times.length && times[i] != Integer.MAX_VALUE) {
|
||||
response.travel_times[i] = times[i]; // already in minutes
|
||||
} else {
|
||||
response.travel_times[i] = -1; // unreachable
|
||||
TravelTimeResult tt = result.travelTimes;
|
||||
if (tt != null) {
|
||||
int[][] values = tt.getValues();
|
||||
// values[percentileIndex][destinationIndex]
|
||||
for (int i = 0; i < req.destinations.length; i++) {
|
||||
if (i < values[0].length && values[0][i] != Integer.MAX_VALUE) {
|
||||
response.travel_times[i] = values[0][i]; // already in minutes
|
||||
} else {
|
||||
response.travel_times[i] = -1; // unreachable
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < req.destinations.length; i++) {
|
||||
response.travel_times[i] = -1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +218,6 @@ public class App {
|
|||
System.out.println("Travel times (" + mode + ") computed for " + req.destinations.length +
|
||||
" destinations in " + elapsed + "ms");
|
||||
|
||||
ctx.json(response);
|
||||
sendResponse(exchange, 200, gson.toJson(response));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,3 +17,7 @@ pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
|
|||
pub const AREA_SUMMARY_SYSTEM_PROMPT: &str = "You are an experienced estate agent with an expertise in area analysis. Help the user find his/her dream area or perfect postcode to settle in. The user is looking to buy a property based on the filters they provide. Given area statistics, write at most a single concise sentences summarising the key characteristics of the area. Be factual and highlight notable values. Do not use bullet points or headers — just flowing prose. Do not use markdown formatting. Highlight unusual facts that stand out from the average, but do not exaggerate. If there are no notable characteristics, say so. Always write at most a single sentence! Reason about the relation of different statistics to each other.";
|
||||
pub const AREA_SUMMARY_MAX_TOKENS: usize = 300;
|
||||
pub const AREA_SUMMARY_TEMPERATURE: f32 = 0.3;
|
||||
|
||||
pub const AI_FILTERS_SYSTEM_PROMPT: &str = "You are a property search assistant. The user will describe their ideal property or area in natural language. Your job is to translate their description into filter settings. ONLY set filters the user explicitly mentioned or clearly implied. Leave everything else out. Do not guess or add extra filters. If a request is ambiguous, prefer a wider range. Output valid JSON matching the provided schema.";
|
||||
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
|
||||
pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
mod places;
|
||||
mod poi;
|
||||
mod postcodes;
|
||||
mod property;
|
||||
|
||||
pub use places::PlaceData;
|
||||
pub use poi::{POICategoryGroup, POIData};
|
||||
pub use postcodes::PostcodeData;
|
||||
pub use property::{precompute_h3, FeatureStats, Histogram, PropertyData};
|
||||
pub use property::{precompute_h3, FeatureStats, Histogram, PropertyData, RenovationEvent};
|
||||
|
|
|
|||
|
|
@ -156,6 +156,17 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " rooms",
|
||||
raw: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Estimated monthly rent",
|
||||
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
|
||||
step: 25.0,
|
||||
description: "Median monthly private rent for the local area and bedroom count",
|
||||
detail: "Median monthly rental price from ONS Private Rental Market Summary Statistics (Oct 2022 - Sep 2023). Matched by local authority district and estimated bedroom count (habitable rooms minus 1). Based on Valuation Office Agency lettings data.",
|
||||
source: "ons-rental",
|
||||
prefix: "£",
|
||||
suffix: "/mo",
|
||||
raw: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Date of last transaction",
|
||||
bounds: Bounds::Fixed {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
mod ai_filters;
|
||||
mod area_summary;
|
||||
mod export;
|
||||
mod features;
|
||||
|
|
@ -5,6 +6,7 @@ mod hexagon_stats;
|
|||
pub(crate) mod hexagons;
|
||||
mod me;
|
||||
mod pb_proxy;
|
||||
mod places;
|
||||
mod pois;
|
||||
mod postcode_stats;
|
||||
mod postcodes;
|
||||
|
|
@ -15,6 +17,7 @@ mod stats;
|
|||
mod tiles;
|
||||
pub(crate) mod travel_time;
|
||||
|
||||
pub use ai_filters::{build_feature_prompt, build_ollama_schema, post_ai_filters};
|
||||
pub use area_summary::post_area_summary;
|
||||
pub use export::get_export;
|
||||
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
|
||||
|
|
@ -22,6 +25,7 @@ pub use hexagon_stats::get_hexagon_stats;
|
|||
pub use hexagons::get_hexagons;
|
||||
pub use me::get_me;
|
||||
pub use pb_proxy::proxy_to_pocketbase;
|
||||
pub use places::get_places;
|
||||
pub use pois::{get_poi_categories, get_pois};
|
||||
pub use postcode_stats::get_postcode_stats;
|
||||
pub use postcodes::{get_postcode_lookup, get_postcodes};
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ fn build_prompt(req: &AreaSummaryRequest) -> String {
|
|||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output
|
||||
fn strip_think_blocks(text: &str) -> String {
|
||||
pub(crate) fn strip_think_blocks(text: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ pub async fn get_export(
|
|||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
);
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
|
||||
let public_url = state.public_url.clone();
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub enum FeatureInfo {
|
|||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct FeatureGroupResponse {
|
||||
name: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) features: Vec<FeatureInfo>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,7 +135,8 @@ pub async fn get_hexagons(
|
|||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
);
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
|
||||
|
|
@ -147,7 +148,7 @@ pub async fn get_hexagons(
|
|||
.map(parse_destination)
|
||||
.transpose()
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let mode = params.mode.clone().unwrap_or_else(|| "transit".into());
|
||||
let mode = params.mode.clone().unwrap_or_else(|| "car".into());
|
||||
|
||||
// Capture what we need for the R5 call before moving state into spawn_blocking
|
||||
let r5_url = state.r5_url.clone();
|
||||
|
|
@ -249,16 +250,16 @@ pub async fn get_hexagons(
|
|||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||
|
||||
// If a destination was requested and R5 is configured, fetch travel times
|
||||
// If a destination was requested and R5 is configured, fetch travel times.
|
||||
if let Some(dest) = destination {
|
||||
if r5_url.is_empty() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Travel time queries require R5 service (R5_URL not configured)".into(),
|
||||
"Travel time queries require routing service (R5_URL not configured)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Collect hex centroids from the response
|
||||
// Collect hex centroids
|
||||
let origins: Vec<[f64; 2]> = response
|
||||
.features
|
||||
.iter()
|
||||
|
|
@ -297,8 +298,7 @@ pub async fn get_hexagons(
|
|||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("R5 travel time query failed, returning hexagons without travel_time: {}", err);
|
||||
// Don't fail the whole request — just omit travel_time
|
||||
warn!("Travel time query failed, returning hexagons without travel_time: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
97
server-rs/src/routes/places.rs
Normal file
97
server-rs/src/routes/places.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PlaceResult {
|
||||
name: String,
|
||||
place_type: String,
|
||||
lat: f32,
|
||||
lon: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PlacesResponse {
|
||||
places: Vec<PlaceResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(clippy::min_ident_chars)]
|
||||
pub struct PlacesParams {
|
||||
q: Option<String>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn get_places(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<PlacesParams>,
|
||||
) -> Result<Json<PlacesResponse>, (StatusCode, String)> {
|
||||
let query = params
|
||||
.q
|
||||
.filter(|val| !val.is_empty())
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Missing 'q' parameter".to_string()))?;
|
||||
|
||||
let limit = params.limit.unwrap_or(7).min(20);
|
||||
|
||||
let places = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let query_lower = query.to_lowercase();
|
||||
let pd = &state.place_data;
|
||||
|
||||
// Linear scan — ~50-100k rows, <1ms
|
||||
let mut matches: Vec<(usize, bool, u8, usize)> = pd
|
||||
.name_lower
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
if name.contains(&query_lower) {
|
||||
let is_prefix = name.starts_with(&query_lower);
|
||||
Some((idx, is_prefix, pd.type_rank[idx], pd.name[idx].len()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort: prefix first, then by type rank (cities before hamlets), then shorter names first
|
||||
matches.sort_unstable_by(|lhs, rhs| {
|
||||
rhs.1
|
||||
.cmp(&lhs.1)
|
||||
.then(lhs.2.cmp(&rhs.2))
|
||||
.then(lhs.3.cmp(&rhs.3))
|
||||
});
|
||||
|
||||
matches.truncate(limit);
|
||||
|
||||
let results: Vec<PlaceResult> = matches
|
||||
.iter()
|
||||
.map(|&(idx, ..)| PlaceResult {
|
||||
name: pd.name[idx].clone(),
|
||||
place_type: pd.place_type.get(idx).to_string(),
|
||||
lat: pd.lat[idx],
|
||||
lon: pd.lon[idx],
|
||||
})
|
||||
.collect();
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
info!(
|
||||
query = query.as_str(),
|
||||
results = results.len(),
|
||||
scanned = pd.name_lower.len(),
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/places"
|
||||
);
|
||||
|
||||
results
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
||||
|
||||
Ok(Json(PlacesResponse { places }))
|
||||
}
|
||||
|
|
@ -69,7 +69,8 @@ pub async fn get_postcodes(
|
|||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
);
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use crate::parsing::{
|
|||
cell_for_row, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
|
||||
validate_h3_resolution,
|
||||
};
|
||||
use crate::data::RenovationEvent;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -41,6 +42,9 @@ pub struct Property {
|
|||
|
||||
pub is_construction_date_approximate: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub renovation_history: Vec<RenovationEvent>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub features: FxHashMap<String, f32>,
|
||||
}
|
||||
|
|
@ -214,6 +218,7 @@ pub async fn get_hexagon_properties(
|
|||
),
|
||||
lat: state.data.lat[row],
|
||||
lon: state.data.lon[row],
|
||||
renovation_history: state.data.renovation_history(row).to_vec(),
|
||||
features,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,20 +2,25 @@ use serde::{Deserialize, Serialize};
|
|||
use tracing::warn;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TravelTimeRequest {
|
||||
origins: Vec<[f64; 2]>,
|
||||
destination: [f64; 2],
|
||||
struct R5Request {
|
||||
origin: [f64; 2],
|
||||
destinations: Vec<[f64; 2]>,
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TravelTimeResponse {
|
||||
travel_times: Vec<Option<f64>>,
|
||||
struct R5Response {
|
||||
travel_times: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Call the R5 service to compute many-to-one travel times.
|
||||
/// Call the R5 Java service to compute one-to-many travel times.
|
||||
///
|
||||
/// Returns a Vec of travel times in minutes (one per origin), with None for unreachable origins.
|
||||
/// `origins` are hex centroids as `[lat, lon]`.
|
||||
/// `destination` is the user-chosen point as `[lat, lon]`.
|
||||
/// `mode` is one of "car", "bicycle", "walking", "transit".
|
||||
///
|
||||
/// R5 computes from destination to all origins (one-to-many from the user's chosen point).
|
||||
/// Returns a Vec of travel times in minutes (one per origin), with None for unreachable.
|
||||
pub async fn fetch_travel_times(
|
||||
client: &reqwest::Client,
|
||||
r5_url: &str,
|
||||
|
|
@ -23,36 +28,45 @@ pub async fn fetch_travel_times(
|
|||
destination: [f64; 2],
|
||||
mode: &str,
|
||||
) -> Result<Vec<Option<f64>>, String> {
|
||||
let url = format!("{}/travel-times", r5_url);
|
||||
if origins.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let request_body = TravelTimeRequest {
|
||||
origins,
|
||||
destination,
|
||||
let body = R5Request {
|
||||
origin: destination,
|
||||
destinations: origins,
|
||||
mode: mode.to_string(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&request_body)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.post(format!("{}/travel-times", r5_url))
|
||||
.json(&body)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("R5 request failed: {}", e);
|
||||
format!("R5 service error: {}", e)
|
||||
format!("R5 routing error: {}", e)
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
warn!("R5 returned {}: {}", status, body);
|
||||
return Err(format!("R5 service returned {}: {}", status, body));
|
||||
return Err(format!("R5 returned {}: {}", status, body));
|
||||
}
|
||||
|
||||
let body: TravelTimeResponse = resp.json().await.map_err(|e| {
|
||||
let r5_resp: R5Response = resp.json().await.map_err(|e| {
|
||||
warn!("Failed to parse R5 response: {}", e);
|
||||
format!("Failed to parse R5 response: {}", e)
|
||||
})?;
|
||||
|
||||
Ok(body.travel_times)
|
||||
// R5 returns -1 for unreachable destinations
|
||||
let travel_times: Vec<Option<f64>> = r5_resp
|
||||
.travel_times
|
||||
.into_iter()
|
||||
.map(|t| if t < 0.0 { None } else { Some(t) })
|
||||
.collect();
|
||||
|
||||
Ok(travel_times)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::auth::TokenCache;
|
||||
use crate::data::{POICategoryGroup, POIData, PostcodeData, PropertyData};
|
||||
use crate::data::{POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData};
|
||||
use crate::routes::FeaturesResponse;
|
||||
use crate::utils::GridIndex;
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ pub struct AppState {
|
|||
pub h3_cells: Vec<u64>,
|
||||
pub poi_data: POIData,
|
||||
pub poi_grid: GridIndex,
|
||||
pub place_data: PlaceData,
|
||||
/// Postcode boundary data for high-zoom rendering
|
||||
pub postcode_data: PostcodeData,
|
||||
/// O(1) lookup: feature name → index in feature_names/feature_data
|
||||
|
|
@ -43,8 +44,12 @@ pub struct AppState {
|
|||
pub ollama_url: String,
|
||||
/// Ollama model name for area summaries (e.g. gemma3:12b)
|
||||
pub ollama_model: String,
|
||||
/// R5 routing service URL for real-time travel times (empty = disabled)
|
||||
/// R5 routing service URL for all travel times (empty = disabled)
|
||||
pub r5_url: String,
|
||||
/// Token validation cache (60s TTL)
|
||||
pub token_cache: Arc<TokenCache>,
|
||||
/// JSON schema for Ollama structured output in AI filters
|
||||
pub ai_filters_schema: serde_json::Value,
|
||||
/// Feature listing portion of the AI filters prompt
|
||||
pub ai_filters_feature_prompt: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
mod grid_index;
|
||||
mod hash;
|
||||
mod interned_column;
|
||||
mod llm;
|
||||
|
||||
pub use grid_index::GridIndex;
|
||||
pub use hash::{generate_priorities, splitmix64_hash};
|
||||
pub use interned_column::InternedColumn;
|
||||
pub use llm::strip_think_blocks;
|
||||
|
|
|
|||
15
server-rs/src/utils/llm.rs
Normal file
15
server-rs/src/utils/llm.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/// Strip `<think>...</think>` blocks from model output
|
||||
pub fn strip_think_blocks(text: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
if let Some(end) = remaining[start..].find("</think>") {
|
||||
remaining = &remaining[start + end + 8..];
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue