More
This commit is contained in:
parent
1f68ca0512
commit
3599803589
43 changed files with 3578 additions and 262 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import MapPage, { type ExportState } from './components/map/MapPage';
|
||||
import LearnPage from './components/learn/LearnPage';
|
||||
import PricingPage from './components/pricing/PricingPage';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
|
||||
|
|
@ -26,9 +25,7 @@ function pageToPath(page: Page): string {
|
|||
switch (page) {
|
||||
case 'dashboard':
|
||||
return '/dashboard';
|
||||
case 'learn':
|
||||
return '/learn';
|
||||
case 'saved-searches':
|
||||
case 'saved-searches':
|
||||
return '/saved';
|
||||
case 'pricing':
|
||||
return '/pricing';
|
||||
|
|
@ -39,8 +36,7 @@ function pageToPath(page: Page): string {
|
|||
|
||||
function pathToPage(pathname: string): Page | null {
|
||||
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 === '/') return 'home';
|
||||
return null;
|
||||
|
|
@ -81,7 +77,7 @@ export default function App() {
|
|||
|
||||
// Backward compat: dashboard params on unknown path
|
||||
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
|
||||
window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`);
|
||||
return 'dashboard';
|
||||
|
|
@ -207,6 +203,7 @@ export default function App() {
|
|||
onNavigateTo={() => {}}
|
||||
screenshotMode
|
||||
ogMode={isOgMode}
|
||||
initialTravelTime={urlState.travelTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -236,8 +233,6 @@ export default function App() {
|
|||
/>
|
||||
{activePage === 'home' ? (
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
|
||||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
) : activePage === 'pricing' ? (
|
||||
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
|
||||
) : activePage === 'saved-searches' ? (
|
||||
|
|
@ -264,6 +259,7 @@ export default function App() {
|
|||
onNavigateTo={navigateTo}
|
||||
onExportStateChange={setExportState}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={urlState.travelTime}
|
||||
/>
|
||||
)}
|
||||
{showAuthModal && (
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export default function DataSourcesPage() {
|
|||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
{source.name}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'What is this application?',
|
||||
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?',
|
||||
|
|
@ -101,7 +101,7 @@ export default function FAQPage() {
|
|||
Frequently Asked Questions
|
||||
</h1>
|
||||
<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.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
399
frontend/src/components/learn/LearnPage.tsx
Normal file
399
frontend/src/components/learn/LearnPage.tsx
Normal 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 2023–2025 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 © Crown copyright and database right 2025.
|
||||
</li>
|
||||
<li>
|
||||
Contains public sector information licensed under the{' '}
|
||||
<a
|
||||
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Open Government Licence v3.0
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Contains OS data © Crown copyright and database rights 2025.</li>
|
||||
<li>Powered by TfL Open Data.</li>
|
||||
<li>
|
||||
Contains data from{' '}
|
||||
<a
|
||||
href="https://www.openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
© OpenStreetMap contributors
|
||||
</a>
|
||||
, available under the{' '}
|
||||
<a
|
||||
href="https://opendatacommons.org/licenses/odbl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Open Data Commons Open Database License (ODbL)
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import { groupFeaturesByCategory } from '../../lib/features';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { RouteIcon, PlusIcon } from '../ui/icons';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
|
||||
interface FeatureBrowserProps {
|
||||
availableFeatures: FeatureMeta[];
|
||||
|
|
@ -19,6 +21,8 @@ interface FeatureBrowserProps {
|
|||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
travelTimeEnabled?: boolean;
|
||||
onEnableTravelTime?: () => void;
|
||||
}
|
||||
|
||||
export default function FeatureBrowser({
|
||||
|
|
@ -30,6 +34,8 @@ export default function FeatureBrowser({
|
|||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
travelTimeEnabled,
|
||||
onEnableTravelTime,
|
||||
}: FeatureBrowserProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
|
@ -60,6 +66,26 @@ export default function FeatureBrowser({
|
|||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<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) => {
|
||||
const isExpanded = isSearching || expandedGroups.has(group.name);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,14 +1,51 @@
|
|||
import { memo, useState } from 'react';
|
||||
import { memo, useState, useMemo } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
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 { formatFilterValue } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
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 {
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -22,15 +59,23 @@ interface FiltersProps {
|
|||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
zoom: number;
|
||||
itemCount: number;
|
||||
usePostcodeView: boolean;
|
||||
pinnedFeature: string | null;
|
||||
onTogglePin: (name: string) => void;
|
||||
onCancelPin: () => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
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({
|
||||
|
|
@ -45,21 +90,34 @@ export default memo(function Filters({
|
|||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
zoom,
|
||||
itemCount,
|
||||
usePostcodeView,
|
||||
pinnedFeature,
|
||||
onTogglePin,
|
||||
onCancelPin: _onCancelPin,
|
||||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
travelTimeEnabled,
|
||||
travelTimeDestination,
|
||||
travelTimeDestinationLabel,
|
||||
travelTimeMode,
|
||||
travelTimeRange,
|
||||
travelTimeDataRange,
|
||||
onTravelTimeEnable,
|
||||
onTravelTimeDisable,
|
||||
onTravelTimeSetDestination,
|
||||
onTravelTimeModeChange,
|
||||
onTravelTimeRangeChange,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
const enabledGroups = useMemo(
|
||||
() => groupFeaturesByCategory(enabledFeatureList),
|
||||
[enabledFeatureList]
|
||||
);
|
||||
|
||||
return (
|
||||
<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
|
||||
</button>
|
||||
</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="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
||||
Active Filters
|
||||
</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">
|
||||
{enabledFeatureList.length}
|
||||
{enabledFeatureList.length + (travelTimeEnabled ? 1 : 0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{itemCount.toLocaleString()} {usePostcodeView ? 'postcodes' : 'hexagons'} · z
|
||||
{zoom.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="md:flex-1 md:overflow-y-auto p-3 space-y-3">
|
||||
{enabledFeatureList.length === 0 && (
|
||||
<div className="md:flex-1 md:overflow-y-auto">
|
||||
{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
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No active filters"
|
||||
description="Browse features below and click + to add a filter"
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFeatureList.map((feature) => {
|
||||
if (feature.type === 'enum') {
|
||||
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;
|
||||
|
||||
{enabledGroups.map((group) => {
|
||||
const isExpanded = !collapsedGroups.has(group.name);
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
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' : ''}`}
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-warm-500 dark:text-warm-400">
|
||||
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
|
||||
<div key={group.name}>
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
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-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</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()}
|
||||
/>
|
||||
</CollapsibleGroupHeader>
|
||||
{isExpanded && (
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{group.features.map((feature) => {
|
||||
if (feature.type === 'enum') {
|
||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
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' : ''}`}
|
||||
>
|
||||
<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>
|
||||
<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 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">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
|
||||
</div>
|
||||
|
|
@ -197,6 +283,8 @@ export default memo(function Filters({
|
|||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
travelTimeEnabled={travelTimeEnabled}
|
||||
onEnableTravelTime={onTravelTimeEnable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ interface MapProps {
|
|||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
travelTimeEnabled?: boolean;
|
||||
travelTimeDestination?: [number, number] | null;
|
||||
travelTimeColorRange?: [number, number] | null;
|
||||
travelTimeRange?: [number, number] | null;
|
||||
}
|
||||
|
||||
interface Dimensions {
|
||||
|
|
@ -98,6 +102,10 @@ export default memo(function Map({
|
|||
onPostcodeSearched,
|
||||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
travelTimeEnabled = false,
|
||||
travelTimeDestination,
|
||||
travelTimeColorRange,
|
||||
travelTimeRange,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
|
|
@ -176,6 +184,10 @@ export default memo(function Map({
|
|||
theme,
|
||||
searchedPostcode,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEnabled,
|
||||
travelTimeDestination,
|
||||
travelTimeColorRange,
|
||||
travelTimeRange,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -204,7 +216,7 @@ export default memo(function Map({
|
|||
className="text-5xl font-bold text-white drop-shadow-lg"
|
||||
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
Your perfect postcodes
|
||||
Your perfect postcode
|
||||
</h1>
|
||||
</div>
|
||||
) : null
|
||||
|
|
@ -212,7 +224,17 @@ export default memo(function Map({
|
|||
<>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
|
||||
{!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
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export default function MapLegend({
|
|||
enumValues,
|
||||
theme = 'light',
|
||||
inline = false,
|
||||
suffix,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
|
|
@ -22,6 +23,7 @@ export default function MapLegend({
|
|||
enumValues?: string[];
|
||||
theme?: 'light' | 'dark';
|
||||
inline?: boolean;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
|
|
@ -61,8 +63,8 @@ export default function MapLegend({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<TickerValue text={formatValue(range[0])} />
|
||||
<TickerValue text={formatValue(range[1])} />
|
||||
<TickerValue text={formatValue(range[0]) + (suffix || '')} />
|
||||
<TickerValue text={formatValue(range[1]) + (suffix || '')} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import POIPane from './POIPane';
|
|||
import { PropertiesPane } from './PropertiesPane';
|
||||
import AreaPane from './AreaPane';
|
||||
import MobileDrawer from './MobileDrawer';
|
||||
import DataSources from '../data-sources/DataSources';
|
||||
import MapLegend from './MapLegend';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
import { useMapData } from '../../hooks/useMapData';
|
||||
|
|
@ -18,6 +17,7 @@ import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
|||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||
import { useAreaSummary } from '../../hooks/useAreaSummary';
|
||||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import { useTravelTime, type TravelTimeInitial } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, buildFilterString } from '../../lib/api';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ interface MapPageProps {
|
|||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
isMobile?: boolean;
|
||||
initialTravelTime?: TravelTimeInitial;
|
||||
}
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -62,6 +63,7 @@ export default function MapPage({
|
|||
screenshotMode,
|
||||
ogMode,
|
||||
isMobile = false,
|
||||
initialTravelTime,
|
||||
}: MapPageProps) {
|
||||
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
||||
const [selectedPOICategories, setSelectedPOICategories] =
|
||||
|
|
@ -99,6 +101,9 @@ export default function MapPage({
|
|||
features,
|
||||
});
|
||||
|
||||
// Travel time hook
|
||||
const travelTime = useTravelTime(initialTravelTime);
|
||||
|
||||
// Map data hook
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
|
|
@ -107,6 +112,9 @@ export default function MapPage({
|
|||
activeFeature,
|
||||
dragValue,
|
||||
dragData,
|
||||
travelTimeEnabled: travelTime.enabled,
|
||||
travelTimeDestination: travelTime.destination,
|
||||
travelTimeMode: travelTime.mode,
|
||||
});
|
||||
|
||||
// Keep filter bounds in sync with map data
|
||||
|
|
@ -124,8 +132,21 @@ export default function MapPage({
|
|||
// POI data
|
||||
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
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -201,7 +222,7 @@ export default function MapPage({
|
|||
.then((blob) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'perfect-postcodes-export.xlsx';
|
||||
link.download = 'perfect-postcode-export.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
})
|
||||
|
|
@ -292,7 +313,6 @@ export default function MapPage({
|
|||
onClose={selection.handleCloseSelection}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||
aiSummary={aiSummary.summary}
|
||||
aiSummaryLoading={aiSummary.loading}
|
||||
aiSummaryError={aiSummary.error}
|
||||
|
|
@ -307,7 +327,6 @@ export default function MapPage({
|
|||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
onLoadMore={selection.handleLoadMoreProperties}
|
||||
onClose={selection.handleCloseSelection}
|
||||
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -317,7 +336,6 @@ export default function MapPage({
|
|||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -334,15 +352,22 @@ export default function MapPage({
|
|||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={mapData.zoom}
|
||||
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
|
||||
usePostcodeView={mapData.usePostcodeView}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
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}
|
||||
bounds={mapData.bounds}
|
||||
hideLegend
|
||||
travelTimeEnabled={travelTime.enabled}
|
||||
travelTimeDestination={travelTime.destination}
|
||||
travelTimeColorRange={mapData.travelTimeColorRange}
|
||||
travelTimeRange={travelTime.timeRange}
|
||||
/>
|
||||
{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">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
|
||||
</div>
|
||||
|
||||
{/* Bottom panel — 55% */}
|
||||
|
|
@ -401,7 +429,18 @@ export default function MapPage({
|
|||
style={{ flex: '55 0 0' }}
|
||||
>
|
||||
{/* 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
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
|
|
@ -516,13 +555,16 @@ export default function MapPage({
|
|||
searchedPostcode={searchedPostcode}
|
||||
onPostcodeSearched={setSearchedPostcode}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEnabled={travelTime.enabled}
|
||||
travelTimeDestination={travelTime.destination}
|
||||
travelTimeColorRange={mapData.travelTimeColorRange}
|
||||
travelTimeRange={travelTime.timeRange}
|
||||
/>
|
||||
{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">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
|
||||
</div>
|
||||
|
||||
{/* Right Pane */}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
|||
import type { POICategoryGroup } from '../../types';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
import { InfoIcon, ChevronIcon } from '../ui/icons';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
|
||||
|
|
@ -162,39 +164,32 @@ export default function POIPane({
|
|||
>
|
||||
<ChevronIcon direction="right" className="w-3 h-3" />
|
||||
</button>
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allInGroupSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someInGroupSelected;
|
||||
}}
|
||||
onChange={() => toggleGroup(group.name)}
|
||||
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">
|
||||
<PillToggle
|
||||
label={group.name}
|
||||
active={allInGroupSelected}
|
||||
indeterminate={someInGroupSelected}
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="text-xs text-warm-400 ml-auto">
|
||||
{groupSelected}/{group.categories.length}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
group.categories.map((category) => (
|
||||
<label
|
||||
key={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"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.has(category)}
|
||||
onChange={() => toggleCategory(category)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<span className="text-sm flex-1">{category}</span>
|
||||
</label>
|
||||
))}
|
||||
{!isCollapsed && (
|
||||
<div className="px-3 py-2">
|
||||
<PillGroup>
|
||||
{group.categories.map((category) => (
|
||||
<PillToggle
|
||||
key={category}
|
||||
label={category}
|
||||
active={selectedCategories.has(category)}
|
||||
onClick={() => toggleCategory(category)}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
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 InfoPopup from '../ui/InfoPopup';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
|
|
@ -145,6 +145,7 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
const price = getNum(property, 'Last known price', 'latest_price');
|
||||
const estimatedPrice = getNum(property, 'Estimated current price');
|
||||
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 rooms = getNum(
|
||||
property,
|
||||
|
|
@ -152,6 +153,7 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
'number_habitable_rooms'
|
||||
);
|
||||
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 councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
|
||||
|
||||
|
|
@ -165,10 +167,16 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{price !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
</span>
|
||||
)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
(£{formatNumber(pricePerSqm)}/m²)
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -179,6 +187,9 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
<span className="font-semibold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(estimatedPrice)}
|
||||
</span>
|
||||
{estPricePerSqm !== undefined && (
|
||||
<span> (£{formatNumber(estPricePerSqm)}/m²)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
172
frontend/src/components/map/TravelTimeCard.tsx
Normal file
172
frontend/src/components/map/TravelTimeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import { shortenUrl } from '../../lib/api';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
import { TrashIcon } from '../ui/icons/TrashIcon';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
|
@ -20,6 +21,7 @@ export default function SavedSearchesPage({
|
|||
}) {
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
|
|
@ -27,17 +29,16 @@ export default function SavedSearchesPage({
|
|||
setDeleteConfirmId(null);
|
||||
}, [deleteConfirmId, onDelete]);
|
||||
|
||||
const handleShare = useCallback((params: string, id: string) => {
|
||||
const url = `${window.location.origin}/?${params}`;
|
||||
const copyToClipboard = useCallback((text: string, id: string) => {
|
||||
const onSuccess = () => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(url).then(onSuccess);
|
||||
navigator.clipboard.writeText(text).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url;
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
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 (
|
||||
<div className="flex-1 overflow-auto bg-warm-50 dark:bg-warm-900">
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
|
|
@ -106,9 +119,12 @@ export default function SavedSearchesPage({
|
|||
</button>
|
||||
<button
|
||||
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
|
||||
onClick={() => setDeleteConfirmId(search.id)}
|
||||
|
|
|
|||
19
frontend/src/components/ui/icons/RouteIcon.tsx
Normal file
19
frontend/src/components/ui/icons/RouteIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/SearchIcon.tsx
Normal file
21
frontend/src/components/ui/icons/SearchIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,3 +6,4 @@ export { ChevronIcon } from './ChevronIcon';
|
|||
export { FilterIcon } from './FilterIcon';
|
||||
export { LightbulbIcon } from './LightbulbIcon';
|
||||
export { MenuIcon } from './MenuIcon';
|
||||
export { RouteIcon } from './RouteIcon';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useRef, useState, useMemo } from 'react';
|
||||
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 {
|
||||
HexagonData,
|
||||
|
|
@ -44,6 +44,10 @@ interface UseDeckLayersProps {
|
|||
theme: 'light' | 'dark';
|
||||
searchedPostcode?: SearchedPostcode | null;
|
||||
bounds?: Bounds | null;
|
||||
travelTimeEnabled?: boolean;
|
||||
travelTimeDestination?: [number, number] | null;
|
||||
travelTimeColorRange?: [number, number] | null;
|
||||
travelTimeRange?: [number, number] | null;
|
||||
}
|
||||
|
||||
export interface PopupInfo {
|
||||
|
|
@ -70,6 +74,10 @@ export function useDeckLayers({
|
|||
theme,
|
||||
searchedPostcode,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEnabled = false,
|
||||
travelTimeDestination,
|
||||
travelTimeColorRange,
|
||||
travelTimeRange,
|
||||
}: UseDeckLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
|
@ -99,6 +107,15 @@ export function useDeckLayers({
|
|||
const hoveredPostcodeRef = useRef(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(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
|
|
@ -225,8 +242,9 @@ export function useDeckLayers({
|
|||
}, []);
|
||||
|
||||
// --- Color triggers ---
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`;
|
||||
const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}`;
|
||||
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 ---
|
||||
const hexLayer = useMemo(
|
||||
|
|
@ -236,11 +254,36 @@ export function useDeckLayers({
|
|||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
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 clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
const minVal = d[`min_${vf}`] as number | undefined;
|
||||
|
|
@ -457,13 +500,30 @@ export function useDeckLayers({
|
|||
});
|
||||
}, [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 baseLayers = usePostcodeView
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const baseLayers: any[] = usePostcodeView
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
: [hexLayer, poiLayer];
|
||||
if (searchedPostcodeHighlightLayer) {
|
||||
return [...baseLayers, searchedPostcodeHighlightLayer];
|
||||
}
|
||||
if (searchedPostcodeHighlightLayer) baseLayers.push(searchedPostcodeHighlightLayer);
|
||||
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
|
||||
return baseLayers;
|
||||
}, [
|
||||
usePostcodeView,
|
||||
|
|
@ -472,6 +532,7 @@ export function useDeckLayers({
|
|||
postcodeLabelsLayer,
|
||||
poiLayer,
|
||||
searchedPostcodeHighlightLayer,
|
||||
destinationMarkerLayer,
|
||||
]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ interface UseMapDataOptions {
|
|||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
dragData: HexagonData[] | null;
|
||||
travelTimeEnabled: boolean;
|
||||
travelTimeDestination: [number, number] | null;
|
||||
travelTimeMode: string;
|
||||
}
|
||||
|
||||
export function useMapData({
|
||||
|
|
@ -41,6 +44,9 @@ export function useMapData({
|
|||
activeFeature,
|
||||
dragValue,
|
||||
dragData,
|
||||
travelTimeEnabled,
|
||||
travelTimeDestination,
|
||||
travelTimeMode,
|
||||
}: UseMapDataOptions) {
|
||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
|
||||
|
|
@ -104,6 +110,10 @@ export function useMapData({
|
|||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', viewFeature || '');
|
||||
if (travelTimeEnabled && travelTimeDestination) {
|
||||
params.set('destination', `${travelTimeDestination[0]},${travelTimeDestination[1]}`);
|
||||
params.set('mode', travelTimeMode);
|
||||
}
|
||||
const res = await fetch(
|
||||
apiUrl('hexagons', params),
|
||||
authHeaders({
|
||||
|
|
@ -126,7 +136,7 @@ export function useMapData({
|
|||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelTimeEnabled, travelTimeDestination, travelTimeMode]);
|
||||
|
||||
const data = dragData ?? rawData;
|
||||
|
||||
|
|
@ -187,6 +197,27 @@ export function useMapData({
|
|||
return null;
|
||||
}, [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(
|
||||
({
|
||||
resolution: newRes,
|
||||
|
|
@ -226,6 +257,7 @@ export function useMapData({
|
|||
currentView,
|
||||
usePostcodeView,
|
||||
colorRange,
|
||||
travelTimeColorRange,
|
||||
handleViewChange,
|
||||
setInitialView,
|
||||
};
|
||||
|
|
|
|||
67
frontend/src/hooks/useTravelTime.ts
Normal file
67
frontend/src/hooks/useTravelTime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,15 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
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;
|
||||
|
||||
|
|
@ -9,7 +18,8 @@ export function useUrlSync(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area'
|
||||
rightPaneTab: 'pois' | 'properties' | 'area',
|
||||
travelTime?: TravelTimeUrlState
|
||||
) {
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -23,7 +33,8 @@ export function useUrlSync(
|
|||
filters,
|
||||
features,
|
||||
selectedPOICategories,
|
||||
rightPaneTab
|
||||
rightPaneTab,
|
||||
travelTime
|
||||
);
|
||||
const search = params.toString();
|
||||
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||
|
|
@ -33,5 +44,5 @@ export function useUrlSync(
|
|||
return () => {
|
||||
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
|
||||
};
|
||||
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
|
||||
}, [currentView, filters, features, selectedPOICategories, rightPaneTab, travelTime]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,3 +113,13 @@ h3 {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
const entries = Object.entries(filters);
|
||||
if (entries.length === 0) return '';
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@ export function formatDuration(d: string): string {
|
|||
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 {
|
||||
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
||||
return Math.round(value).toString();
|
||||
|
|
|
|||
|
|
@ -1,62 +1,138 @@
|
|||
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(): {
|
||||
viewState?: ViewState;
|
||||
filters?: FeatureFilters;
|
||||
poiCategories?: Set<string>;
|
||||
tab?: 'pois' | 'properties' | 'area';
|
||||
travelTime?: TravelTimeInitial;
|
||||
} {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result: ReturnType<typeof parseUrlState> = {};
|
||||
|
||||
const v = params.get('v');
|
||||
if (v) {
|
||||
const parts = v.split(',').map(Number);
|
||||
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
||||
result.viewState = {
|
||||
latitude: parts[0],
|
||||
longitude: parts[1],
|
||||
zoom: parts[2],
|
||||
pitch: 0,
|
||||
};
|
||||
// View state: separate lat/lon/zoom params
|
||||
const lat = params.get('lat');
|
||||
const lon = params.get('lon');
|
||||
const zoom = params.get('zoom');
|
||||
if (lat && lon && zoom) {
|
||||
const latN = Number(lat);
|
||||
const lonN = Number(lon);
|
||||
const zoomN = Number(zoom);
|
||||
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
|
||||
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const f = params.get('f');
|
||||
if (f) {
|
||||
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];
|
||||
} else {
|
||||
// Backward compat: old packed `v=lat,lon,zoom`
|
||||
const v = params.get('v');
|
||||
if (v) {
|
||||
const parts = v.split(',').map(Number);
|
||||
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
||||
result.viewState = { latitude: parts[0], longitude: parts[1], zoom: parts[2], pitch: 0 };
|
||||
}
|
||||
}
|
||||
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');
|
||||
if (poi) {
|
||||
result.poiCategories = new Set(poi.split(',').filter(Boolean));
|
||||
// Tab: full name
|
||||
const tab = params.get('tab');
|
||||
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');
|
||||
if (tab === 'p') result.tab = 'properties';
|
||||
else if (tab === 'o') result.tab = 'pois';
|
||||
else if (tab === 'a') result.tab = 'area';
|
||||
// Travel time
|
||||
const dest = params.get('dest');
|
||||
if (dest) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -66,40 +142,48 @@ export function stateToParams(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
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 {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (viewState) {
|
||||
params.set(
|
||||
'v',
|
||||
`${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}`
|
||||
);
|
||||
params.set('lat', viewState.latitude.toFixed(4));
|
||||
params.set('lon', viewState.longitude.toFixed(4));
|
||||
params.set('zoom', viewState.zoom.toFixed(1));
|
||||
}
|
||||
|
||||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filtersStr = filterEntries
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') {
|
||||
return `${name}:${(value as string[]).join('|')}`;
|
||||
}
|
||||
const [min, max] = value as [number, number];
|
||||
return `${name}:${min}:${max}`;
|
||||
})
|
||||
.join(',');
|
||||
params.set('f', filtersStr);
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') {
|
||||
params.append('filter', `${name}:${(value as string[]).join('|')}`);
|
||||
} else {
|
||||
const [min, max] = value as [number, number];
|
||||
params.append('filter', `${name}:${min}:${max}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedPOICategories.size > 0) {
|
||||
params.set('poi', Array.from(selectedPOICategories).join(','));
|
||||
for (const category of selectedPOICategories) {
|
||||
params.append('poi', category);
|
||||
}
|
||||
|
||||
if (rightPaneTab === 'properties') {
|
||||
params.set('tab', 'p');
|
||||
params.set('tab', 'properties');
|
||||
} 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;
|
||||
|
|
@ -109,13 +193,13 @@ export function summarizeParams(queryString: string): string {
|
|||
const params = new URLSearchParams(queryString);
|
||||
const parts: string[] = [];
|
||||
|
||||
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;
|
||||
// New format: repeated `filter` params
|
||||
const filterParams = params.getAll('filter');
|
||||
if (filterParams.length > 0) {
|
||||
const filterNames = filterParams
|
||||
.map((entry) => {
|
||||
const colonIdx = entry.indexOf(':');
|
||||
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (filterNames.length > 0) {
|
||||
|
|
@ -123,11 +207,28 @@ export function summarizeParams(queryString: string): string {
|
|||
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');
|
||||
if (poi) {
|
||||
const count = poi.split(',').filter(Boolean).length;
|
||||
const poiParams = params.getAll('poi');
|
||||
if (poiParams.length > 0) {
|
||||
const count = poiParams.flatMap((p) => p.split(',')).filter(Boolean).length;
|
||||
if (count > 0) {
|
||||
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue