This commit is contained in:
Andras Schmelczer 2026-02-10 22:21:15 +00:00
parent 1f68ca0512
commit 3599803589
43 changed files with 3578 additions and 262 deletions

View file

@ -42,6 +42,8 @@ INSPIRE_DIR := $(DATA_DIR)/inspire
OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg
UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet
PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries
TRANSIT_DIR := $(DATA_DIR)/transit
TRANSIT_STAMP := $(TRANSIT_DIR)/.done
# Sentinel files for directory targets (Make doesn't track directories well) # Sentinel files for directory targets (Make doesn't track directories well)
GEOSURE_STAMP := $(GEOSURE_DIR)/.done GEOSURE_STAMP := $(GEOSURE_DIR)/.done
@ -55,7 +57,7 @@ PMTILES_VERSION := 1.22.3
download-arcgis download-price-paid download-deprivation download-ethnicity \ 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-postcodes download-geosure download-noise download-inspire \ download-postcodes download-geosure download-noise download-inspire \
download-oa-boundaries download-uprn-lookup \ download-oa-boundaries download-uprn-lookup download-transit-network \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \
transform-school-proximity transform-geosure transform-postcode-boundaries \ transform-school-proximity transform-geosure transform-postcode-boundaries \
generate-postcode-boundaries \ generate-postcode-boundaries \
@ -78,6 +80,7 @@ download-noise: $(NOISE)
download-inspire: $(INSPIRE_STAMP) download-inspire: $(INSPIRE_STAMP)
download-oa-boundaries: $(OA_BOUNDARIES) download-oa-boundaries: $(OA_BOUNDARIES)
download-uprn-lookup: $(UPRN_LOOKUP) download-uprn-lookup: $(UPRN_LOOKUP)
download-transit-network: $(TRANSIT_STAMP)
transform-pois: $(POIS_FILTERED) transform-pois: $(POIS_FILTERED)
transform-epc-pp: $(EPC_PP) transform-epc-pp: $(EPC_PP)
transform-crime: $(CRIME) transform-crime: $(CRIME)
@ -152,6 +155,10 @@ $(OA_BOUNDARIES):
$(UPRN_LOOKUP): $(UPRN_LOOKUP):
uv run python -m pipeline.download.uprn_lookup --output $@ uv run python -m pipeline.download.uprn_lookup --output $@
$(TRANSIT_STAMP):
uv run python -m pipeline.download.transit_network --output $(TRANSIT_DIR)
@touch $@
# ── Journey times (requires TFL_API_KEY) ────────────────────────────────────── # ── Journey times (requires TFL_API_KEY) ──────────────────────────────────────
$(JT_BANK): $(JT_BANK):

View file

@ -16,6 +16,11 @@ tasks:
cmds: cmds:
- uv run python -m pipeline.download.map_assets --output frontend/public/assets - 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
cmds:
- uv run python -m pipeline.download.greenspace_water --output data/greenspace_water.parquet {{.CLI_ARGS}}
test: test:
desc: Run all tests (Python and Rust) desc: Run all tests (Python and Rust)
cmds: cmds:
@ -27,6 +32,7 @@ tasks:
- uv run -m pipeline.utils.test_fuzzy_join - uv run -m pipeline.utils.test_fuzzy_join
- uv run pytest pipeline/utils/test_haversine.py - uv run pytest pipeline/utils/test_haversine.py
- uv run pytest pipeline/utils/test_poi_counts.py - uv run pytest pipeline/utils/test_poi_counts.py
- uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
test:server: test:server:
desc: Run Rust backend tests desc: Run Rust backend tests

View file

@ -1,6 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import MapPage, { type ExportState } from './components/map/MapPage'; import MapPage, { type ExportState } from './components/map/MapPage';
import LearnPage from './components/learn/LearnPage';
import PricingPage from './components/pricing/PricingPage'; import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage'; import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage'; import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
@ -26,9 +25,7 @@ function pageToPath(page: Page): string {
switch (page) { switch (page) {
case 'dashboard': case 'dashboard':
return '/dashboard'; return '/dashboard';
case 'learn': case 'saved-searches':
return '/learn';
case 'saved-searches':
return '/saved'; return '/saved';
case 'pricing': case 'pricing':
return '/pricing'; return '/pricing';
@ -39,8 +36,7 @@ function pageToPath(page: Page): string {
function pathToPage(pathname: string): Page | null { function pathToPage(pathname: string): Page | null {
if (pathname === '/dashboard') return 'dashboard'; if (pathname === '/dashboard') return 'dashboard';
if (pathname === '/learn') return 'learn'; if (pathname === '/saved') return 'saved-searches';
if (pathname === '/saved') return 'saved-searches';
if (pathname === '/pricing') return 'pricing'; if (pathname === '/pricing') return 'pricing';
if (pathname === '/') return 'home'; if (pathname === '/') return 'home';
return null; return null;
@ -81,7 +77,7 @@ export default function App() {
// Backward compat: dashboard params on unknown path // Backward compat: dashboard params on unknown path
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.has('lat') || params.has('filter') || params.has('poi') || params.has('tab') || params.has('v') || params.has('f')) { if (params.has('lat') || params.has('filter') || params.has('poi') || params.has('tab') || params.has('v') || params.has('f') || params.has('dest')) {
// Rewrite URL to /dashboard keeping query params // Rewrite URL to /dashboard keeping query params
window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`); window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`);
return 'dashboard'; return 'dashboard';
@ -207,6 +203,7 @@ export default function App() {
onNavigateTo={() => {}} onNavigateTo={() => {}}
screenshotMode screenshotMode
ogMode={isOgMode} ogMode={isOgMode}
initialTravelTime={urlState.travelTime}
/> />
); );
} }
@ -236,8 +233,6 @@ export default function App() {
/> />
{activePage === 'home' ? ( {activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} /> <HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'pricing' ? ( ) : activePage === 'pricing' ? (
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} /> <PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
) : activePage === 'saved-searches' ? ( ) : activePage === 'saved-searches' ? (
@ -264,6 +259,7 @@ export default function App() {
onNavigateTo={navigateTo} onNavigateTo={navigateTo}
onExportStateChange={setExportState} onExportStateChange={setExportState}
isMobile={isMobile} isMobile={isMobile}
initialTravelTime={urlState.travelTime}
/> />
)} )}
{showAuthModal && ( {showAuthModal && (

View file

@ -165,7 +165,7 @@ export default function DataSourcesPage() {
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100"> <h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{source.name} {source.name}
</h2> </h2>
<span className="shrink-0 text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded"> <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} {source.license}
</span> </span>
</div> </div>

View file

@ -10,7 +10,7 @@ const FAQ_ITEMS: FAQItem[] = [
{ {
question: 'What is this application?', question: 'What is this application?',
answer: answer:
'Perfect Postcodes is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.', 'Perfect Postcode is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
}, },
{ {
question: 'Where does the data come from?', question: 'Where does the data come from?',
@ -101,7 +101,7 @@ export default function FAQPage() {
Frequently Asked Questions Frequently Asked Questions
</h1> </h1>
<p className="text-warm-600 dark:text-warm-400 mb-6"> <p className="text-warm-600 dark:text-warm-400 mb-6">
Common questions about how Perfect Postcodes works, where the data comes from, and how to use the Common questions about how Perfect Postcode works, where the data comes from, and how to use the
map. map.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">

View file

@ -0,0 +1,399 @@
import { useEffect, useState, useRef } from 'react';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
type LearnTab = 'data-sources' | 'faq';
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',
},
];
interface FAQItem {
question: string;
answer: string;
}
const FAQ_ITEMS: FAQItem[] = [
{
question: 'What is this application?',
answer:
'Perfect Postcode is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
},
{
question: 'Where does the data come from?',
answer:
'All data comes from open government and community sources. Property prices are from HM Land Registry, energy certificates from MHCLG, transport times from TfL, deprivation scores from the English Indices of Deprivation 2025, crime data from data.police.uk, school ratings from Ofsted, broadband from Ofcom, noise from Defra, ethnicity from the 2021 Census, and points of interest from OpenStreetMap. See the Data Sources tab for full details and links.',
},
{
question: 'What are the coloured hexagons on the map?',
answer:
'The map uses H3 hexagons to aggregate property data at different zoom levels. Each hexagon summarises the properties within it. The colour represents the value of whichever feature you have pinned or are actively filtering — for example, average price or energy rating. Zoom in to see smaller, more detailed hexagons; zoom out for a broader overview.',
},
{
question: 'How do filters work?',
answer:
'Use the Filters panel on the left to narrow down properties. Add a filter by clicking a feature name, then drag the range slider to set minimum and maximum values. For categorical features like property type, select or deselect individual values. Only hexagons containing properties that match all active filters are shown. Filters are combined with AND logic — every property must satisfy every filter.',
},
{
question: 'What does the eye icon do on a filter?',
answer:
"The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.",
},
{
question: 'How fresh is the data?',
answer:
'Property prices cover all Land Registry transactions up to the most recent quarterly release. EPC data includes certificates issued up to the latest available download. Crime data spans 20232025 as yearly averages. TfL journey times are computed from current timetables. Deprivation indices are from the 2025 release. School ratings reflect the latest Ofsted inspections as at April 2025. Broadband data is from Ofcom Connected Nations 2025.',
},
{
question: 'How are EPC records matched to Land Registry sales?',
answer:
"EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.",
},
{
question: 'What are Points of Interest (POIs)?',
answer:
'POIs are places like cafes, schools, supermarkets, GP surgeries, parks, and train stations extracted from OpenStreetMap and the NaPTAN public transport dataset. Use the POI panel on the right to toggle categories on and off. POIs appear as markers on the map when you are zoomed in far enough.',
},
{
question: 'Can I share a specific view with someone?',
answer:
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL to your clipboard. Anyone who opens that link will see the same view, filters, and active POI categories.',
},
{
question: 'How do I see individual properties?',
answer:
'Click on a hexagon to open the Properties panel on the right. It lists all matching properties within that hexagon, showing address, price, and key features. Use "Load more" at the bottom to paginate through large hexagons.',
},
{
question: 'Why are some hexagons grey?',
answer:
'Grey hexagons contain properties that have data but fall outside the range of your currently pinned or active feature. This gives you a sense of where properties exist even when their values are outside your selected range.',
},
{
question: 'Does this work on mobile?',
answer:
'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.',
},
];
function FAQItemCard({ item }: { item: FAQItem }) {
const [open, setOpen] = useState(false);
return (
<div className="bg-white dark:bg-navy-800 rounded-lg border border-warm-200 dark:border-navy-700">
<button
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
onClick={() => setOpen(!open)}
>
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
<ChevronIcon
direction="down"
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="px-5 pb-4">
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">{item.answer}</p>
</div>
)}
</div>
);
}
export default function LearnPage() {
const [tab, setTab] = useState<LearnTab>('data-sources');
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function handleHash() {
const hash = window.location.hash.replace('#', '');
if (hash === 'faq') {
setTab('faq');
setHighlightedId(null);
} else if (hash && DATA_SOURCES.some((s) => s.id === hash)) {
setTab('data-sources');
setHighlightedId(hash);
setTimeout(() => {
cardRefs.current[hash]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
} else {
setHighlightedId(null);
}
}
handleHash();
window.addEventListener('hashchange', handleHash);
return () => window.removeEventListener('hashchange', handleHash);
}, []);
// Scroll to top when switching tabs
useEffect(() => {
scrollContainerRef.current?.scrollTo(0, 0);
}, [tab]);
const tabClass = (t: LearnTab) =>
`px-4 py-2 text-sm font-medium rounded-t border-b-2 ${
tab === t
? 'border-teal-500 text-teal-700 dark:text-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`;
return (
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
{/* Tab bar */}
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
<div className="flex gap-2 border-b border-warm-200 dark:border-navy-700">
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
Data Sources
</button>
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
FAQ
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
{tab === 'data-sources' ? (
<>
<div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-6">
<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 &copy; 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 &copy; 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"
>
&copy; 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 className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
Frequently Asked Questions
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
Common questions about how Perfect Postcode works, where the data comes from, and how
to use the map.
</p>
<div className="space-y-3">
{FAQ_ITEMS.map((item, index) => (
<FAQItemCard key={index} item={item} />
))}
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -9,6 +9,8 @@ import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel'; import { FeatureLabel } from '../ui/FeatureLabel';
import { RouteIcon, PlusIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
interface FeatureBrowserProps { interface FeatureBrowserProps {
availableFeatures: FeatureMeta[]; availableFeatures: FeatureMeta[];
@ -19,6 +21,8 @@ interface FeatureBrowserProps {
onNavigateToSource?: (slug: string, featureName: string) => void; onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null; openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void; onClearOpenInfoFeature?: () => void;
travelTimeEnabled?: boolean;
onEnableTravelTime?: () => void;
} }
export default function FeatureBrowser({ export default function FeatureBrowser({
@ -30,6 +34,8 @@ export default function FeatureBrowser({
onNavigateToSource, onNavigateToSource,
openInfoFeature, openInfoFeature,
onClearOpenInfoFeature, onClearOpenInfoFeature,
travelTimeEnabled,
onEnableTravelTime,
}: FeatureBrowserProps) { }: FeatureBrowserProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null); const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -60,6 +66,26 @@ export default function FeatureBrowser({
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." /> <SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div> </div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col"> <div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{!travelTimeEnabled && onEnableTravelTime && (!search || 'travel time journey commute'.includes(search.toLowerCase())) && (
<div className="shrink-0 border-b border-warm-200 dark:border-warm-700">
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
<div className="flex items-center gap-2 min-w-0" onClick={onEnableTravelTime}>
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Color by journey time to a destination
</span>
</div>
</div>
<IconButton onClick={() => onEnableTravelTime()} title="Add travel time">
<PlusIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
</div>
)}
{grouped.map((group) => { {grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name); const isExpanded = isSearching || expandedGroups.has(group.name);
return ( return (

View file

@ -1,14 +1,51 @@
import { memo, useState } from 'react'; import { memo, useState, useMemo } from 'react';
import { Slider } from '../ui/Slider'; import { Slider } from '../ui/Slider';
import { FilterIcon, LightbulbIcon } from '../ui/icons'; import { FilterIcon, LightbulbIcon } from '../ui/icons';
import { EmptyState } from '../ui/EmptyState'; import { EmptyState } from '../ui/EmptyState';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import type { FeatureMeta, FeatureFilters } from '../../types'; import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue } from '../../lib/format'; import { formatFilterValue } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import InfoPopup from '../ui/InfoPopup'; import InfoPopup from '../ui/InfoPopup';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel'; import { FeatureLabel } from '../ui/FeatureLabel';
import FeatureBrowser from './FeatureBrowser'; import FeatureBrowser from './FeatureBrowser';
import { TravelTimeCard } from './TravelTimeCard';
import type { TransportMode } from '../../hooks/useTravelTime';
function SliderLabels({
min,
max,
value,
}: {
min: number;
max: number;
value: [number, number];
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
const rightPct = ((value[1] - min) / range) * 100;
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{formatFilterValue(value[0])}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{formatFilterValue(value[1])}
</span>
</div>
);
}
interface FiltersProps { interface FiltersProps {
features: FeatureMeta[]; features: FeatureMeta[];
@ -22,15 +59,23 @@ interface FiltersProps {
onDragStart: (name: string) => void; onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void; onDragChange: (value: [number, number]) => void;
onDragEnd: () => void; onDragEnd: () => void;
zoom: number;
itemCount: number;
usePostcodeView: boolean;
pinnedFeature: string | null; pinnedFeature: string | null;
onTogglePin: (name: string) => void; onTogglePin: (name: string) => void;
onCancelPin: () => void; onCancelPin: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void; onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null; openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void; onClearOpenInfoFeature?: () => void;
travelTimeEnabled: boolean;
travelTimeDestination: [number, number] | null;
travelTimeDestinationLabel: string;
travelTimeMode: TransportMode;
travelTimeRange: [number, number] | null;
travelTimeDataRange: [number, number] | null;
onTravelTimeEnable: () => void;
onTravelTimeDisable: () => void;
onTravelTimeSetDestination: (lat: number, lon: number, label: string) => void;
onTravelTimeModeChange: (mode: TransportMode) => void;
onTravelTimeRangeChange: (range: [number, number]) => void;
} }
export default memo(function Filters({ export default memo(function Filters({
@ -45,21 +90,34 @@ export default memo(function Filters({
onDragStart, onDragStart,
onDragChange, onDragChange,
onDragEnd, onDragEnd,
zoom,
itemCount,
usePostcodeView,
pinnedFeature, pinnedFeature,
onTogglePin, onTogglePin,
onCancelPin: _onCancelPin, onCancelPin: _onCancelPin,
onNavigateToSource, onNavigateToSource,
openInfoFeature, openInfoFeature,
onClearOpenInfoFeature, onClearOpenInfoFeature,
travelTimeEnabled,
travelTimeDestination,
travelTimeDestinationLabel,
travelTimeMode,
travelTimeRange,
travelTimeDataRange,
onTravelTimeEnable,
onTravelTimeDisable,
onTravelTimeSetDestination,
onTravelTimeModeChange,
onTravelTimeRangeChange,
}: FiltersProps) { }: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const [showPhilosophy, setShowPhilosophy] = useState(false); const [showPhilosophy, setShowPhilosophy] = useState(false);
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null); const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const enabledGroups = useMemo(
() => groupFeaturesByCategory(enabledFeatureList),
[enabledFeatureList]
);
return ( return (
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full"> <div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
@ -72,118 +130,146 @@ export default memo(function Filters({
Finding the Perfect Postcode Finding the Perfect Postcode
</button> </button>
</div> </div>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:max-h-[65%]"> <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"> <div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100"> <span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
Active Filters Active Filters
</span> </span>
{enabledFeatureList.length > 0 && ( {(enabledFeatureList.length > 0 || travelTimeEnabled) && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400"> <span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{enabledFeatureList.length} {enabledFeatureList.length + (travelTimeEnabled ? 1 : 0)}
</span> </span>
)} )}
</div> </div>
<span className="text-xs text-warm-500 dark:text-warm-400">
{itemCount.toLocaleString()} {usePostcodeView ? 'postcodes' : 'hexagons'} · z
{zoom.toFixed(1)}
</span>
</div> </div>
<div className="md:flex-1 md:overflow-y-auto p-3 space-y-3"> <div className="md:flex-1 md:overflow-y-auto">
{enabledFeatureList.length === 0 && ( {travelTimeEnabled && (
<div className="px-2 py-1">
<TravelTimeCard
destination={travelTimeDestination}
destinationLabel={travelTimeDestinationLabel}
mode={travelTimeMode}
timeRange={travelTimeRange}
dataRange={travelTimeDataRange}
onSetDestination={onTravelTimeSetDestination}
onModeChange={onTravelTimeModeChange}
onTimeRangeChange={onTravelTimeRangeChange}
onRemove={onTravelTimeDisable}
/>
</div>
)}
{enabledFeatureList.length === 0 && !travelTimeEnabled && (
<EmptyState <EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />} icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title="No active filters" title="No active filters"
description="Browse features below and click + to add a filter" description="Browse features below and click + to add a filter"
className="px-3 py-4"
/> />
)} )}
{enabledFeatureList.map((feature) => { {enabledGroups.map((group) => {
if (feature.type === 'enum') { const isExpanded = !collapsedGroups.has(group.name);
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div
key={feature.name}
className={`space-y-1 p-3 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<div className="space-y-0.5 max-h-40 overflow-y-auto">
{allValues.map((val) => (
<label
key={val}
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedValues.includes(val)}
onChange={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
className="rounded accent-teal-600"
/>
{val}
</label>
))}
</div>
</div>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
return ( return (
<div <div key={group.name}>
key={feature.name} <CollapsibleGroupHeader
className={`space-y-1 p-3 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`} name={group.name}
> expanded={isExpanded}
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" /> onToggle={() => toggleGroup(group.name)}
<div className="flex items-center justify-between"> className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
<span className="text-sm text-warm-500 dark:text-warm-400"> >
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])} <span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span> </span>
<FeatureActions </CollapsibleGroupHeader>
feature={feature} {isExpanded && (
isPinned={isPinned} <div className="px-2 py-1 space-y-1">
onTogglePin={onTogglePin} {group.features.map((feature) => {
onRemove={onRemoveFilter} if (feature.type === 'enum') {
/> const selectedValues = (filters[feature.name] as string[]) || [];
</div> const allValues = feature.values || [];
<Slider return (
min={feature.min!} <div
max={feature.max!} key={feature.name}
step={step} className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
value={[displayValue[0], displayValue[1]]} >
onValueChange={([min, max]) => onDragChange([min, max])} <div className="flex items-center justify-between">
onPointerDown={() => onDragStart(feature.name)} <FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
onPointerUp={() => onDragEnd()} <FeatureActions
/> feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={val}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
/>
))}
</PillGroup>
</div>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
return (
<div
key={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between gap-1">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
<FeatureActions
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels min={feature.min!} max={feature.max!} value={displayValue} />
</div>
</div>
);
})}
</div>
)}
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
<div className="shrink-0 md:shrink md:min-h-0 md:flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700"> <div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[60%] border-t border-warm-200 dark:border-warm-700">
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700"> <div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span> <span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
</div> </div>
@ -197,6 +283,8 @@ export default memo(function Filters({
onNavigateToSource={onNavigateToSource} onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature} openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature} onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEnabled={travelTimeEnabled}
onEnableTravelTime={onTravelTimeEnable}
/> />
</div> </div>
</div> </div>

View file

@ -46,6 +46,10 @@ interface MapProps {
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void; onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
bounds?: Bounds | null; bounds?: Bounds | null;
hideLegend?: boolean; hideLegend?: boolean;
travelTimeEnabled?: boolean;
travelTimeDestination?: [number, number] | null;
travelTimeColorRange?: [number, number] | null;
travelTimeRange?: [number, number] | null;
} }
interface Dimensions { interface Dimensions {
@ -98,6 +102,10 @@ export default memo(function Map({
onPostcodeSearched, onPostcodeSearched,
bounds: viewportBounds, bounds: viewportBounds,
hideLegend = false, hideLegend = false,
travelTimeEnabled = false,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
}: MapProps) { }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE); const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
@ -176,6 +184,10 @@ export default memo(function Map({
theme, theme,
searchedPostcode, searchedPostcode,
bounds: viewportBounds, bounds: viewportBounds,
travelTimeEnabled,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
}); });
return ( return (
@ -204,7 +216,7 @@ export default memo(function Map({
className="text-5xl font-bold text-white drop-shadow-lg" className="text-5xl font-bold text-white drop-shadow-lg"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }} style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
> >
Your perfect postcodes Your perfect postcode
</h1> </h1>
</div> </div>
) : null ) : null
@ -212,7 +224,17 @@ export default memo(function Map({
<> <>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} /> <PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{!hideLegend && {!hideLegend &&
(viewFeature && colorRange && colorFeatureMeta ? ( (travelTimeEnabled && travelTimeDestination && travelTimeColorRange ? (
<MapLegend
featureLabel="Travel time"
range={travelTimeColorRange}
showCancel={false}
onCancel={onCancelPin}
mode="feature"
theme={theme}
suffix=" min"
/>
) : viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend <MapLegend
featureLabel={ featureLabel={
viewSource === 'eye' viewSource === 'eye'

View file

@ -13,6 +13,7 @@ export default function MapLegend({
enumValues, enumValues,
theme = 'light', theme = 'light',
inline = false, inline = false,
suffix,
}: { }: {
featureLabel: string; featureLabel: string;
range: [number, number]; range: [number, number];
@ -22,6 +23,7 @@ export default function MapLegend({
enumValues?: string[]; enumValues?: string[];
theme?: 'light' | 'dark'; theme?: 'light' | 'dark';
inline?: boolean; inline?: boolean;
suffix?: string;
}) { }) {
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle = const gradientStyle =
@ -61,8 +63,8 @@ export default function MapLegend({
</> </>
) : ( ) : (
<> <>
<TickerValue text={formatValue(range[0])} /> <TickerValue text={formatValue(range[0]) + (suffix || '')} />
<TickerValue text={formatValue(range[1])} /> <TickerValue text={formatValue(range[1]) + (suffix || '')} />
</> </>
)} )}
</div> </div>

View file

@ -8,7 +8,6 @@ import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane'; import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane'; import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer'; import MobileDrawer from './MobileDrawer';
import DataSources from '../data-sources/DataSources';
import MapLegend from './MapLegend'; import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton'; import { TabButton } from '../ui/TabButton';
import { useMapData } from '../../hooks/useMapData'; import { useMapData } from '../../hooks/useMapData';
@ -18,6 +17,7 @@ import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize'; import { usePaneResize } from '../../hooks/usePaneResize';
import { useAreaSummary } from '../../hooks/useAreaSummary'; import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync'; import { useUrlSync } from '../../hooks/useUrlSync';
import { useTravelTime, type TravelTimeInitial } from '../../hooks/useTravelTime';
import { apiUrl, buildFilterString } from '../../lib/api'; import { apiUrl, buildFilterString } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -44,6 +44,7 @@ interface MapPageProps {
screenshotMode?: boolean; screenshotMode?: boolean;
ogMode?: boolean; ogMode?: boolean;
isMobile?: boolean; isMobile?: boolean;
initialTravelTime?: TravelTimeInitial;
} }
export default function MapPage({ export default function MapPage({
@ -62,6 +63,7 @@ export default function MapPage({
screenshotMode, screenshotMode,
ogMode, ogMode,
isMobile = false, isMobile = false,
initialTravelTime,
}: MapPageProps) { }: MapPageProps) {
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null); const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] = const [selectedPOICategories, setSelectedPOICategories] =
@ -99,6 +101,9 @@ export default function MapPage({
features, features,
}); });
// Travel time hook
const travelTime = useTravelTime(initialTravelTime);
// Map data hook // Map data hook
const mapData = useMapData({ const mapData = useMapData({
filters, filters,
@ -107,6 +112,9 @@ export default function MapPage({
activeFeature, activeFeature,
dragValue, dragValue,
dragData, dragData,
travelTimeEnabled: travelTime.enabled,
travelTimeDestination: travelTime.destination,
travelTimeMode: travelTime.mode,
}); });
// Keep filter bounds in sync with map data // Keep filter bounds in sync with map data
@ -124,8 +132,21 @@ export default function MapPage({
// POI data // POI data
const pois = usePOIData(mapData.bounds, selectedPOICategories); const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Compute data range for travel time slider
const travelTimeDataRange = useMemo((): [number, number] | null => {
if (!travelTime.enabled || !travelTime.destination) return null;
const vals: number[] = [];
for (const item of mapData.data) {
const val = item.travel_time;
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [vals[0], vals[vals.length - 1]];
}, [travelTime.enabled, travelTime.destination, mapData.data]);
// Sync current state to URL // Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab); useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime);
// Set initial view and tab from URL state // Set initial view and tab from URL state
useEffect(() => { useEffect(() => {
@ -201,7 +222,7 @@ export default function MapPage({
.then((blob) => { .then((blob) => {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob);
link.download = 'perfect-postcodes-export.xlsx'; link.download = 'perfect-postcode-export.xlsx';
link.click(); link.click();
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
}) })
@ -292,7 +313,6 @@ export default function MapPage({
onClose={selection.handleCloseSelection} onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation} hexagonLocation={hexagonLocation}
filters={filters} filters={filters}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
aiSummary={aiSummary.summary} aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading} aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error} aiSummaryError={aiSummary.error}
@ -307,7 +327,6 @@ export default function MapPage({
hexagonId={selection.selectedHexagon?.id || null} hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties} onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection} onClose={selection.handleCloseSelection}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/> />
); );
@ -317,7 +336,6 @@ export default function MapPage({
selectedCategories={selectedPOICategories} selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories} onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length} poiCount={pois.length}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/> />
); );
@ -334,15 +352,22 @@ export default function MapPage({
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragChange={handleDragChange} onDragChange={handleDragChange}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
zoom={mapData.zoom}
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
usePostcodeView={mapData.usePostcodeView}
pinnedFeature={pinnedFeature} pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin} onCancelPin={handleCancelPin}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
openInfoFeature={pendingInfoFeature} openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeDestinationLabel={travelTime.destinationLabel}
travelTimeMode={travelTime.mode}
travelTimeRange={travelTime.timeRange}
travelTimeDataRange={travelTimeDataRange}
onTravelTimeEnable={travelTime.handleEnable}
onTravelTimeDisable={travelTime.handleDisable}
onTravelTimeSetDestination={travelTime.handleSetDestination}
onTravelTimeModeChange={travelTime.handleModeChange}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
/> />
); );
@ -386,13 +411,16 @@ export default function MapPage({
onPostcodeSearched={setSearchedPostcode} onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds} bounds={mapData.bounds}
hideLegend hideLegend
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeColorRange={mapData.travelTimeColorRange}
travelTimeRange={travelTime.timeRange}
/> />
{mapData.loading && ( {mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs"> <div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
Loading... Loading...
</div> </div>
)} )}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div> </div>
{/* Bottom panel — 55% */} {/* Bottom panel — 55% */}
@ -401,7 +429,18 @@ export default function MapPage({
style={{ flex: '55 0 0' }} style={{ flex: '55 0 0' }}
> >
{/* Legend */} {/* Legend */}
{viewFeature && mapData.colorRange && mobileLegendMeta ? ( {travelTime.enabled && travelTime.destination && mapData.travelTimeColorRange ? (
<MapLegend
featureLabel="Travel time"
range={mapData.travelTimeColorRange}
showCancel={false}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
) : viewFeature && mapData.colorRange && mobileLegendMeta ? (
<MapLegend <MapLegend
featureLabel={ featureLabel={
viewSource === 'eye' viewSource === 'eye'
@ -516,13 +555,16 @@ export default function MapPage({
searchedPostcode={searchedPostcode} searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode} onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds} bounds={mapData.bounds}
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeColorRange={mapData.travelTimeColorRange}
travelTimeRange={travelTime.timeRange}
/> />
{mapData.loading && ( {mapData.loading && (
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow"> <div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
Loading... Loading...
</div> </div>
)} )}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div> </div>
{/* Right Pane */} {/* Right Pane */}

View file

@ -3,6 +3,8 @@ import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import type { POICategoryGroup } from '../../types'; import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup'; import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import { InfoIcon, ChevronIcon } from '../ui/icons'; import { InfoIcon, ChevronIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton'; import { IconButton } from '../ui/IconButton';
@ -162,39 +164,32 @@ export default function POIPane({
> >
<ChevronIcon direction="right" className="w-3 h-3" /> <ChevronIcon direction="right" className="w-3 h-3" />
</button> </button>
<label className="flex items-center gap-2 flex-1 cursor-pointer"> <PillToggle
<input label={group.name}
type="checkbox" active={allInGroupSelected}
checked={allInGroupSelected} indeterminate={someInGroupSelected}
ref={(el) => { onClick={() => toggleGroup(group.name)}
if (el) el.indeterminate = someInGroupSelected; size="xs"
}} />
onChange={() => toggleGroup(group.name)} <span className="text-xs text-warm-400 ml-auto">
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
{group.name}
</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length} {groupSelected}/{group.categories.length}
</span> </span>
</div> </div>
{!isCollapsed && {!isCollapsed && (
group.categories.map((category) => ( <div className="px-3 py-2">
<label <PillGroup>
key={category} {group.categories.map((category) => (
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300" <PillToggle
> key={category}
<input label={category}
type="checkbox" active={selectedCategories.has(category)}
checked={selectedCategories.has(category)} onClick={() => toggleCategory(category)}
onChange={() => toggleCategory(category)} size="xs"
className="rounded accent-teal-600" />
/> ))}
<span className="text-sm flex-1">{category}</span> </PillGroup>
</label> </div>
))} )}
</div> </div>
); );
})} })}

View file

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Property } from '../../types'; import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber } from '../../lib/format'; import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields'; import { getNum } from '../../lib/property-fields';
import InfoPopup from '../ui/InfoPopup'; import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
@ -145,6 +145,7 @@ function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price'); const price = getNum(property, 'Last known price', 'latest_price');
const estimatedPrice = getNum(property, 'Estimated current price'); const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm'); const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const estPricePerSqm = getNum(property, 'Est. price per sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area'); const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum( const rooms = getNum(
property, property,
@ -152,6 +153,7 @@ function PropertyCard({ property }: { property: Property }) {
'number_habitable_rooms' 'number_habitable_rooms'
); );
const age = getNum(property, 'Approximate construction age', 'construction_age_band'); const age = getNum(property, 'Approximate construction age', 'construction_age_band');
const transactionDate = getNum(property, 'Date of last transaction', 'date_of_transfer');
const councilTax = getNum(property, 'Council tax (£/yr)'); const councilTax = getNum(property, 'Council tax (£/yr)');
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)'); const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
@ -165,10 +167,16 @@ function PropertyCard({ property }: { property: Property }) {
{price !== undefined && ( {price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400"> <div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{formatNumber(price)} £{formatNumber(price)}
{transactionDate !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
({formatTransactionDate(transactionDate)})
</span>
)}
{pricePerSqm !== undefined && ( {pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400"> <span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '} {' '}
(£{formatNumber(pricePerSqm)}/m²) £{formatNumber(pricePerSqm)}/m²
</span> </span>
)} )}
</div> </div>
@ -179,6 +187,9 @@ function PropertyCard({ property }: { property: Property }) {
<span className="font-semibold text-teal-700 dark:text-teal-400"> <span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(estimatedPrice)} £{formatNumber(estimatedPrice)}
</span> </span>
{estPricePerSqm !== undefined && (
<span> (£{formatNumber(estPricePerSqm)}/m²)</span>
)}
</div> </div>
)} )}

View file

@ -0,0 +1,172 @@
import { useState, useCallback } from 'react';
import { Slider } from '../ui/Slider';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import { IconButton } from '../ui/IconButton';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { formatFilterValue } from '../../lib/format';
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' },
];
interface TravelTimeCardProps {
destination: [number, number] | null;
destinationLabel: string;
mode: TransportMode;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
onSetDestination: (lat: number, lon: number, label: string) => void;
onModeChange: (mode: TransportMode) => void;
onTimeRangeChange: (range: [number, number]) => void;
onRemove: () => void;
}
export function TravelTimeCard({
destination,
destinationLabel,
mode,
timeRange,
dataRange,
onSetDestination,
onModeChange,
onTimeRangeChange,
onRemove,
}: TravelTimeCardProps) {
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSearch = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
setError(null);
setLoading(true);
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(trimmed)}`,
authHeaders()
);
if (!res.ok) {
setError('Postcode not found');
return;
}
const json: { postcode: string; latitude: number; longitude: number } =
await res.json();
onSetDestination(json.latitude, json.longitude, json.postcode);
setQuery('');
} catch {
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[query, onSetDestination]
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
const displayRange = timeRange ?? [sliderMin, sliderMax];
return (
<div className="space-y-2 px-2 py-2 rounded ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time
</span>
</div>
<IconButton onClick={() => onRemove()} title="Remove travel time">
<CloseIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
{/* Destination search */}
<div>
<form onSubmit={handleSearch} className="flex gap-1">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
placeholder={destination ? 'Change destination...' : 'Enter postcode...'}
className="flex-1 min-w-0 px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="px-2 py-1 text-xs rounded bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50"
>
{loading ? '...' : 'Go'}
</button>
</form>
{error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</p>
)}
{destination && destinationLabel && (
<div className="flex items-center gap-1 mt-1">
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
<span className="text-xs text-warm-600 dark:text-warm-300">
{destinationLabel}
</span>
</div>
)}
</div>
{/* Mode selector */}
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Mode
</span>
<PillGroup className="mt-0.5">
{MODES.map((m) => (
<PillToggle
key={m.value}
label={m.label}
active={mode === m.value}
onClick={() => onModeChange(m.value)}
size="xs"
/>
))}
</PillGroup>
</div>
{/* Time range slider — only show when we have data */}
{destination && dataRange && (
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time
</span>
<Slider
min={sliderMin}
max={sliderMax}
step={1}
value={[displayRange[0], displayRange[1]]}
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} min
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} min
</span>
</div>
</div>
)}
</div>
);
}

View file

@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { SavedSearch } from '../../hooks/useSavedSearches'; import type { SavedSearch } from '../../hooks/useSavedSearches';
import { shortenUrl } from '../../lib/api';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { TrashIcon } from '../ui/icons/TrashIcon'; import { TrashIcon } from '../ui/icons/TrashIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -20,6 +21,7 @@ export default function SavedSearchesPage({
}) { }) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const handleDeleteConfirm = useCallback(async () => { const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return; if (!deleteConfirmId) return;
@ -27,17 +29,16 @@ export default function SavedSearchesPage({
setDeleteConfirmId(null); setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]); }, [deleteConfirmId, onDelete]);
const handleShare = useCallback((params: string, id: string) => { const copyToClipboard = useCallback((text: string, id: string) => {
const url = `${window.location.origin}/?${params}`;
const onSuccess = () => { const onSuccess = () => {
setCopiedId(id); setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
}; };
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(onSuccess); navigator.clipboard.writeText(text).then(onSuccess);
} else { } else {
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = url; ta.value = text;
ta.style.position = 'fixed'; ta.style.position = 'fixed';
ta.style.opacity = '0'; ta.style.opacity = '0';
document.body.appendChild(ta); document.body.appendChild(ta);
@ -48,6 +49,18 @@ export default function SavedSearchesPage({
} }
}, []); }, []);
const handleShare = useCallback(async (params: string, id: string) => {
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
copyToClipboard(shortUrl, id);
} catch {
copyToClipboard(`${window.location.origin}/?${params}`, id);
} finally {
setSharingId(null);
}
}, [copyToClipboard]);
return ( return (
<div className="flex-1 overflow-auto bg-warm-50 dark:bg-warm-900"> <div className="flex-1 overflow-auto bg-warm-50 dark:bg-warm-900">
<div className="max-w-5xl mx-auto px-6 py-8"> <div className="max-w-5xl mx-auto px-6 py-8">
@ -106,9 +119,12 @@ export default function SavedSearchesPage({
</button> </button>
<button <button
onClick={() => handleShare(search.params, search.id)} onClick={() => handleShare(search.params, search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300" disabled={sharingId === search.id}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-50 disabled:cursor-wait"
> >
{copiedId === search.id ? 'Copied!' : 'Share'} {sharingId === search.id ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : copiedId === search.id ? 'Copied!' : 'Share'}
</button> </button>
<button <button
onClick={() => setDeleteConfirmId(search.id)} onClick={() => setDeleteConfirmId(search.id)}

View file

@ -0,0 +1,19 @@
interface IconProps {
className?: string;
}
export function RouteIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="6" cy="19" r="3" />
<circle cx="18" cy="5" r="3" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19h3a4 4 0 004-4V9" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function SearchIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
}

View file

@ -6,3 +6,4 @@ export { ChevronIcon } from './ChevronIcon';
export { FilterIcon } from './FilterIcon'; export { FilterIcon } from './FilterIcon';
export { LightbulbIcon } from './LightbulbIcon'; export { LightbulbIcon } from './LightbulbIcon';
export { MenuIcon } from './MenuIcon'; export { MenuIcon } from './MenuIcon';
export { RouteIcon } from './RouteIcon';

View file

@ -1,6 +1,6 @@
import { useCallback, useRef, useState, useMemo } from 'react'; import { useCallback, useRef, useState, useMemo } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers'; import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core'; import type { PickingInfo } from '@deck.gl/core';
import type { import type {
HexagonData, HexagonData,
@ -44,6 +44,10 @@ interface UseDeckLayersProps {
theme: 'light' | 'dark'; theme: 'light' | 'dark';
searchedPostcode?: SearchedPostcode | null; searchedPostcode?: SearchedPostcode | null;
bounds?: Bounds | null; bounds?: Bounds | null;
travelTimeEnabled?: boolean;
travelTimeDestination?: [number, number] | null;
travelTimeColorRange?: [number, number] | null;
travelTimeRange?: [number, number] | null;
} }
export interface PopupInfo { export interface PopupInfo {
@ -70,6 +74,10 @@ export function useDeckLayers({
theme, theme,
searchedPostcode, searchedPostcode,
bounds: viewportBounds, bounds: viewportBounds,
travelTimeEnabled = false,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
}: UseDeckLayersProps) { }: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null); const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -99,6 +107,15 @@ export function useDeckLayers({
const hoveredPostcodeRef = useRef(hoveredPostcode); const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode; hoveredPostcodeRef.current = hoveredPostcode;
const travelTimeEnabledRef = useRef(travelTimeEnabled);
travelTimeEnabledRef.current = travelTimeEnabled;
const travelTimeDestinationRef = useRef(travelTimeDestination);
travelTimeDestinationRef.current = travelTimeDestination;
const travelTimeColorRangeRef = useRef(travelTimeColorRange);
travelTimeColorRangeRef.current = travelTimeColorRange;
const travelTimeRangeRef = useRef(travelTimeRange);
travelTimeRangeRef.current = travelTimeRange;
const colorFeatureMeta = useMemo( const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features] [viewFeature, features]
@ -225,8 +242,9 @@ export function useDeckLayers({
}, []); }, []);
// --- Color triggers --- // --- Color triggers ---
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`; const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`; 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}`;
// --- Layers --- // --- Layers ---
const hexLayer = useMemo( const hexLayer = useMemo(
@ -236,11 +254,36 @@ export function useDeckLayers({
data, data,
getHexagon: (d) => d.h3, getHexagon: (d) => d.h3,
getFillColor: (d) => { getFillColor: (d) => {
const dark = isDarkRef.current;
// Travel time coloring takes priority
if (travelTimeEnabledRef.current && travelTimeDestinationRef.current) {
const ttVal = d.travel_time;
const ttClr = travelTimeColorRangeRef.current;
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
const ttFr = travelTimeRangeRef.current;
if (ttFr && ((ttVal as number) < ttFr[0] || (ttVal as number) > ttFr[1])) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
}
if (ttClr) {
return getFeatureFillColor(
ttVal as number,
ttVal as number,
ttVal as number,
ttClr,
null,
0,
densityGradientRef.current,
dark,
255
);
}
}
const vf = viewFeatureRef.current; const vf = viewFeatureRef.current;
const clr = colorRangeRef.current; const clr = colorRangeRef.current;
const fr = filterRangeRef.current; const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current; const cfm = colorFeatureMetaRef.current;
const dark = isDarkRef.current;
if (vf && clr && cfm) { if (vf && clr && cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`]; const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
const minVal = d[`min_${vf}`] as number | undefined; const minVal = d[`min_${vf}`] as number | undefined;
@ -457,13 +500,30 @@ export function useDeckLayers({
}); });
}, [searchedPostcode, searchedPostcodeHasData]); }, [searchedPostcode, searchedPostcodeHasData]);
const destinationMarkerLayer = useMemo(() => {
if (!travelTimeEnabled || !travelTimeDestination) return null;
return new ScatterplotLayer({
id: 'travel-time-destination',
data: [{ position: [travelTimeDestination[1], travelTimeDestination[0]] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 8,
getFillColor: [239, 68, 68, 220],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
lineWidthUnits: 'pixels' as const,
radiusUnits: 'pixels' as const,
stroked: true,
pickable: false,
});
}, [travelTimeEnabled, travelTimeDestination]);
const layers = useMemo(() => { const layers = useMemo(() => {
const baseLayers = usePostcodeView // eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer] ? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer]; : [hexLayer, poiLayer];
if (searchedPostcodeHighlightLayer) { if (searchedPostcodeHighlightLayer) baseLayers.push(searchedPostcodeHighlightLayer);
return [...baseLayers, searchedPostcodeHighlightLayer]; if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
}
return baseLayers; return baseLayers;
}, [ }, [
usePostcodeView, usePostcodeView,
@ -472,6 +532,7 @@ export function useDeckLayers({
postcodeLabelsLayer, postcodeLabelsLayer,
poiLayer, poiLayer,
searchedPostcodeHighlightLayer, searchedPostcodeHighlightLayer,
destinationMarkerLayer,
]); ]);
const handleMouseLeave = useCallback(() => { const handleMouseLeave = useCallback(() => {

View file

@ -32,6 +32,9 @@ interface UseMapDataOptions {
activeFeature: string | null; activeFeature: string | null;
dragValue: [number, number] | null; dragValue: [number, number] | null;
dragData: HexagonData[] | null; dragData: HexagonData[] | null;
travelTimeEnabled: boolean;
travelTimeDestination: [number, number] | null;
travelTimeMode: string;
} }
export function useMapData({ export function useMapData({
@ -41,6 +44,9 @@ export function useMapData({
activeFeature, activeFeature,
dragValue, dragValue,
dragData, dragData,
travelTimeEnabled,
travelTimeDestination,
travelTimeMode,
}: UseMapDataOptions) { }: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]); const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]); const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
@ -104,6 +110,10 @@ export function useMapData({
}); });
if (filtersStr) params.set('filters', filtersStr); if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || ''); params.set('fields', viewFeature || '');
if (travelTimeEnabled && travelTimeDestination) {
params.set('destination', `${travelTimeDestination[0]},${travelTimeDestination[1]}`);
params.set('mode', travelTimeMode);
}
const res = await fetch( const res = await fetch(
apiUrl('hexagons', params), apiUrl('hexagons', params),
authHeaders({ authHeaders({
@ -126,7 +136,7 @@ export function useMapData({
clearTimeout(debounceRef.current); clearTimeout(debounceRef.current);
} }
}; };
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]); }, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelTimeEnabled, travelTimeDestination, travelTimeMode]);
const data = dragData ?? rawData; const data = dragData ?? rawData;
@ -187,6 +197,27 @@ export function useMapData({
return null; return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]); }, [viewFeature, features, dataRange, activeFeature, dragValue]);
// Color range for travel time (computed from response data)
const travelTimeColorRange = useMemo((): [number, number] | null => {
if (!travelTimeEnabled || !travelTimeDestination) return null;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item.travel_time;
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [travelTimeEnabled, travelTimeDestination, data, bounds]);
const handleViewChange = useCallback( const handleViewChange = useCallback(
({ ({
resolution: newRes, resolution: newRes,
@ -226,6 +257,7 @@ export function useMapData({
currentView, currentView,
usePostcodeView, usePostcodeView,
colorRange, colorRange,
travelTimeColorRange,
handleViewChange, handleViewChange,
setInitialView, setInitialView,
}; };

View file

@ -0,0 +1,67 @@
import { useState, useCallback } from 'react';
export type TransportMode = 'transit' | 'car' | 'bicycle';
export interface TravelTimeState {
enabled: boolean;
destination: [number, number] | null; // [lat, lon]
destinationLabel: string;
mode: TransportMode;
timeRange: [number, number] | null;
}
export interface TravelTimeInitial {
destination?: [number, number];
destinationLabel?: string;
mode?: TransportMode;
timeRange?: [number, number];
}
export function useTravelTime(initial?: TravelTimeInitial) {
const [enabled, setEnabled] = useState(!!initial?.destination);
const [destination, setDestination] = useState<[number, number] | null>(
initial?.destination ?? null
);
const [destinationLabel, setDestinationLabel] = useState(initial?.destinationLabel ?? '');
const [mode, setMode] = useState<TransportMode>(initial?.mode ?? 'transit');
const [timeRange, setTimeRange] = useState<[number, number] | null>(
initial?.timeRange ?? null
);
const handleEnable = useCallback(() => {
setEnabled(true);
}, []);
const handleDisable = useCallback(() => {
setEnabled(false);
setDestination(null);
setDestinationLabel('');
setTimeRange(null);
}, []);
const handleSetDestination = useCallback((lat: number, lon: number, label: string) => {
setDestination([lat, lon]);
setDestinationLabel(label);
}, []);
const handleModeChange = useCallback((newMode: TransportMode) => {
setMode(newMode);
}, []);
const handleTimeRangeChange = useCallback((range: [number, number]) => {
setTimeRange(range);
}, []);
return {
enabled,
destination,
destinationLabel,
mode,
timeRange,
handleEnable,
handleDisable,
handleSetDestination,
handleModeChange,
handleTimeRangeChange,
};
}

View file

@ -1,6 +1,15 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types'; import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state'; import { stateToParams } from '../lib/url-state';
import type { TransportMode } from './useTravelTime';
export interface TravelTimeUrlState {
enabled: boolean;
destination: [number, number] | null;
destinationLabel: string;
mode: TransportMode;
timeRange: [number, number] | null;
}
const URL_DEBOUNCE_MS = 300; const URL_DEBOUNCE_MS = 300;
@ -9,7 +18,8 @@ export function useUrlSync(
filters: FeatureFilters, filters: FeatureFilters,
features: FeatureMeta[], features: FeatureMeta[],
selectedPOICategories: Set<string>, selectedPOICategories: Set<string>,
rightPaneTab: 'pois' | 'properties' | 'area' rightPaneTab: 'pois' | 'properties' | 'area',
travelTime?: TravelTimeUrlState
) { ) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -23,7 +33,8 @@ export function useUrlSync(
filters, filters,
features, features,
selectedPOICategories, selectedPOICategories,
rightPaneTab rightPaneTab,
travelTime
); );
const search = params.toString(); const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
@ -33,5 +44,5 @@ export function useUrlSync(
return () => { return () => {
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
}; };
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]); }, [currentView, filters, features, selectedPOICategories, rightPaneTab, travelTime]);
} }

View file

@ -113,3 +113,13 @@ h3 {
transition-delay: 0.2s, 0s; transition-delay: 0.2s, 0s;
} }
/* Hide scrollbar for pill groups on mobile */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

View file

@ -48,6 +48,17 @@ export async function fetchWithRetry<T>(
} }
} }
export async function shortenUrl(params: string): Promise<string> {
const res = await fetch(apiUrl('shorten'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return `${window.location.origin}${data.url}`;
}
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string { export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string {
const entries = Object.entries(filters); const entries = Object.entries(filters);
if (entries.length === 0) return ''; if (entries.length === 0) return '';

View file

@ -28,6 +28,17 @@ export function formatDuration(d: string): string {
return d; return d;
} }
const MONTH_NAMES = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
export function formatTransactionDate(fractionalYear: number): string {
const year = Math.floor(fractionalYear);
const monthIndex = Math.min(Math.round((fractionalYear - year) * 12), 11);
return `${MONTH_NAMES[monthIndex]} ${year}`;
}
export function formatAge(value: number, approximate = true): string { export function formatAge(value: number, approximate = true): string {
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`; if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
return Math.round(value).toString(); return Math.round(value).toString();

View file

@ -1,62 +1,138 @@
import type { FeatureMeta, FeatureFilters, ViewState } from '../types'; import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
import type { TransportMode, TravelTimeInitial } from '../hooks/useTravelTime';
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
const filterParams = params.getAll('filter');
if (filterParams.length === 0) return undefined;
const filters: FeatureFilters = {};
for (const entry of filterParams) {
const colonIdx = entry.indexOf(':');
if (colonIdx === -1) continue;
const name = entry.substring(0, colonIdx);
const rest = entry.substring(colonIdx + 1);
if (rest.includes(':')) {
const [minStr, maxStr] = rest.split(':');
const min = Number(minStr);
const max = Number(maxStr);
if (!isNaN(min) && !isNaN(max)) {
filters[name] = [min, max];
}
} else if (rest.includes('|')) {
filters[name] = rest.split('|');
} else {
filters[name] = [rest];
}
}
return Object.keys(filters).length > 0 ? filters : undefined;
}
/** Backward compat: parse old comma-packed `f` param */
function parseLegacyFilters(f: string): FeatureFilters | undefined {
const filters: FeatureFilters = {};
for (const segment of f.split(',')) {
const colonIdx = segment.indexOf(':');
if (colonIdx === -1) continue;
const name = segment.substring(0, colonIdx);
const rest = segment.substring(colonIdx + 1);
if (rest.includes(':')) {
const [minStr, maxStr] = rest.split(':');
const min = Number(minStr);
const max = Number(maxStr);
if (!isNaN(min) && !isNaN(max)) {
filters[name] = [min, max];
}
} else if (rest.includes('|')) {
filters[name] = rest.split('|');
} else {
filters[name] = [rest];
}
}
return Object.keys(filters).length > 0 ? filters : undefined;
}
export function parseUrlState(): { export function parseUrlState(): {
viewState?: ViewState; viewState?: ViewState;
filters?: FeatureFilters; filters?: FeatureFilters;
poiCategories?: Set<string>; poiCategories?: Set<string>;
tab?: 'pois' | 'properties' | 'area'; tab?: 'pois' | 'properties' | 'area';
travelTime?: TravelTimeInitial;
} { } {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const result: ReturnType<typeof parseUrlState> = {}; const result: ReturnType<typeof parseUrlState> = {};
const v = params.get('v'); // View state: separate lat/lon/zoom params
if (v) { const lat = params.get('lat');
const parts = v.split(',').map(Number); const lon = params.get('lon');
if (parts.length === 3 && parts.every((n) => !isNaN(n))) { const zoom = params.get('zoom');
result.viewState = { if (lat && lon && zoom) {
latitude: parts[0], const latN = Number(lat);
longitude: parts[1], const lonN = Number(lon);
zoom: parts[2], const zoomN = Number(zoom);
pitch: 0, if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
}; result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
} }
} } else {
// Backward compat: old packed `v=lat,lon,zoom`
const f = params.get('f'); const v = params.get('v');
if (f) { if (v) {
const filters: FeatureFilters = {}; const parts = v.split(',').map(Number);
for (const segment of f.split(',')) { if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
const colonIdx = segment.indexOf(':'); result.viewState = { latitude: parts[0], longitude: parts[1], zoom: parts[2], pitch: 0 };
if (colonIdx === -1) continue;
const name = segment.substring(0, colonIdx);
const rest = segment.substring(colonIdx + 1);
if (rest.includes(':')) {
const [minStr, maxStr] = rest.split(':');
const min = Number(minStr);
const max = Number(maxStr);
if (!isNaN(min) && !isNaN(max)) {
filters[name] = [min, max];
}
} else if (rest.includes('|')) {
filters[name] = rest.split('|');
} else {
filters[name] = [rest];
} }
} }
if (Object.keys(filters).length > 0) { }
result.filters = filters;
// Filters: repeated `filter` params
result.filters = parseFilters(params);
if (!result.filters) {
// Backward compat: old packed `f` param
const f = params.get('f');
if (f) result.filters = parseLegacyFilters(f);
}
// POI categories: repeated `poi` params
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
// Handle both new (repeated params) and old (comma-separated) formats
const categories = poiParams.flatMap((p) => p.split(',')).filter(Boolean);
if (categories.length > 0) {
result.poiCategories = new Set(categories);
} }
} }
const poi = params.get('poi'); // Tab: full name
if (poi) { const tab = params.get('tab');
result.poiCategories = new Set(poi.split(',').filter(Boolean)); if (tab === 'properties' || tab === 'pois' || tab === 'area') {
result.tab = tab;
} else if (tab === 'p') {
result.tab = 'properties'; // backward compat
} else if (tab === 'o') {
result.tab = 'pois';
} else if (tab === 'a') {
result.tab = 'area';
} }
const tab = params.get('tab'); // Travel time
if (tab === 'p') result.tab = 'properties'; const dest = params.get('dest');
else if (tab === 'o') result.tab = 'pois'; if (dest) {
else if (tab === 'a') result.tab = 'area'; const parts = dest.split(',').map(Number);
if (parts.length === 2 && parts.every((n) => !isNaN(n))) {
const tt: TravelTimeInitial = {
destination: [parts[0], parts[1]],
destinationLabel: params.get('destLabel') || '',
mode: (params.get('tmode') as TransportMode) || 'transit',
};
const ttRange = params.get('tt');
if (ttRange) {
const [min, max] = ttRange.split(':').map(Number);
if (!isNaN(min) && !isNaN(max)) {
tt.timeRange = [min, max];
}
}
result.travelTime = tt;
}
}
return result; return result;
} }
@ -66,40 +142,48 @@ export function stateToParams(
filters: FeatureFilters, filters: FeatureFilters,
features: FeatureMeta[], features: FeatureMeta[],
selectedPOICategories: Set<string>, selectedPOICategories: Set<string>,
rightPaneTab: 'pois' | 'properties' | 'area' rightPaneTab: 'pois' | 'properties' | 'area',
travelTime?: { enabled: boolean; destination: [number, number] | null; destinationLabel: string; mode: string; timeRange: [number, number] | null }
): URLSearchParams { ): URLSearchParams {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (viewState) { if (viewState) {
params.set( params.set('lat', viewState.latitude.toFixed(4));
'v', params.set('lon', viewState.longitude.toFixed(4));
`${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}` params.set('zoom', viewState.zoom.toFixed(1));
);
} }
const filterEntries = Object.entries(filters); for (const [name, value] of Object.entries(filters)) {
if (filterEntries.length > 0) { const meta = features.find((f) => f.name === name);
const filtersStr = filterEntries if (meta?.type === 'enum') {
.map(([name, value]) => { params.append('filter', `${name}:${(value as string[]).join('|')}`);
const meta = features.find((f) => f.name === name); } else {
if (meta?.type === 'enum') { const [min, max] = value as [number, number];
return `${name}:${(value as string[]).join('|')}`; params.append('filter', `${name}:${min}:${max}`);
} }
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
params.set('f', filtersStr);
} }
if (selectedPOICategories.size > 0) { for (const category of selectedPOICategories) {
params.set('poi', Array.from(selectedPOICategories).join(',')); params.append('poi', category);
} }
if (rightPaneTab === 'properties') { if (rightPaneTab === 'properties') {
params.set('tab', 'p'); params.set('tab', 'properties');
} else if (rightPaneTab === 'area') { } else if (rightPaneTab === 'area') {
params.set('tab', 'a'); params.set('tab', 'area');
}
if (travelTime?.enabled && travelTime.destination) {
params.set('dest', `${travelTime.destination[0].toFixed(5)},${travelTime.destination[1].toFixed(5)}`);
if (travelTime.destinationLabel) {
params.set('destLabel', travelTime.destinationLabel);
}
if (travelTime.mode !== 'transit') {
params.set('tmode', travelTime.mode);
}
if (travelTime.timeRange) {
params.set('tt', `${travelTime.timeRange[0]}:${travelTime.timeRange[1]}`);
}
} }
return params; return params;
@ -109,13 +193,13 @@ export function summarizeParams(queryString: string): string {
const params = new URLSearchParams(queryString); const params = new URLSearchParams(queryString);
const parts: string[] = []; const parts: string[] = [];
const f = params.get('f'); // New format: repeated `filter` params
if (f) { const filterParams = params.getAll('filter');
const filterNames = f if (filterParams.length > 0) {
.split(',') const filterNames = filterParams
.map((seg) => { .map((entry) => {
const colonIdx = seg.indexOf(':'); const colonIdx = entry.indexOf(':');
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg; return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
}) })
.filter(Boolean); .filter(Boolean);
if (filterNames.length > 0) { if (filterNames.length > 0) {
@ -123,11 +207,28 @@ export function summarizeParams(queryString: string): string {
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters` filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
); );
} }
} else {
// Backward compat: old packed `f` param
const f = params.get('f');
if (f) {
const filterNames = f
.split(',')
.map((seg) => {
const colonIdx = seg.indexOf(':');
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
})
.filter(Boolean);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
);
}
}
} }
const poi = params.get('poi'); const poiParams = params.getAll('poi');
if (poi) { if (poiParams.length > 0) {
const count = poi.split(',').filter(Boolean).length; const count = poiParams.flatMap((p) => p.split(',')).filter(Boolean).length;
if (count > 0) { if (count > 0) {
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`); parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
} }

View file

@ -48,6 +48,15 @@ module.exports = {
900: '#1c1917', 900: '#1c1917',
}, },
}, },
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
},
animation: {
'fade-in': 'fade-in 0.2s ease-out forwards',
},
}, },
}, },
plugins: [], plugins: [],

File diff suppressed because one or more lines are too long

View file

View file

@ -129,15 +129,21 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path" "--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() args = parser.parse_args()
with tempfile.TemporaryDirectory() as cache_dir: with tempfile.TemporaryDirectory() as cache_dir:
pbf_file = Path(cache_dir) / "great-britain-latest.osm.pbf" if args.pbf and args.pbf.exists():
pbf_file = args.pbf
if not pbf_file.exists(): print(f"Using provided PBF file at {pbf_file}")
download_pbf(pbf_file)
else: else:
print(f"Using cached PBF file at {pbf_file}") pbf_file = Path(cache_dir) / "great-britain-latest.osm.pbf"
if not pbf_file.exists():
download_pbf(pbf_file)
else:
print(f"Using cached PBF file at {pbf_file}")
print(f"Tag keys: {POI_TAG_KEYS}") print(f"Tag keys: {POI_TAG_KEYS}")

View file

@ -0,0 +1,354 @@
"""Download and prepare transit network data for R5 routing.
Downloads:
- England OSM PBF from Geofabrik (~1.5GB)
- BODS GTFS from Bus Open Data Service (~1.5GB, all England bus/tram/ferry)
Then processes for R5 compatibility:
- Cleans GTFS (fixes stop_times >72h, feed_info year >2100)
- Crops OSM PBF to London bounding box via osmium
- Crops GTFS to London bounding box (keeps only London-touching trips)
Requires: osmium-tool (apt install osmium-tool)
Output directory: property-data/transit/
Final files: london.osm.pbf + bods_gtfs.zip (London-only, R5-ready)
"""
import argparse
import csv
import io
import os
import subprocess
import urllib.request
import zipfile
from pathlib import Path
from tqdm import tqdm
ENGLAND_PBF_URL = (
"https://download.geofabrik.de/europe/united-kingdom/england-latest.osm.pbf"
)
# Bus Open Data Service — pre-converted GTFS covering all England bus/tram/ferry
BODS_GTFS_URL = "https://data.bus-data.dft.gov.uk/timetable/download/gtfs-file/all/"
USER_AGENT = "property-map-pipeline/1.0 (https://github.com)"
# London + Home Counties bounding box (~50km buffer around Greater London)
LONDON_BBOX = {"min_lat": 51.2, "max_lat": 51.85, "min_lon": -0.65, "max_lon": 0.35}
def _download_http(url: str, dest: Path, *, desc: str) -> None:
"""Stream-download a URL to a file with progress bar."""
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".tmp")
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with (
tqdm(unit="B", unit_scale=True, desc=desc) as bar,
urllib.request.urlopen(req) as resp,
open(tmp, "wb") as f,
):
length = resp.headers.get("Content-Length")
if length:
bar.total = int(length)
while chunk := resp.read(1 << 20):
f.write(chunk)
bar.update(len(chunk))
tmp.rename(dest)
print(f" Saved to {dest}")
def download_osm_pbf(output_dir: Path) -> Path:
"""Download England OSM PBF extract from Geofabrik."""
dest = output_dir / "england.osm.pbf"
if dest.exists():
print(f"OSM PBF already exists: {dest}")
return dest
print("Downloading England OSM PBF (~1.5 GB)...")
_download_http(ENGLAND_PBF_URL, dest, desc="england.osm.pbf")
return dest
def download_bods_gtfs(output_dir: Path) -> Path:
"""Download BODS GTFS (all England bus/tram/ferry timetables)."""
dest = output_dir / "bods_gtfs_raw.zip"
if dest.exists():
print(f"BODS GTFS already exists: {dest}")
return dest
print("Downloading BODS GTFS (~1.5 GB)...")
_download_http(BODS_GTFS_URL, dest, desc="bods_gtfs_raw.zip")
return dest
def clean_gtfs(src: Path, dst: Path) -> None:
"""Fix R5-incompatible entries in GTFS.
- Removes stop_times with arrival/departure hour > 72
- Caps feed_info end_date year to 2099
"""
if dst.exists():
print(f"Cleaned GTFS already exists: {dst}")
return
print("Cleaning GTFS for R5 compatibility...")
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
dst, "w", zipfile.ZIP_DEFLATED
) as zout:
for info in zin.infolist():
if info.filename == "stop_times.txt":
dropped = 0
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
arr_idx = cols.index("arrival_time") if "arrival_time" in cols else -1
dep_idx = (
cols.index("departure_time") if "departure_time" in cols else -1
)
import tempfile
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
skip = False
for idx in [arr_idx, dep_idx]:
if 0 <= idx < len(parts):
time_val = parts[idx].strip('"')
if ":" in time_val:
try:
hour = int(time_val.split(":")[0])
if hour > 72:
skip = True
break
except ValueError:
pass
if skip:
dropped += 1
else:
tmp.write(line)
tmp.close()
print(f" stop_times: dropped {dropped} rows with hours > 72")
zout.write(tmp.name, "stop_times.txt")
os.unlink(tmp.name)
elif info.filename == "feed_info.txt":
data = zin.read(info).decode("utf-8")
lines = data.strip().split("\n")
header_line = lines[0]
feed_cols = header_line.split(",")
fixed_lines = [header_line]
for line in lines[1:]:
parts = line.split(",")
for i, col_name in enumerate(feed_cols):
if "end_date" in col_name.lower() and i < len(parts):
date_val = parts[i].strip('"')
if len(date_val) == 8:
year = int(date_val[:4])
if year > 2100:
parts[i] = "20991231"
print(f" feed_info: capped end_date {date_val} → 20991231")
fixed_lines.append(",".join(parts))
zout.writestr("feed_info.txt", "\n".join(fixed_lines) + "\n")
else:
zout.writestr(info, zin.read(info))
print(f" Saved to {dst}")
def crop_osm_to_london(src: Path, dst: Path) -> None:
"""Extract London bounding box from England OSM PBF using osmium."""
if dst.exists():
print(f"London OSM PBF already exists: {dst}")
return
bbox = LONDON_BBOX
bbox_str = f"{bbox['min_lon']},{bbox['min_lat']},{bbox['max_lon']},{bbox['max_lat']}"
print(f"Cropping OSM PBF to London bbox ({bbox_str})...")
subprocess.run(
["osmium", "extract", f"--bbox={bbox_str}", str(src), "-o", str(dst), "--overwrite"],
check=True,
)
size_mb = dst.stat().st_size / (1024 * 1024)
print(f" Saved to {dst} ({size_mb:.0f} MB)")
def crop_gtfs_to_london(src: Path, dst: Path) -> None:
"""Crop GTFS to trips touching the London bounding box."""
if dst.exists():
print(f"London GTFS already exists: {dst}")
return
bbox = LONDON_BBOX
print("Cropping GTFS to London area...")
with zipfile.ZipFile(src, "r") as zin:
# Step 1: Find stops in bbox
print(" Finding stops in bbox...")
with zin.open("stops.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
stops_in_bbox = set()
all_stops = list(reader)
for row in all_stops:
lat = float(row["stop_lat"])
lon = float(row["stop_lon"])
if bbox["min_lat"] <= lat <= bbox["max_lat"] and bbox["min_lon"] <= lon <= bbox["max_lon"]:
stops_in_bbox.add(row["stop_id"])
print(f" {len(stops_in_bbox):,} / {len(all_stops):,} stops in bbox")
# Step 2: Find trips touching these stops
print(" Finding trips touching London stops...")
with zin.open("stop_times.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
st_fieldnames = reader.fieldnames
trips_in_bbox = set()
for row in reader:
if row["stop_id"] in stops_in_bbox:
trips_in_bbox.add(row["trip_id"])
print(f" {len(trips_in_bbox):,} trips touch London")
# Step 3: Collect all stop_times for those trips
print(" Collecting stop_times for London trips...")
stop_times_kept = []
with zin.open("stop_times.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
for row in reader:
if row["trip_id"] in trips_in_bbox:
stop_times_kept.append(row)
stops_needed = {row["stop_id"] for row in stop_times_kept}
print(f" {len(stop_times_kept):,} stop_times kept")
# Step 4: Read trips and find needed routes/services/shapes
print(" Reading trips...")
with zin.open("trips.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
trips_fieldnames = reader.fieldnames
all_trips = list(reader)
trips_kept = [t for t in all_trips if t["trip_id"] in trips_in_bbox]
routes_needed = {t["route_id"] for t in trips_kept}
services_needed = {t["service_id"] for t in trips_kept}
shapes_needed = {t.get("shape_id", "") for t in trips_kept} - {""}
# Step 5: Write cropped GTFS
print(" Writing cropped GTFS...")
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
# stops
stops_kept = [s for s in all_stops if s["stop_id"] in stops_needed]
_write_csv(zout, "stops.txt", list(all_stops[0].keys()), stops_kept)
# stop_times
_write_csv(zout, "stop_times.txt", st_fieldnames, stop_times_kept)
# trips
_write_csv(zout, "trips.txt", trips_fieldnames, trips_kept)
# routes
with zin.open("routes.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
routes_fn = reader.fieldnames
routes_kept = [r for r in reader if r["route_id"] in routes_needed]
_write_csv(zout, "routes.txt", routes_fn, routes_kept)
# agency (copy all)
zout.writestr("agency.txt", zin.read("agency.txt"))
# calendar
with zin.open("calendar.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
cal_fn = reader.fieldnames
cal_kept = [r for r in reader if r["service_id"] in services_needed]
_write_csv(zout, "calendar.txt", cal_fn, cal_kept)
# calendar_dates
with zin.open("calendar_dates.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
cd_fn = reader.fieldnames
cd_kept = [r for r in reader if r["service_id"] in services_needed]
_write_csv(zout, "calendar_dates.txt", cd_fn, cd_kept)
# shapes (stream — can be very large)
print(" Streaming shapes.txt...")
with zin.open("shapes.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f))
shapes_fn = reader.fieldnames
shapes_rows = [r for r in reader if r["shape_id"] in shapes_needed]
_write_csv(zout, "shapes.txt", shapes_fn, shapes_rows)
# feed_info + frequencies (copy)
zout.writestr("feed_info.txt", zin.read("feed_info.txt"))
zout.writestr("frequencies.txt", zin.read("frequencies.txt"))
size_mb = dst.stat().st_size / (1024 * 1024)
print(f" Saved to {dst} ({size_mb:.0f} MB)")
def _write_csv(
zout: zipfile.ZipFile, name: str, fieldnames: list[str], rows: list[dict]
) -> None:
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=fieldnames)
w.writeheader()
w.writerows(rows)
zout.writestr(name, buf.getvalue())
print(f" {name}: {len(rows):,} rows")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download and prepare transit network data for R5 routing engine"
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Output directory for transit data",
)
args = parser.parse_args()
output_dir: Path = args.output
raw_dir = output_dir / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
# Download raw data
england_pbf = download_osm_pbf(raw_dir)
bods_raw = download_bods_gtfs(raw_dir)
# Clean GTFS (fix R5 incompatibilities)
bods_clean = raw_dir / "bods_gtfs_clean.zip"
clean_gtfs(bods_raw, bods_clean)
# Crop to London area for R5 (full England requires >30GB RAM)
london_pbf = output_dir / "london.osm.pbf"
crop_osm_to_london(england_pbf, london_pbf)
london_gtfs = output_dir / "bods_gtfs.zip"
crop_gtfs_to_london(bods_clean, london_gtfs)
# Summary
print()
print("Transit data ready for R5:")
for f in sorted(output_dir.iterdir()):
if f.is_dir() or f.name.startswith("."):
continue
size_mb = f.stat().st_size / (1024 * 1024)
print(f" {f.name}: {size_mb:.1f} MB")
if __name__ == "__main__":
main()

View file

@ -32,6 +32,12 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--limit", type=int, default=0, help="Process only first N OAs (0=all)" "--limit", type=int, default=0, help="Process only first N OAs (0=all)"
) )
parser.add_argument(
"--greenspace",
type=Path,
default=None,
help="Greenspace/water parquet for boundary trimming (optional)",
)
args = parser.parse_args() args = parser.parse_args()
# Phase 1: Load all data # Phase 1: Load all data
@ -115,7 +121,20 @@ def main() -> None:
print("Phase 4: Merging fragments and writing GeoJSON") print("Phase 4: Merging fragments and writing GeoJSON")
print("=" * 60) print("=" * 60)
merged = merge_fragments(all_fragments) greenspace_tree = None
greenspace_geoms = None
if args.greenspace and args.greenspace.exists():
from .greenspace import load_greenspace
print(f" Loading greenspace/water from {args.greenspace}...")
greenspace_tree, greenspace_geoms = load_greenspace(args.greenspace)
print(f" Loaded {len(greenspace_geoms)} greenspace/water polygons")
merged = merge_fragments(
all_fragments,
greenspace_tree=greenspace_tree,
greenspace_geoms=greenspace_geoms,
)
print(f" Merged into {len(merged)} unique postcodes") print(f" Merged into {len(merged)} unique postcodes")
file_count = write_district_geojson(merged, args.output) file_count = write_district_geojson(merged, args.output)

View file

@ -0,0 +1,65 @@
"""Load greenspace/water polygons and subtract them from postcode boundaries."""
from pathlib import Path
import polars as pl
from shapely import wkb
from shapely.geometry import MultiPolygon, Polygon
from shapely.ops import unary_union
from shapely.strtree import STRtree
def load_greenspace(path: Path) -> tuple[STRtree, list]:
"""Load greenspace parquet and build an STRtree spatial index.
Returns:
(tree, geoms) where tree is a Shapely STRtree and geoms is
the list of geometries indexed by the tree.
"""
df = pl.read_parquet(path)
geoms = [wkb.loads(g) for g in df["geometry"].to_list()]
tree = STRtree(geoms)
return tree, geoms
MAX_REMOVAL_FRACTION = 0.9 # Keep original if >90% would be removed
def subtract_greenspace(
postcode_geom: Polygon | MultiPolygon,
tree: STRtree,
geoms: list,
) -> Polygon | MultiPolygon:
"""Subtract park/water polygons that overlap the postcode geometry.
Uses the STRtree for fast candidate lookup, then subtracts the union
of intersecting greenspace from the postcode polygon. If subtraction
would remove >90% of the area, keeps the original (the postcode
genuinely covers that land, e.g. churchyards, riverside addresses).
"""
candidate_idxs = tree.query(postcode_geom)
if len(candidate_idxs) == 0:
return postcode_geom
# Collect geometries that actually intersect (not just bbox overlap)
intersecting = []
for idx in candidate_idxs:
g = geoms[idx]
if g.intersects(postcode_geom):
intersecting.append(g)
if not intersecting:
return postcode_geom
green_union = unary_union(intersecting)
result = postcode_geom.difference(green_union)
if result.is_empty:
return postcode_geom
# Don't over-trim postcodes that genuinely cover green/water areas
original_area = postcode_geom.area
if original_area > 0 and result.area / original_area < (1 - MAX_REMOVAL_FRACTION):
return postcode_geom
return result

View file

@ -63,10 +63,34 @@ def to_wgs84_geojson(
} }
def _fill_holes(geom):
"""Remove all interior rings (holes) from a polygon or multipolygon."""
if geom.geom_type == "Polygon":
return Polygon(geom.exterior)
elif geom.geom_type == "MultiPolygon":
return MultiPolygon([Polygon(p.exterior) for p in geom.geoms])
return geom
def _largest_polygon(geom):
"""Extract the largest polygon from a MultiPolygon."""
if geom.geom_type == "MultiPolygon":
return max(geom.geoms, key=lambda g: g.area)
return geom
def merge_fragments( def merge_fragments(
all_fragments: list[tuple[str, Polygon | MultiPolygon]], all_fragments: list[tuple[str, Polygon | MultiPolygon]],
greenspace_tree=None,
greenspace_geoms=None,
) -> dict[str, Polygon | MultiPolygon]: ) -> dict[str, Polygon | MultiPolygon]:
"""Merge cross-OA fragments for postcodes spanning multiple OAs.""" """Merge cross-OA fragments for postcodes spanning multiple OAs.
Args:
all_fragments: List of (postcode, geometry) pairs.
greenspace_tree: Optional STRtree of park/water polygons.
greenspace_geoms: Optional list of park/water geometries (indexed by tree).
"""
by_postcode: dict[str, list] = defaultdict(list) by_postcode: dict[str, list] = defaultdict(list)
for pc, geom in all_fragments: for pc, geom in all_fragments:
by_postcode[pc].append(geom) by_postcode[pc].append(geom)
@ -80,13 +104,25 @@ def merge_fragments(
combined = make_valid(combined) combined = make_valid(combined)
# Close tiny gaps between adjacent OA boundary edges (float mismatches) # Close tiny gaps between adjacent OA boundary edges (float mismatches)
if combined.geom_type == "MultiPolygon": if combined.geom_type == "MultiPolygon":
combined = combined.buffer(1.0).buffer(-1.0) combined = combined.buffer(5.0).buffer(-5.0)
if not combined.is_valid: if not combined.is_valid:
combined = make_valid(combined) combined = make_valid(combined)
# Postcodes are contiguous delivery routes — keep only the largest # Postcodes are contiguous delivery routes — keep only the largest
# polygon; small detached fragments are algorithm artifacts # polygon; small detached fragments are algorithm artifacts
if combined.geom_type == "MultiPolygon": combined = _largest_polygon(combined)
combined = max(combined.geoms, key=lambda g: g.area) # Remove artifact interior holes from INSPIRE+Voronoi+make_valid chain
combined = _fill_holes(combined)
# Subtract parks/water if provided
if greenspace_tree is not None and greenspace_geoms is not None:
from .greenspace import subtract_greenspace
pre_green = combined
combined = subtract_greenspace(combined, greenspace_tree, greenspace_geoms)
combined = _largest_polygon(combined)
combined = _fill_holes(combined)
# Revert if subtraction + fragment selection lost >90% of area
if pre_green.area > 0 and combined.area / pre_green.area < 0.1:
combined = pre_green
merged[pc] = combined merged[pc] = combined
return merged return merged

View file

@ -9,7 +9,8 @@ import pytest
from shapely.geometry import MultiPolygon, Polygon, box from shapely.geometry import MultiPolygon, Polygon, box
from .oa_boundaries import parse_gpkg_geometry from .oa_boundaries import parse_gpkg_geometry
from .output import merge_fragments, to_wgs84_geojson from .greenspace import subtract_greenspace
from .output import _fill_holes, merge_fragments, to_wgs84_geojson
from .process_oa import _extract_polygonal, process_oa from .process_oa import _extract_polygonal, process_oa
from .uprn import get_oa_uprns, load_uprns from .uprn import get_oa_uprns, load_uprns
from .voronoi import _equal_split_fallback, compute_voronoi_regions from .voronoi import _equal_split_fallback, compute_voronoi_regions
@ -426,3 +427,143 @@ class TestParseGpkgGeometry:
blob = bytes([0x47, 0x50, 0x00, 0b00001010]) + b"\x00" * 100 blob = bytes([0x47, 0x50, 0x00, 0b00001010]) + b"\x00" * 100
with pytest.raises(ValueError, match="Unknown GeoPackage envelope type 5"): with pytest.raises(ValueError, match="Unknown GeoPackage envelope type 5"):
parse_gpkg_geometry(blob) parse_gpkg_geometry(blob)
# ---------------------------------------------------------------------------
# _fill_holes removes interior rings
# ---------------------------------------------------------------------------
class TestFillHoles:
"""_fill_holes must remove all interior holes from polygons."""
def test_polygon_with_hole(self):
"""A polygon with an interior ring should become a solid polygon."""
outer = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)]
hole = [(30, 30), (70, 30), (70, 70), (30, 70), (30, 30)]
poly_with_hole = Polygon(outer, [hole])
assert len(list(poly_with_hole.interiors)) == 1
result = _fill_holes(poly_with_hole)
assert result.geom_type == "Polygon"
assert len(list(result.interiors)) == 0
assert result.area == pytest.approx(Polygon(outer).area)
def test_multipolygon_with_holes(self):
"""A MultiPolygon where each part has holes should have all holes removed."""
outer1 = [(0, 0), (50, 0), (50, 50), (0, 50), (0, 0)]
hole1 = [(10, 10), (20, 10), (20, 20), (10, 20), (10, 10)]
outer2 = [(60, 60), (110, 60), (110, 110), (60, 110), (60, 60)]
hole2 = [(70, 70), (80, 70), (80, 80), (70, 80), (70, 70)]
mp = MultiPolygon(
[Polygon(outer1, [hole1]), Polygon(outer2, [hole2])]
)
result = _fill_holes(mp)
assert result.geom_type == "MultiPolygon"
for p in result.geoms:
assert len(list(p.interiors)) == 0
def test_polygon_without_hole_unchanged(self):
"""A polygon with no holes should pass through unchanged."""
poly = box(0, 0, 100, 100)
result = _fill_holes(poly)
assert result.area == pytest.approx(poly.area)
# ---------------------------------------------------------------------------
# Improved merge with 5m buffer closes 3m gaps
# ---------------------------------------------------------------------------
class TestMergeImprovedBuffer:
"""The 5m buffer should close gaps that the old 1m buffer could not."""
def test_3m_gap_merged(self):
"""Two fragments with a 3m gap should merge into a single polygon."""
left = box(0, 0, 50, 100)
right = box(53, 0, 100, 100) # 3m gap at x=50..53
result = merge_fragments([("AA1 1AA", left), ("AA1 1AA", right)])
assert "AA1 1AA" in result
geom = result["AA1 1AA"]
assert geom.geom_type == "Polygon", (
f"Expected single Polygon after merging 3m gap, got {geom.geom_type}"
)
def test_holes_removed_after_merge(self):
"""Interior holes created by merging should be filled."""
# Create a donut-like shape from fragments
outer = box(0, 0, 100, 100)
inner = box(30, 30, 70, 70)
ring = outer.difference(inner)
# Add the inner piece as a separate fragment
result = merge_fragments([("AA1 1AA", ring), ("AA1 1AA", inner)])
assert "AA1 1AA" in result
geom = result["AA1 1AA"]
assert len(list(geom.interiors)) == 0, "Merged polygon should have no holes"
# ---------------------------------------------------------------------------
# subtract_greenspace
# ---------------------------------------------------------------------------
class TestSubtractGreenspace:
"""subtract_greenspace must remove park/water area from postcode polygons."""
def test_park_subtracted(self):
"""A park overlapping a postcode should reduce its area."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100) # 10000 sqm
park = box(60, 0, 100, 100) # 4000 sqm overlap on the right
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
# Should have lost ~4000 sqm
assert result.area == pytest.approx(6000, rel=0.01)
def test_no_greenspace_unchanged(self):
"""With no overlapping greenspace, the geometry should be unchanged."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100)
park = box(200, 200, 300, 300) # far away
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
assert result.area == pytest.approx(postcode.area)
def test_full_overlap_preserves_postcode(self):
"""If greenspace covers the entire postcode, keep the original."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100)
park = box(-10, -10, 110, 110) # completely covers postcode
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
# Should keep original since subtraction would erase entirely
assert result.area == pytest.approx(postcode.area)
def test_over_90pct_removal_preserves_postcode(self):
"""If greenspace would remove >90% of area, keep the original."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100) # 10000 sqm
park = box(5, 0, 100, 100) # 9500 sqm overlap = 95% removal
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
# Should keep original since >90% would be removed
assert result.area == pytest.approx(postcode.area)
def test_under_90pct_removal_subtracts(self):
"""If greenspace removes <90%, subtraction should proceed."""
from shapely.strtree import STRtree
postcode = box(0, 0, 100, 100) # 10000 sqm
park = box(20, 0, 100, 100) # 8000 sqm overlap = 80% removal
tree = STRtree([park])
geoms = [park]
result = subtract_greenspace(postcode, tree, geoms)
# 80% < 90% cap, so subtraction should happen
assert result.area == pytest.approx(2000, rel=0.01)

View file

@ -36,9 +36,10 @@ def main():
df = pl.read_parquet(args.input) df = pl.read_parquet(args.input)
print(f" {len(df):,} rows, {len(df.columns)} columns") print(f" {len(df):,} rows, {len(df.columns)} columns")
# Drop existing estimated price column if re-running # Drop existing estimated columns if re-running
if "Estimated current price" in df.columns: for col in ["Estimated current price", "Est. price per sqm"]:
df = df.drop("Estimated current price") if col in df.columns:
df = df.drop(col)
# Derive helper columns for the join # Derive helper columns for the join
has_price = ( has_price = (
@ -126,6 +127,14 @@ def main():
.alias("Estimated current price"), .alias("Estimated current price"),
) )
# Derive estimated price per sqm where both estimated price and floor area exist
df = df.with_columns(
(pl.col("Estimated current price") / pl.col("Total floor area (sqm)"))
.round(0)
.cast(pl.Int32)
.alias("Est. price per sqm"),
)
n_adjusted = df.filter( n_adjusted = df.filter(
has_price & pl.col("_log_index_sale").is_not_null() has_price & pl.col("_log_index_sale").is_not_null()
).height ).height

View file

@ -29,7 +29,7 @@ use tracing_subscriber::EnvFilter;
use state::AppState; use state::AppState;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "perfect-postcodes", about = "Perfect Postcodes property map server")] #[command(name = "perfect-postcode", about = "Perfect Postcode property map server")]
struct Cli { struct Cli {
/// Path to the wide property parquet file /// Path to the wide property parquet file
#[arg(long)] #[arg(long)]
@ -74,6 +74,10 @@ struct Cli {
/// Ollama model name for area summaries /// Ollama model name for area summaries
#[arg(long, env = "OLLAMA_MODEL", default_value = "gemma3:12b")] #[arg(long, env = "OLLAMA_MODEL", default_value = "gemma3:12b")]
ollama_model: String, ollama_model: String,
/// R5 routing service URL for real-time travel times (e.g. http://r5:8003)
#[arg(long, env = "R5_URL", default_value = "")]
r5_url: String,
} }
#[tokio::main] #[tokio::main]
@ -238,6 +242,11 @@ async fn main() -> anyhow::Result<()> {
"Ollama configured: {} (model: {})", "Ollama configured: {} (model: {})",
cli.ollama_url, cli.ollama_model cli.ollama_url, cli.ollama_model
); );
if !cli.r5_url.is_empty() {
info!("R5 routing service configured: {}", cli.r5_url);
} else {
info!("R5 routing service not configured (travel time queries disabled)");
}
let token_cache = Arc::new(auth::TokenCache::new()); let token_cache = Arc::new(auth::TokenCache::new());
@ -261,6 +270,7 @@ async fn main() -> anyhow::Result<()> {
pocketbase_url: cli.pocketbase_url, pocketbase_url: cli.pocketbase_url,
ollama_url: cli.ollama_url, ollama_url: cli.ollama_url,
ollama_model: cli.ollama_model, ollama_model: cli.ollama_model,
r5_url: cli.r5_url,
token_cache, token_cache,
}); });
@ -283,6 +293,8 @@ async fn main() -> anyhow::Result<()> {
let state_pb = state.clone(); let state_pb = state.clone();
let state_postcode_stats = state.clone(); let state_postcode_stats = state.clone();
let state_area_summary = state.clone(); let state_area_summary = state.clone();
let state_shorten = state.clone();
let state_short_url = state.clone();
let api = Router::new() let api = Router::new()
.route( .route(
@ -335,6 +347,14 @@ async fn main() -> anyhow::Result<()> {
.route( .route(
"/api/area-summary", "/api/area-summary",
post(move |body| routes::post_area_summary(state_area_summary.clone(), body)), post(move |body| routes::post_area_summary(state_area_summary.clone(), body)),
)
.route(
"/api/shorten",
post(move |body| routes::post_shorten(state_shorten.clone(), body)),
)
.route(
"/s/{code}",
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
); );
// Add tile routes // Add tile routes

View file

@ -10,8 +10,10 @@ mod postcode_stats;
mod postcodes; mod postcodes;
pub(crate) mod properties; pub(crate) mod properties;
mod screenshot; mod screenshot;
mod shorten;
mod stats; mod stats;
mod tiles; mod tiles;
pub(crate) mod travel_time;
pub use area_summary::post_area_summary; pub use area_summary::post_area_summary;
pub use export::get_export; pub use export::get_export;
@ -25,4 +27,5 @@ pub use postcode_stats::get_postcode_stats;
pub use postcodes::{get_postcode_lookup, get_postcodes}; pub use postcodes::{get_postcode_lookup, get_postcodes};
pub use properties::get_hexagon_properties; pub use properties::get_hexagon_properties;
pub use screenshot::get_screenshot; pub use screenshot::get_screenshot;
pub use shorten::{get_short_url, post_shorten};
pub use tiles::{get_style, get_tile, init_tile_reader}; pub use tiles::{get_style, get_tile, init_tile_reader};

View file

@ -6,7 +6,7 @@ use axum::response::Json;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tracing::info; use tracing::{info, warn};
use crate::aggregation::Aggregator; use crate::aggregation::Aggregator;
use crate::consts::MAX_CELLS_PER_REQUEST; use crate::consts::MAX_CELLS_PER_REQUEST;
@ -14,6 +14,7 @@ use crate::parsing::{
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices, bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution, parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
}; };
use crate::routes::travel_time::fetch_travel_times;
use crate::state::AppState; use crate::state::AppState;
#[derive(Serialize)] #[derive(Serialize)]
@ -32,6 +33,10 @@ pub struct HexagonParams {
/// When present (even if empty), only listed features are aggregated and written. /// When present (even if empty), only listed features are aggregated and written.
/// When absent, all features are included (backward compatible). /// When absent, all features are included (backward compatible).
fields: Option<String>, fields: Option<String>,
/// Destination point as "lat,lon" for real-time travel time calculation via R5.
destination: Option<String>,
/// Transport mode for travel time: "transit" (default), "car", or "bicycle".
mode: Option<String>,
} }
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds. /// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
@ -99,6 +104,23 @@ fn build_feature_maps(
features features
} }
/// Parse "lat,lon" string into (lat, lon) tuple.
fn parse_destination(s: &str) -> Result<[f64; 2], String> {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 2 {
return Err("destination must be 'lat,lon'".into());
}
let lat: f64 = parts[0]
.trim()
.parse()
.map_err(|_| "invalid destination latitude")?;
let lon: f64 = parts[1]
.trim()
.parse()
.map_err(|_| "invalid destination longitude")?;
Ok([lat, lon])
}
pub async fn get_hexagons( pub async fn get_hexagons(
state: Arc<AppState>, state: Arc<AppState>,
Query(params): Query<HexagonParams>, Query(params): Query<HexagonParams>,
@ -118,7 +140,20 @@ pub async fn get_hexagons(
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index); let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> { // Parse destination for travel time (before moving into blocking closure)
let destination = params
.destination
.as_deref()
.map(parse_destination)
.transpose()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let mode = params.mode.clone().unwrap_or_else(|| "transit".into());
// Capture what we need for the R5 call before moving state into spawn_blocking
let r5_url = state.r5_url.clone();
let http_client = state.http_client.clone();
let mut response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let num_features = state.data.num_features; let num_features = state.data.num_features;
@ -214,5 +249,59 @@ pub async fn get_hexagons(
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))? .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?; .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
// 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(),
));
}
// Collect hex centroids from the response
let origins: Vec<[f64; 2]> = response
.features
.iter()
.map(|f| {
let lat = f
.get("lat")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let lon = f
.get("lon")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
[lat, lon]
})
.collect();
match fetch_travel_times(&http_client, &r5_url, origins, dest, &mode).await {
Ok(travel_times) => {
for (feature, tt) in response.features.iter_mut().zip(travel_times) {
match tt {
Some(minutes) => {
if let Some(num) = serde_json::Number::from_f64(minutes) {
feature.insert("travel_time".into(), Value::Number(num));
}
}
None => {
feature.insert("travel_time".into(), Value::Null);
}
}
}
info!(
hexagons = response.features.len(),
destination = format_args!("{},{}", dest[0], dest[1]),
mode = mode,
"Travel times merged"
);
}
Err(err) => {
warn!("R5 travel time query failed, returning hexagons without travel_time: {}", err);
// Don't fail the whole request — just omit travel_time
}
}
}
Ok(Json(response)) Ok(Json(response))
} }

View file

@ -0,0 +1,122 @@
use std::sync::Arc;
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::state::AppState;
const CODE_LEN: usize = 8;
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
fn generate_code() -> String {
let mut rng = rand::rng();
(0..CODE_LEN)
.map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
.collect()
}
#[derive(Deserialize)]
pub struct ShortenRequest {
params: String,
}
#[derive(Serialize)]
pub struct ShortenResponse {
code: String,
url: String,
}
#[derive(Serialize)]
struct PbRecord {
code: String,
params: String,
}
pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>) -> Response {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let code = generate_code();
let record = PbRecord {
code: code.clone(),
params: req.params,
};
let res = state
.http_client
.post(format!(
"{pb_url}/api/collections/short_urls/records"
))
.json(&record)
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
let body = ShortenResponse {
url: format!("/s/{code}"),
code,
};
Json(body).into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase create failed ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("PocketBase request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
pub async fn get_short_url(state: Arc<AppState>, Path(code): Path<String>) -> Response {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!("code=\"{code}\"");
let url = format!(
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = state.http_client.get(&url).send().await;
match res {
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse PocketBase response: {err}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let params = json["items"]
.as_array()
.and_then(|items| items.first())
.and_then(|item| item["params"].as_str());
match params {
Some(params) => {
Redirect::temporary(&format!("/dashboard?{params}")).into_response()
}
None => StatusCode::NOT_FOUND.into_response(),
}
}
Ok(resp) => {
let status = resp.status();
warn!("PocketBase lookup failed ({status})");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("PocketBase request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}

View file

@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
use tracing::warn;
#[derive(Serialize)]
struct TravelTimeRequest {
origins: Vec<[f64; 2]>,
destination: [f64; 2],
mode: String,
}
#[derive(Deserialize)]
struct TravelTimeResponse {
travel_times: Vec<Option<f64>>,
}
/// Call the R5 service to compute many-to-one travel times.
///
/// Returns a Vec of travel times in minutes (one per origin), with None for unreachable origins.
pub async fn fetch_travel_times(
client: &reqwest::Client,
r5_url: &str,
origins: Vec<[f64; 2]>,
destination: [f64; 2],
mode: &str,
) -> Result<Vec<Option<f64>>, String> {
let url = format!("{}/travel-times", r5_url);
let request_body = TravelTimeRequest {
origins,
destination,
mode: mode.to_string(),
};
let resp = client
.post(&url)
.json(&request_body)
.timeout(std::time::Duration::from_secs(60))
.send()
.await
.map_err(|e| {
warn!("R5 request failed: {}", e);
format!("R5 service 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));
}
let body: TravelTimeResponse = resp.json().await.map_err(|e| {
warn!("Failed to parse R5 response: {}", e);
format!("Failed to parse R5 response: {}", e)
})?;
Ok(body.travel_times)
}

View file

@ -43,6 +43,8 @@ pub struct AppState {
pub ollama_url: String, pub ollama_url: String,
/// Ollama model name for area summaries (e.g. gemma3:12b) /// Ollama model name for area summaries (e.g. gemma3:12b)
pub ollama_model: String, pub ollama_model: String,
/// R5 routing service URL for real-time travel times (empty = disabled)
pub r5_url: String,
/// Token validation cache (60s TTL) /// Token validation cache (60s TTL)
pub token_cache: Arc<TokenCache>, pub token_cache: Arc<TokenCache>,
} }

39
uv.lock generated
View file

@ -140,6 +140,18 @@ css = [
{ name = "tinycss2", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "tinycss2", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
] ]
[[package]]
name = "branca"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/14/9d409124bda3f4ab7af3802aba07181d1fd56aa96cc4b999faea6a27a0d2/branca-0.8.2.tar.gz", hash = "sha256:e5040f4c286e973658c27de9225c1a5a7356dd0702a7c8d84c0f0dfbde388fe7", size = 27890, upload-time = "2025-10-06T10:28:20.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/50/fc9680058e63161f2f63165b84c957a0df1415431104c408e8104a3a18ef/branca-0.8.2-py3-none-any.whl", hash = "sha256:2ebaef3983e3312733c1ae2b793b0a8ba3e1c4edeb7598e10328505280cf2f7c", size = 26193, upload-time = "2025-10-06T10:28:19.255Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.1.4" version = "2026.1.4"
@ -388,6 +400,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" },
] ]
[[package]]
name = "folium"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "branca", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "jinja2", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "numpy", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "requests", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "xyzservices", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/76/84a1b1b00ce71f9c0c44af7d80f310c02e2e583591fe7d4cb03baecd0d3f/folium-0.20.0.tar.gz", hash = "sha256:a0d78b9d5a36ba7589ca9aedbd433e84e9fcab79cd6ac213adbcff922e454cb9", size = 109932, upload-time = "2025-06-16T20:22:51.803Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/a8/5f764f333204db0390362a4356d03a43626997f26818a0e9396f1b3bd8c9/folium-0.20.0-py2.py3-none-any.whl", hash = "sha256:f0bc2a92acde20bca56367aa5c1c376c433f450608d058daebab2fc9bf8198bf", size = 113394, upload-time = "2025-06-16T20:22:50.318Z" },
]
[[package]] [[package]]
name = "fonttools" name = "fonttools"
version = "4.61.1" version = "4.61.1"
@ -1340,6 +1368,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "fastexcel", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "fastexcel", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "folium", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "httpx", extra = ["socks"], marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "httpx", extra = ["socks"], marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "ipywidgets", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "ipywidgets", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "jupyter", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "jupyter", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
@ -1369,6 +1398,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastexcel", specifier = ">=0.19.0" }, { name = "fastexcel", specifier = ">=0.19.0" },
{ name = "folium", specifier = ">=0.20.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "ipywidgets", specifier = ">=8.0.0" }, { name = "ipywidgets", specifier = ">=8.0.0" },
{ name = "jupyter", specifier = ">=1.0.0" }, { name = "jupyter", specifier = ">=1.0.0" },
@ -2105,3 +2135,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" },
] ]
[[package]]
name = "xyzservices"
version = "2025.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29e742a509913badb53ce0b38f64b6db859e2f6339da9/xyzservices-2025.11.0.tar.gz", hash = "sha256:2fc72b49502b25023fd71e8f532fb4beddbbf0aa124d90ea25dba44f545e17ce", size = 1135703, upload-time = "2025-11-22T11:31:51.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" },
]