Can't even keep track anymore

This commit is contained in:
Andras Schmelczer 2026-02-13 09:16:28 +00:00
parent dccc1e439d
commit 3a3f899ea2
50 changed files with 1144 additions and 560 deletions

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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 &copy; Crown copyright and database right 2025.</li>
<li>
Contains public sector information licensed under the{' '}
<a
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:text-teal-300 hover:underline"
>
Open Government Licence v3.0
</a>
.
</li>
<li>Contains OS data &copy; Crown copyright and database rights 2025.</li>
<li>Powered by TfL Open Data.</li>
<li>
Contains data from{' '}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:text-teal-300 hover:underline"
>
&copy; OpenStreetMap contributors
</a>
, available under the{' '}
<a
href="https://opendatacommons.org/licenses/odbl/"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:text-teal-300 hover:underline"
>
Open Data Commons Open Database License (ODbL)
</a>
.
</li>
</ul>
</div>
</footer>
</div>
);
}

View file

@ -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 {

View 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>
);
});

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 {

View 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 };
}

View file

@ -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}`;

View file

@ -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,

View file

@ -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[]) => {

View file

@ -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
);

View file

@ -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);

View file

@ -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 {

View file

@ -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) {

View file

@ -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 {