Can't even keep track anymore
This commit is contained in:
parent
dccc1e439d
commit
3a3f899ea2
50 changed files with 1144 additions and 560 deletions
|
|
@ -191,7 +191,7 @@ export default function App() {
|
|||
initialFilters={urlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories || new Set()}
|
||||
initialTab={urlState.tab || 'pois'}
|
||||
initialTab={urlState.tab || 'area'}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={null}
|
||||
|
|
@ -249,7 +249,7 @@ export default function App() {
|
|||
initialFilters={urlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories || new Set()}
|
||||
initialTab={urlState.tab || 'pois'}
|
||||
initialTab={urlState.tab || 'area'}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={pendingInfoFeature}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
export default function DataSources({ onNavigate }: { onNavigate: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onNavigate}
|
||||
className="absolute bottom-2 right-2 bg-white/90 dark:bg-warm-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
|
||||
>
|
||||
Data Sources
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
const DATA_SOURCES = [
|
||||
{
|
||||
id: 'price-paid',
|
||||
name: 'Price Paid Data',
|
||||
origin: 'HM Land Registry',
|
||||
use: 'Complete historical property sale prices for England and Wales. Used for the last known sale price of each property.',
|
||||
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'epc',
|
||||
name: 'Energy Performance Certificates (EPC)',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
|
||||
optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
|
||||
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'nspl',
|
||||
name: 'National Statistics Postcode Lookup (NSPL)',
|
||||
origin: 'ONS / ArcGIS',
|
||||
use: 'Maps postcodes to latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.',
|
||||
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'iod',
|
||||
name: 'English Indices of Deprivation 2025',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Relative deprivation scores for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.',
|
||||
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ethnicity',
|
||||
name: 'Population by Ethnicity (2021 Census)',
|
||||
origin: 'ONS',
|
||||
use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
|
||||
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'crime',
|
||||
name: 'Street-level Crime Data',
|
||||
origin: 'data.police.uk',
|
||||
use: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
|
||||
url: 'https://data.police.uk/data/',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'tfl-journey-times',
|
||||
name: 'TfL Journey Times',
|
||||
origin: 'Transport for London',
|
||||
use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.",
|
||||
url: 'https://api-portal.tfl.gov.uk/',
|
||||
license: 'Powered by TfL Open Data',
|
||||
},
|
||||
{
|
||||
id: 'osm-pois',
|
||||
name: 'OpenStreetMap POIs',
|
||||
origin: 'OpenStreetMap contributors / Geofabrik',
|
||||
use: 'Points of interest extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.',
|
||||
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
||||
license: 'Open Data Commons Open Database License (ODbL)',
|
||||
},
|
||||
{
|
||||
id: 'naptan',
|
||||
name: 'NaPTAN (Public Transport Stops)',
|
||||
origin: 'Department for Transport',
|
||||
use: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.',
|
||||
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'noise',
|
||||
name: 'Defra Noise Mapping',
|
||||
origin: 'Defra / Environment Agency',
|
||||
use: 'Strategic noise mapping Round 4 (2022) for road, rail, and airport sources. Lden (day-evening-night 24h weighted average) at 10m grid resolution, modelled at 4m above ground. Sampled at postcode centroids via WCS GeoTIFF tiles.',
|
||||
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ofsted',
|
||||
name: 'Ofsted School Inspections',
|
||||
origin: 'Ofsted',
|
||||
use: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
|
||||
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'broadband',
|
||||
name: 'Ofcom Broadband Performance',
|
||||
origin: 'Ofcom',
|
||||
use: 'Fixed broadband coverage and speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.',
|
||||
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'geosure',
|
||||
name: 'GeoSure Ground Stability',
|
||||
origin: 'Ordnance Survey',
|
||||
use: 'Ground stability hazard ratings on a 5km hex grid covering Great Britain. Six risk categories (collapsible deposits, compressible ground, landslides, running sand, shrink-swell, and soluble rocks) rated Low, Moderate, or Significant. Spatial-joined to postcodes via centroid intersection.',
|
||||
url: 'https://osdatahub.os.uk/downloads/open/GeoSure',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'council-tax',
|
||||
name: 'Council Tax Levels 2025-26',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.',
|
||||
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DataSourcesPage() {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
function handleHash() {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
if (hash && DATA_SOURCES.some((s) => s.id === hash)) {
|
||||
setHighlightedId(hash);
|
||||
// Scroll after a brief delay to allow render
|
||||
setTimeout(() => {
|
||||
cardRefs.current[hash]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
} else {
|
||||
setHighlightedId(null);
|
||||
}
|
||||
}
|
||||
handleHash();
|
||||
window.addEventListener('hashchange', handleHash);
|
||||
return () => window.removeEventListener('hashchange', handleHash);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">Data Sources</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
This application combines {DATA_SOURCES.length} open datasets covering property prices,
|
||||
energy performance, transport, demographics, crime, environment, and more.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{DATA_SOURCES.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
ref={(el) => {
|
||||
cardRefs.current[source.id] = el;
|
||||
}}
|
||||
className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${
|
||||
highlightedId === source.id
|
||||
? 'border-teal-400 ring-2 ring-teal-400'
|
||||
: 'border-warm-200 dark:border-navy-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
{source.name}
|
||||
</h2>
|
||||
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
|
||||
{source.license}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
||||
Source: {source.origin}
|
||||
</p>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">{source.use}</p>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
|
||||
>
|
||||
{source.url}
|
||||
</a>
|
||||
{'optOutUrl' in source && source.optOutUrl && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={source.optOutUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Opt out of public disclosure
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
||||
Attribution
|
||||
</h2>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
<li>Contains HM Land Registry data © Crown copyright and database right 2025.</li>
|
||||
<li>
|
||||
Contains public sector information licensed under the{' '}
|
||||
<a
|
||||
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Open Government Licence v3.0
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Contains OS data © Crown copyright and database rights 2025.</li>
|
||||
<li>Powered by TfL Open Data.</li>
|
||||
<li>
|
||||
Contains data from{' '}
|
||||
<a
|
||||
href="https://www.openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
© OpenStreetMap contributors
|
||||
</a>
|
||||
, available under the{' '}
|
||||
<a
|
||||
href="https://opendatacommons.org/licenses/odbl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Open Data Commons Open Database License (ODbL)
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,6 +117,14 @@ const DATA_SOURCES = [
|
|||
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ons-rental',
|
||||
name: 'Private Rental Market Statistics',
|
||||
origin: 'ONS / Valuation Office Agency',
|
||||
use: 'Median monthly private rental prices by local authority and bedroom category (Oct 2022 - Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
|
||||
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
];
|
||||
|
||||
interface FAQItem {
|
||||
|
|
|
|||
53
frontend/src/components/map/AiFilterInput.tsx
Normal file
53
frontend/src/components/map/AiFilterInput.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { memo, useState, useCallback } from 'react';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
interface AiFilterInputProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onSubmit: (query: string) => void;
|
||||
}
|
||||
|
||||
export default memo(function AiFilterInput({ loading, error, onSubmit }: AiFilterInputProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed || loading) return;
|
||||
onSubmit(trimmed);
|
||||
},
|
||||
[query, loading, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<form onSubmit={handleSubmit} className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Describe your ideal property..."
|
||||
className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5"
|
||||
>
|
||||
{loading ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'AI'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400 truncate" title={error}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { memo, useState, useMemo } from 'react';
|
||||
import { memo, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { LightbulbIcon } from '../ui/icons';
|
||||
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
|
|
@ -14,6 +14,7 @@ import InfoPopup from '../ui/InfoPopup';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import AiFilterInput from './AiFilterInput';
|
||||
import FeatureBrowser from './FeatureBrowser';
|
||||
import { TravelTimeCard } from './TravelTimeCard';
|
||||
import type { TransportMode } from '../../hooks/useTravelTime';
|
||||
|
|
@ -80,6 +81,9 @@ interface FiltersProps {
|
|||
onTravelTimeSetDestination: (lat: number, lon: number, label: string) => void;
|
||||
onTravelTimeModeChange: (mode: TransportMode) => void;
|
||||
onTravelTimeRangeChange: (range: [number, number]) => void;
|
||||
aiFilterLoading: boolean;
|
||||
aiFilterError: string | null;
|
||||
onAiFilterSubmit: (query: string) => void;
|
||||
}
|
||||
|
||||
export default memo(function Filters({
|
||||
|
|
@ -111,13 +115,27 @@ export default memo(function Filters({
|
|||
onTravelTimeSetDestination,
|
||||
onTravelTimeModeChange,
|
||||
onTravelTimeRangeChange,
|
||||
aiFilterLoading,
|
||||
aiFilterError,
|
||||
onAiFilterSubmit,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
|
||||
const handleAddAndScroll = useCallback(
|
||||
(name: string) => {
|
||||
onAddFilter(name);
|
||||
requestAnimationFrame(() => {
|
||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
},
|
||||
[onAddFilter]
|
||||
);
|
||||
const enabledGroups = useMemo(
|
||||
() => groupFeaturesByCategory(enabledFeatureList),
|
||||
[enabledFeatureList]
|
||||
|
|
@ -134,15 +152,18 @@ export default memo(function Filters({
|
|||
}, [features]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
</button>
|
||||
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div className="shrink-0 border-b border-warm-200 dark:border-navy-700">
|
||||
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} onSubmit={onAiFilterSubmit} />
|
||||
<div className="flex items-center gap-2 px-3 pb-2">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
|
|
@ -176,12 +197,9 @@ export default memo(function Filters({
|
|||
)}
|
||||
|
||||
{enabledFeatureList.length === 0 && !travelTimeEnabled && (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No active filters"
|
||||
description="Browse features below and click + to add a filter"
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
Browse features below and click + to add a filter
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enabledGroups.map((group) => {
|
||||
|
|
@ -304,7 +322,7 @@ export default memo(function Filters({
|
|||
availableFeatures={availableFeatures}
|
||||
allFeatures={features}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onAddFilter={onAddFilter}
|
||||
onAddFilter={handleAddAndScroll}
|
||||
onTogglePin={onTogglePin}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
|
|
|
|||
|
|
@ -15,26 +15,26 @@ import { usePOIData } from '../../hooks/usePOIData';
|
|||
import { useFilters } from '../../hooks/useFilters';
|
||||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||
import { useAiFilters } from '../../hooks/useAiFilters';
|
||||
import { useAreaSummary } from '../../hooks/useAreaSummary';
|
||||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import { useTravelTime, type TravelTimeInitial } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, buildFilterString } from '../../lib/api';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
|
||||
export interface ExportState {
|
||||
onExport: () => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
type MobileBottomTab = 'filters' | 'pois' | 'area';
|
||||
|
||||
interface MapPageProps {
|
||||
features: FeatureMeta[];
|
||||
poiCategoryGroups: POICategoryGroup[];
|
||||
initialFilters: FeatureFilters;
|
||||
initialViewState: ViewState;
|
||||
initialPOICategories: Set<string>;
|
||||
initialTab: 'pois' | 'properties' | 'area';
|
||||
initialTab: 'properties' | 'area';
|
||||
initialLoading: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
pendingInfoFeature: string | null;
|
||||
|
|
@ -73,9 +73,11 @@ export default function MapPage({
|
|||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
|
||||
|
||||
// Mobile state
|
||||
const [mobileBottomTab, setMobileBottomTab] = useState<MobileBottomTab>('filters');
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
|
||||
// POI floating panel state
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
|
||||
// Initialize filters first
|
||||
const {
|
||||
filters,
|
||||
|
|
@ -90,6 +92,7 @@ export default function MapPage({
|
|||
handleAddFilter,
|
||||
handleFilterChange,
|
||||
handleRemoveFilter,
|
||||
handleSetFilters,
|
||||
handleDragStart,
|
||||
handleDragChange,
|
||||
handleDragEnd,
|
||||
|
|
@ -101,6 +104,16 @@ export default function MapPage({
|
|||
features,
|
||||
});
|
||||
|
||||
// AI filters hook
|
||||
const aiFilters = useAiFilters();
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
const result = await aiFilters.fetchAiFilters(query);
|
||||
if (result) handleSetFilters(result);
|
||||
},
|
||||
[aiFilters.fetchAiFilters, handleSetFilters]
|
||||
);
|
||||
|
||||
// Travel time hook
|
||||
const travelTime = useTravelTime(initialTravelTime);
|
||||
|
||||
|
|
@ -161,7 +174,6 @@ export default function MapPage({
|
|||
handleHexagonClick(id, isPostcode);
|
||||
if (id) {
|
||||
setMobileDrawerOpen(true);
|
||||
setMobileBottomTab('area');
|
||||
}
|
||||
},
|
||||
[handleHexagonClick]
|
||||
|
|
@ -289,6 +301,10 @@ export default function MapPage({
|
|||
screenshotMode
|
||||
ogMode={ogMode}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEnabled={travelTime.enabled}
|
||||
travelTimeDestination={travelTime.destination}
|
||||
travelTimeColorRange={mapData.travelTimeColorRange}
|
||||
travelTimeRange={travelTime.timeRange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -368,6 +384,9 @@ export default function MapPage({
|
|||
onTravelTimeSetDestination={travelTime.handleSetDestination}
|
||||
onTravelTimeModeChange={travelTime.handleModeChange}
|
||||
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
||||
aiFilterLoading={aiFilters.loading}
|
||||
aiFilterError={aiFilters.error}
|
||||
onAiFilterSubmit={handleAiFilterSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -421,6 +440,19 @@ export default function MapPage({
|
|||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{/* Floating POI button */}
|
||||
<button
|
||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Floating POI panel */}
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
|
||||
{renderPOIPane()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom panel — 55% */}
|
||||
|
|
@ -466,27 +498,9 @@ export default function MapPage({
|
|||
inline
|
||||
/>
|
||||
)}
|
||||
{/* Tab bar */}
|
||||
<div className="flex shrink-0 border-b border-warm-200 dark:border-warm-700 text-sm">
|
||||
<TabButton
|
||||
label="Filters"
|
||||
isActive={mobileBottomTab === 'filters'}
|
||||
onClick={() => setMobileBottomTab('filters')}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
isActive={mobileBottomTab === 'pois'}
|
||||
onClick={() => setMobileBottomTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Filters content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{mobileBottomTab === 'pois' ? (
|
||||
<div className="h-full overflow-y-auto">{renderPOIPane()}</div>
|
||||
) : (
|
||||
renderFilters()
|
||||
)}
|
||||
{renderFilters()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -496,7 +510,6 @@ export default function MapPage({
|
|||
onClose={() => setMobileDrawerOpen(false)}
|
||||
renderArea={renderAreaPane}
|
||||
renderProperties={renderPropertiesPane}
|
||||
renderPOIs={renderPOIPane}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -565,6 +578,19 @@ export default function MapPage({
|
|||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{/* Floating POI button */}
|
||||
<button
|
||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Floating POI panel */}
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-14 right-4 z-10 w-80 max-h-[60vh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
|
||||
{renderPOIPane()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Pane */}
|
||||
|
|
@ -590,19 +616,12 @@ export default function MapPage({
|
|||
isActive={selection.rightPaneTab === 'properties'}
|
||||
onClick={selection.handlePropertiesTabClick}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
isActive={selection.rightPaneTab === 'pois'}
|
||||
onClick={() => selection.setRightPaneTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selection.rightPaneTab === 'area'
|
||||
? renderAreaPane()
|
||||
: selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderPOIPane()}
|
||||
{selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderAreaPane()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,20 +2,18 @@ import { useState, useEffect } from 'react';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
||||
type DrawerTab = 'area' | 'properties' | 'pois';
|
||||
type DrawerTab = 'area' | 'properties';
|
||||
|
||||
interface MobileDrawerProps {
|
||||
onClose: () => void;
|
||||
renderArea: () => React.ReactNode;
|
||||
renderProperties: () => React.ReactNode;
|
||||
renderPOIs: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function MobileDrawer({
|
||||
onClose,
|
||||
renderArea,
|
||||
renderProperties,
|
||||
renderPOIs,
|
||||
}: MobileDrawerProps) {
|
||||
const [tab, setTab] = useState<DrawerTab>('area');
|
||||
|
||||
|
|
@ -43,7 +41,6 @@ export default function MobileDrawer({
|
|||
isActive={tab === 'properties'}
|
||||
onClick={() => setTab('properties')}
|
||||
/>
|
||||
<TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto flex items-center justify-center w-10 h-10 rounded-lg hover:bg-warm-100 dark:hover:bg-navy-800"
|
||||
|
|
@ -55,7 +52,7 @@ export default function MobileDrawer({
|
|||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tab === 'area' ? renderArea() : tab === 'properties' ? renderProperties() : renderPOIs()}
|
||||
{tab === 'area' ? renderArea() : renderProperties()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,12 +80,31 @@ export default function POIPane({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 pt-4 pb-2 space-y-3">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
POIs
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
{selectedCount}/{allCategories.length}
|
||||
</span>
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
|
|
@ -118,34 +137,6 @@ export default function POIPane({
|
|||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{selectedCount}/{allCategories.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<div className="px-3 py-2 bg-teal-50 dark:bg-teal-900/30 rounded text-sm flex items-center justify-between">
|
||||
<span className="font-medium text-teal-900 dark:text-teal-300">
|
||||
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||
|
|
|
|||
|
|
@ -1,14 +1,36 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { PostcodeGeometry } from '../../types';
|
||||
import { authHeaders } from '../../lib/api';
|
||||
import type { PostcodeGeometry, PlaceResult } from '../../types';
|
||||
import { authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { SearchIcon } from '../ui/icons/SearchIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
|
||||
export interface SearchedPostcode {
|
||||
postcode: string;
|
||||
geometry: PostcodeGeometry;
|
||||
}
|
||||
|
||||
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
|
||||
function looksLikePostcode(s: string) {
|
||||
return POSTCODE_RE.test(s.trim());
|
||||
}
|
||||
|
||||
type SearchResult =
|
||||
| { type: 'postcode'; label: string }
|
||||
| { type: 'place'; name: string; place_type: string; lat: number; lon: number };
|
||||
|
||||
const ZOOM_FOR_TYPE: Record<string, number> = {
|
||||
city: 10,
|
||||
borough: 12,
|
||||
town: 13,
|
||||
suburb: 14,
|
||||
neighbourhood: 14,
|
||||
village: 14,
|
||||
locality: 14,
|
||||
hamlet: 15,
|
||||
isolated_dwelling: 16,
|
||||
};
|
||||
|
||||
export default function PostcodeSearch({
|
||||
onFlyTo,
|
||||
onPostcodeSearched,
|
||||
|
|
@ -17,24 +39,29 @@ export default function PostcodeSearch({
|
|||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Close on outside click (mobile only)
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!isMobile || !expanded) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||||
setExpanded(false);
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
if (isMobile) setExpanded(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isMobile, expanded]);
|
||||
}, [isMobile]);
|
||||
|
||||
// Focus input when expanding on mobile
|
||||
useEffect(() => {
|
||||
|
|
@ -43,16 +70,16 @@ export default function PostcodeSearch({
|
|||
}
|
||||
}, [isMobile, expanded]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const selectPostcode = useCallback(
|
||||
async (postcode: string) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setOpen(false);
|
||||
try {
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`, authHeaders());
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(postcode.trim())}`,
|
||||
authHeaders()
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
return;
|
||||
|
|
@ -66,6 +93,7 @@ export default function PostcodeSearch({
|
|||
onFlyTo(json.latitude, json.longitude, 16);
|
||||
onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry });
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
setError('Lookup failed');
|
||||
|
|
@ -73,9 +101,115 @@ export default function PostcodeSearch({
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[query, onFlyTo, onPostcodeSearched, isMobile]
|
||||
[onFlyTo, onPostcodeSearched, isMobile]
|
||||
);
|
||||
|
||||
const selectPlace = useCallback(
|
||||
(place: { name: string; place_type: string; lat: number; lon: number }) => {
|
||||
const zoom = ZOOM_FOR_TYPE[place.place_type] ?? 14;
|
||||
onFlyTo(place.lat, place.lon, zoom);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
if (isMobile) setExpanded(false);
|
||||
},
|
||||
[onFlyTo, isMobile]
|
||||
);
|
||||
|
||||
const selectResult = useCallback(
|
||||
(result: SearchResult) => {
|
||||
if (result.type === 'postcode') {
|
||||
selectPostcode(result.label);
|
||||
} else {
|
||||
selectPlace(result);
|
||||
}
|
||||
},
|
||||
[selectPostcode, selectPlace]
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setQuery(value);
|
||||
setError(null);
|
||||
setActiveIndex(-1);
|
||||
|
||||
// Cancel in-flight request
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (looksLikePostcode(trimmed)) {
|
||||
setResults([{ type: 'postcode', label: trimmed.toUpperCase() }]);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounced place search
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmed, limit: '7' });
|
||||
const res = await fetch(
|
||||
`/api/places?${params}`,
|
||||
authHeaders({ signal: controller.signal })
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const json: { places: PlaceResult[] } = await res.json();
|
||||
const placeResults: SearchResult[] = json.places.map((p) => ({
|
||||
type: 'place' as const,
|
||||
...p,
|
||||
}));
|
||||
setResults(placeResults);
|
||||
setOpen(placeResults.length > 0);
|
||||
} catch (err) {
|
||||
logNonAbortError('places search', err);
|
||||
}
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < results.length) {
|
||||
selectResult(results[activeIndex]);
|
||||
} else if (looksLikePostcode(query)) {
|
||||
selectPostcode(query);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[results, activeIndex, query, selectResult, selectPostcode]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mobile collapsed state: just a search icon button
|
||||
if (isMobile && !expanded) {
|
||||
return (
|
||||
|
|
@ -83,7 +217,7 @@ export default function PostcodeSearch({
|
|||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="absolute top-3 left-3 z-10 p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
|
||||
aria-label="Search postcode"
|
||||
aria-label="Search places or postcodes"
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
|
||||
</button>
|
||||
|
|
@ -91,36 +225,76 @@ export default function PostcodeSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
|
||||
>
|
||||
<div className="flex shadow-lg rounded overflow-hidden">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Search postcode..."
|
||||
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-white dark:placeholder-warm-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Go'}
|
||||
</button>
|
||||
<div ref={containerRef} className="absolute top-3 left-3 z-10 flex flex-col">
|
||||
<div className="relative">
|
||||
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
|
||||
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setOpen(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search places or postcodes..."
|
||||
className="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="mr-3 w-4 h-4 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 max-h-64 overflow-y-auto">
|
||||
{results.map((result, idx) => (
|
||||
<button
|
||||
key={
|
||||
result.type === 'postcode'
|
||||
? `pc-${result.label}`
|
||||
: `pl-${result.name}-${result.lat}`
|
||||
}
|
||||
type="button"
|
||||
className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm cursor-pointer ${
|
||||
idx === activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
selectResult(result);
|
||||
}}
|
||||
>
|
||||
{result.type === 'postcode' ? (
|
||||
<>
|
||||
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
|
||||
<span className="text-warm-400 dark:text-warm-500 text-xs ml-auto">
|
||||
postcode
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.name}</span>
|
||||
<span className="text-warm-400 dark:text-warm-500 text-xs ml-auto">
|
||||
{result.place_type}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
|
||||
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-warm-800/90 rounded px-2 py-0.5 shadow mt-1">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,23 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Renovations</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.renovation_history.map((reno, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{reno.event}
|
||||
<span className="text-warm-500 dark:text-warm-400">{reno.year}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import { authHeaders } from '../../lib/api';
|
|||
import type { TransportMode } from '../../hooks/useTravelTime';
|
||||
|
||||
const MODES: { value: TransportMode; label: string }[] = [
|
||||
{ value: 'transit', label: 'Transit' },
|
||||
{ value: 'car', label: 'Car' },
|
||||
{ value: 'bicycle', label: 'Bicycle' },
|
||||
{ value: 'walking', label: 'Walking' },
|
||||
{ value: 'transit', label: 'Transit' },
|
||||
];
|
||||
|
||||
interface TravelTimeCardProps {
|
||||
|
|
|
|||
55
frontend/src/hooks/useAiFilters.ts
Normal file
55
frontend/src/hooks/useAiFilters.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { FeatureFilters } from '../types';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||
|
||||
interface UseAiFiltersResult {
|
||||
fetchAiFilters: (query: string) => Promise<FeatureFilters | null>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useAiFilters(): UseAiFiltersResult {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchAiFilters = useCallback(async (query: string): Promise<FeatureFilters | null> => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = apiUrl('ai-filters');
|
||||
const response = await fetch(
|
||||
url,
|
||||
authHeaders({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
setLoading(false);
|
||||
return json.filters as FeatureFilters;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return null;
|
||||
logNonAbortError('ai-filters', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to generate filters';
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { fetchAiFilters, loading, error };
|
||||
}
|
||||
|
|
@ -11,12 +11,8 @@ import type {
|
|||
Bounds,
|
||||
} from '../types';
|
||||
import type { SearchedPostcode } from '../components/map/PostcodeSearch';
|
||||
import {
|
||||
emojiToTwemojiUrl,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
getFeatureFillColor,
|
||||
} from '../lib/map-utils';
|
||||
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
|
||||
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
||||
function osmIdToUrl(id: string): string | null {
|
||||
|
|
@ -242,7 +238,7 @@ export function useDeckLayers({
|
|||
}, []);
|
||||
|
||||
// --- Color triggers ---
|
||||
const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}`;
|
||||
const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}|${travelTimeDestination?.[1]}`;
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}|${ttTrigger}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,14 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}
|
||||
}, [activeFeature, dragValue]);
|
||||
|
||||
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
||||
setFilters(newFilters);
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
setDragData(null);
|
||||
setPinnedFeature(null);
|
||||
}, []);
|
||||
|
||||
const handleTogglePin = useCallback((name: string) => {
|
||||
setPinnedFeature((prev) => (prev === name ? null : name));
|
||||
}, []);
|
||||
|
|
@ -144,6 +152,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
handleAddFilter,
|
||||
handleFilterChange,
|
||||
handleRemoveFilter,
|
||||
handleSetFilters,
|
||||
handleDragStart,
|
||||
handleDragChange,
|
||||
handleDragEnd,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
|||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>('pois');
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type TransportMode = 'transit' | 'car' | 'bicycle';
|
||||
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
|
||||
|
||||
export interface TravelTimeState {
|
||||
enabled: boolean;
|
||||
|
|
@ -23,7 +23,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
initial?.destination ?? null
|
||||
);
|
||||
const [destinationLabel, setDestinationLabel] = useState(initial?.destinationLabel ?? '');
|
||||
const [mode, setMode] = useState<TransportMode>(initial?.mode ?? 'transit');
|
||||
const [mode, setMode] = useState<TransportMode>(initial?.mode ?? 'car');
|
||||
const [timeRange, setTimeRange] = useState<[number, number] | null>(
|
||||
initial?.timeRange ?? null
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function useUrlSync(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area',
|
||||
rightPaneTab: 'properties' | 'area',
|
||||
travelTime?: TravelTimeUrlState
|
||||
) {
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
|
|||
|
|
@ -10,14 +10,6 @@ import {
|
|||
BUFFER_MULTIPLIER,
|
||||
} from './consts';
|
||||
|
||||
// Re-export constants for backwards compatibility
|
||||
export {
|
||||
FEATURE_GRADIENT as GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from './consts';
|
||||
|
||||
const ROAD_OPACITY = 0.4;
|
||||
|
||||
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function parseUrlState(): {
|
|||
viewState?: ViewState;
|
||||
filters?: FeatureFilters;
|
||||
poiCategories?: Set<string>;
|
||||
tab?: 'pois' | 'properties' | 'area';
|
||||
tab?: 'properties' | 'area';
|
||||
travelTime?: TravelTimeInitial;
|
||||
} {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
|
@ -61,7 +61,7 @@ export function parseUrlState(): {
|
|||
|
||||
// Tab: full name
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'properties' || tab === 'pois' || tab === 'area') {
|
||||
if (tab === 'properties' || tab === 'area') {
|
||||
result.tab = tab;
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ export function stateToParams(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area',
|
||||
rightPaneTab: 'properties' | 'area',
|
||||
travelTime?: { enabled: boolean; destination: [number, number] | null; destinationLabel: string; mode: string; timeRange: [number, number] | null }
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -121,8 +121,6 @@ export function stateToParams(
|
|||
|
||||
if (rightPaneTab === 'properties') {
|
||||
params.set('tab', 'properties');
|
||||
} else if (rightPaneTab === 'area') {
|
||||
params.set('tab', 'area');
|
||||
}
|
||||
|
||||
if (travelTime?.enabled && travelTime.destination) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,18 @@ export interface POICategoriesResponse {
|
|||
groups: POICategoryGroup[];
|
||||
}
|
||||
|
||||
export interface PlaceResult {
|
||||
name: string;
|
||||
place_type: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface RenovationEvent {
|
||||
year: number;
|
||||
event: string;
|
||||
}
|
||||
|
||||
export interface Property {
|
||||
// String fields
|
||||
address?: string;
|
||||
|
|
@ -114,9 +126,10 @@ export interface Property {
|
|||
lon: number;
|
||||
|
||||
is_construction_date_approximate?: boolean;
|
||||
renovation_history?: RenovationEvent[];
|
||||
|
||||
// All other numeric features (dynamic, including construction_age_band)
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
[key: string]: string | number | boolean | RenovationEvent[] | undefined;
|
||||
}
|
||||
|
||||
export interface HexagonPropertiesResponse {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue