This commit is contained in:
Andras Schmelczer 2026-02-14 12:53:29 +00:00
parent 3a3f899ea2
commit 128b3191e7
68 changed files with 28060 additions and 1152 deletions

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { Slider } from '../ui/Slider';
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
@ -88,7 +88,10 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
abortRef.current = new AbortController();
setFetching(true);
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
.then((res) => res.json())
.then((res) => {
assertOk(res, 'hexagons');
return res.json();
})
.then((data: { features: HexagonData[] }) => {
setHexData(data.features);
setLoading(false);
@ -142,7 +145,10 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
dragAbortRef.current?.abort();
dragAbortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((res) => {
assertOk(res, 'hexagons');
return res.json();
})
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
.catch((err) => logNonAbortError('Failed to fetch demo drag data', err));
},

View file

@ -4,10 +4,11 @@ import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
interface AiFilterInputProps {
loading: boolean;
error: string | null;
notes: string | null;
onSubmit: (query: string) => void;
}
export default memo(function AiFilterInput({ loading, error, onSubmit }: AiFilterInputProps) {
export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: AiFilterInputProps) {
const [query, setQuery] = useState('');
const handleSubmit = useCallback(
@ -48,6 +49,11 @@ export default memo(function AiFilterInput({ loading, error, onSubmit }: AiFilte
{error}
</p>
)}
{notes && !error && (
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">
{notes}
</p>
)}
</div>
);
});

View file

@ -155,10 +155,10 @@ export default function AreaPane({
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
// Features that are part of a stacked enum config (rendered as compact charts)
const stackedEnumFeatureNames = new Set(
(stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter(Boolean)
) as string[]) ?? []
const stackedEnumFeatureNames = new Set<string>(
stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
) ?? []
);
const isExpanded = !collapsedGroups.has(group.name);

View file

@ -11,6 +11,7 @@ import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { RouteIcon, PlusIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
@ -21,8 +22,8 @@ interface FeatureBrowserProps {
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEnabled?: boolean;
onEnableTravelTime?: () => void;
activeTravelModes: TransportMode[];
onEnableTravelMode: (mode: TransportMode) => void;
}
export default function FeatureBrowser({
@ -34,8 +35,8 @@ export default function FeatureBrowser({
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEnabled,
onEnableTravelTime,
activeTravelModes,
onEnableTravelMode,
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -60,32 +61,42 @@ export default function FeatureBrowser({
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;
// Inactive modes available to add
const inactiveModes = useMemo(
() => TRANSPORT_MODES.filter((m) => !activeTravelModes.includes(m)),
[activeTravelModes]
);
const showTravelModes =
inactiveModes.length > 0 &&
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
return (
<>
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{!travelTimeEnabled && onEnableTravelTime && (!search || 'travel time journey commute'.includes(search.toLowerCase())) && (
<div className="shrink-0 border-b border-warm-200 dark:border-warm-700">
{showTravelModes && inactiveModes.map((mode) => (
<div key={mode} className="shrink-0 border-b border-warm-200 dark:border-warm-700">
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
<div className="flex items-center gap-2 min-w-0" onClick={onEnableTravelTime}>
<div className="flex items-center gap-2 min-w-0" onClick={() => onEnableTravelMode(mode)}>
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time
Travel Time ({MODE_LABELS[mode]})
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Color by journey time to a destination
</span>
</div>
</div>
<IconButton onClick={() => onEnableTravelTime()} title="Add travel time">
<IconButton onClick={() => onEnableTravelMode(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
<PlusIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
</div>
)}
))}
{grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
@ -128,7 +139,7 @@ export default function FeatureBrowser({
</div>
);
})}
{grouped.length === 0 ? (
{grouped.length === 0 && !showTravelModes ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'}

View file

@ -17,18 +17,25 @@ import { FeatureLabel } from '../ui/FeatureLabel';
import AiFilterInput from './AiFilterInput';
import FeatureBrowser from './FeatureBrowser';
import { TravelTimeCard } from './TravelTimeCard';
import type { TransportMode } from '../../hooks/useTravelTime';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
} from '../../hooks/useTravelTime';
function SliderLabels({
min,
max,
value,
displayValues,
absoluteMax,
}: {
min: number;
max: number;
value: [number, number];
displayValues?: [number, number];
/** When true and slider is at max, append "+" to indicate unrestricted upper bound */
absoluteMax?: boolean;
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
@ -46,7 +53,7 @@ function SliderLabels({
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{formatFilterValue(labels[1])}
{formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''}
</span>
</div>
);
@ -70,19 +77,15 @@ interface FiltersProps {
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEnabled: boolean;
travelTimeDestination: [number, number] | null;
travelTimeDestinationLabel: string;
travelTimeMode: TransportMode;
travelTimeRange: [number, number] | null;
travelTimeDataRange: [number, number] | null;
onTravelTimeEnable: () => void;
onTravelTimeDisable: () => void;
onTravelTimeSetDestination: (lat: number, lon: number, label: string) => void;
onTravelTimeModeChange: (mode: TransportMode) => void;
onTravelTimeRangeChange: (range: [number, number]) => void;
travelTimeEntries: TravelTimeEntries;
travelTimeDataRanges: Partial<Record<TransportMode, [number, number]>>;
onTravelTimeEnableMode: (mode: TransportMode) => void;
onTravelTimeDisableMode: (mode: TransportMode) => void;
onTravelTimeSetDestination: (mode: TransportMode, lat: number, lon: number, label: string) => void;
onTravelTimeRangeChange: (mode: TransportMode, range: [number, number]) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterNotes: string | null;
onAiFilterSubmit: (query: string) => void;
}
@ -104,19 +107,15 @@ export default memo(function Filters({
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEnabled,
travelTimeDestination,
travelTimeDestinationLabel,
travelTimeMode,
travelTimeRange,
travelTimeDataRange,
onTravelTimeEnable,
onTravelTimeDisable,
travelTimeEntries,
travelTimeDataRanges,
onTravelTimeEnableMode,
onTravelTimeDisableMode,
onTravelTimeSetDestination,
onTravelTimeModeChange,
onTravelTimeRangeChange,
aiFilterLoading,
aiFilterError,
aiFilterNotes,
onAiFilterSubmit,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
@ -127,6 +126,11 @@ export default memo(function Filters({
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const activeModes = useMemo(
() => TRANSPORT_MODES.filter((m) => m in travelTimeEntries),
[travelTimeEntries]
);
const handleAddAndScroll = useCallback(
(name: string) => {
onAddFilter(name);
@ -144,17 +148,19 @@ export default memo(function Filters({
const percentileScales = useMemo(() => {
const scales = new Map<string, PercentileScale>();
for (const f of features) {
if (f.type === 'numeric' && f.histogram) {
if (f.type === 'numeric' && f.histogram && !f.absolute) {
scales.set(f.name, buildPercentileScale(f.histogram));
}
}
return scales;
}, [features]);
const badgeCount = enabledFeatureList.length + activeModes.length;
return (
<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} />
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} />
<div className="flex items-center gap-2 px-3 pb-2">
<button
onClick={() => setShowPhilosophy(true)}
@ -171,32 +177,34 @@ export default memo(function Filters({
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
Active Filters
</span>
{(enabledFeatureList.length > 0 || travelTimeEnabled) && (
{badgeCount > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{enabledFeatureList.length + (travelTimeEnabled ? 1 : 0)}
{badgeCount}
</span>
)}
</div>
</div>
<div className="md:flex-1 md:overflow-y-auto">
{travelTimeEnabled && (
<div className="px-2 py-1">
<TravelTimeCard
destination={travelTimeDestination}
destinationLabel={travelTimeDestinationLabel}
mode={travelTimeMode}
timeRange={travelTimeRange}
dataRange={travelTimeDataRange}
onSetDestination={onTravelTimeSetDestination}
onModeChange={onTravelTimeModeChange}
onTimeRangeChange={onTravelTimeRangeChange}
onRemove={onTravelTimeDisable}
/>
</div>
)}
{activeModes.map((mode) => {
const entry = travelTimeEntries[mode]!;
return (
<div key={mode} className="px-2 py-1">
<TravelTimeCard
mode={mode}
destination={entry.destination}
destinationLabel={entry.destinationLabel}
timeRange={entry.timeRange}
dataRange={travelTimeDataRanges[mode] ?? null}
onSetDestination={(lat, lon, label) => onTravelTimeSetDestination(mode, lat, lon, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(mode, range)}
onRemove={() => onTravelTimeDisableMode(mode)}
/>
</div>
);
})}
{enabledFeatureList.length === 0 && !travelTimeEnabled && (
{enabledFeatureList.length === 0 && activeModes.length === 0 && (
<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>
@ -300,6 +308,7 @@ export default memo(function Filters({
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={scale ? displayValue : undefined}
absoluteMax={feature.absolute}
/>
</div>
</div>
@ -327,8 +336,8 @@ export default memo(function Filters({
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEnabled={travelTimeEnabled}
onEnableTravelTime={onTravelTimeEnable}
activeTravelModes={activeModes}
onEnableTravelMode={onTravelTimeEnableMode}
/>
</div>
</div>

View file

@ -0,0 +1,143 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { SearchIcon } from '../ui/icons/SearchIcon';
export interface SearchedLocation {
postcode: string;
geometry: PostcodeGeometry;
}
const ZOOM_FOR_TYPE: Record<string, number> = {
city: 10,
borough: 12,
town: 13,
suburb: 14,
quarter: 14,
neighbourhood: 14,
village: 14,
station: 15,
island: 12,
locality: 14,
hamlet: 15,
isolated_dwelling: 16,
};
export default function LocationSearch({
onFlyTo,
onLocationSearched,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onLocationSearched?: (postcode: SearchedLocation | null) => void;
}) {
const search = useLocationSearch();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
search.close();
if (isMobile) setExpanded(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isMobile, search]);
// Focus input when expanding on mobile
useEffect(() => {
if (isMobile && expanded) {
inputRef.current?.focus();
}
}, [isMobile, expanded]);
const selectResult = useCallback(
async (result: SearchResult) => {
if (result.type === 'place') {
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
onFlyTo(result.lat, result.lon, zoom);
onLocationSearched?.(null);
search.clear();
if (isMobile) setExpanded(false);
return;
}
// Postcode — fetch geometry
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders(),
);
if (!res.ok) {
setError('Postcode not found');
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
search.clear();
if (isMobile) setExpanded(false);
} catch {
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[onFlyTo, onLocationSearched, isMobile, search],
);
// Mobile collapsed state: just a search icon button
if (isMobile && !expanded) {
return (
<button
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 places or postcodes"
>
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
</button>
);
}
return (
<div ref={containerRef} className="absolute top-3 left-3 z-10 flex flex-col">
<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" />
<PlaceSearchInput
search={search}
onSelect={selectResult}
loading={loading}
placeholder="Search places or postcodes..."
size="sm"
inputClassName="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"
inputRef={inputRef}
onInputChange={() => setError(null)}
/>
</div>
{error && (
<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>
)}
</div>
);
}

View file

@ -6,6 +6,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import type {
HexagonData,
PostcodeFeature,
PostcodeGeometry,
ViewState,
ViewChangeParams,
POI,
@ -15,11 +16,12 @@ import type {
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import type { FeatureFilters } from '../../types';
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
import { MODE_LABELS, type TransportMode, type TravelTimeEntries } from '../../hooks/useTravelTime';
interface MapProps {
data: HexagonData[];
@ -42,14 +44,12 @@ interface MapProps {
screenshotMode?: boolean;
ogMode?: boolean;
filters?: FeatureFilters;
searchedPostcode?: SearchedPostcode | null;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
selectedPostcodeGeometry?: PostcodeGeometry | null;
onLocationSearched?: (location: SearchedLocation | null) => void;
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEnabled?: boolean;
travelTimeDestination?: [number, number] | null;
travelTimeColorRange?: [number, number] | null;
travelTimeRange?: [number, number] | null;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
}
interface Dimensions {
@ -98,14 +98,12 @@ export default memo(function Map({
screenshotMode = false,
ogMode = false,
filters = {},
searchedPostcode,
onPostcodeSearched,
selectedPostcodeGeometry,
onLocationSearched,
bounds: viewportBounds,
hideLegend = false,
travelTimeEnabled = false,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
travelTimeEntries = {},
travelTimeColorRanges = {},
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
@ -168,6 +166,7 @@ export default memo(function Map({
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
primaryTravelMode,
} = useDeckLayers({
data,
postcodeData,
@ -182,12 +181,10 @@ export default memo(function Map({
onHexagonClick,
onHexagonHover,
theme,
searchedPostcode,
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEnabled,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
travelTimeEntries,
travelTimeColorRanges,
});
return (
@ -222,12 +219,12 @@ export default memo(function Map({
) : null
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
{!hideLegend &&
(travelTimeEnabled && travelTimeDestination && travelTimeColorRange ? (
(primaryTravelMode && travelTimeColorRanges[primaryTravelMode] ? (
<MapLegend
featureLabel="Travel time"
range={travelTimeColorRange}
featureLabel={`Travel time (${MODE_LABELS[primaryTravelMode]})`}
range={travelTimeColorRanges[primaryTravelMode]!}
showCancel={false}
onCancel={onCancelPin}
mode="feature"

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
import type { SearchedPostcode } from './PostcodeSearch';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
import Filters from './Filters';
@ -18,8 +18,14 @@ 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 {
useTravelTime,
TRANSPORT_MODES,
MODE_LABELS,
type TransportMode,
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, assertOk, buildFilterString, logNonAbortError } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
@ -65,7 +71,6 @@ export default function MapPage({
isMobile = false,
initialTravelTime,
}: MapPageProps) {
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
@ -109,7 +114,7 @@ export default function MapPage({
const handleAiFilterSubmit = useCallback(
async (query: string) => {
const result = await aiFilters.fetchAiFilters(query);
if (result) handleSetFilters(result);
if (result) handleSetFilters(result.filters);
},
[aiFilters.fetchAiFilters, handleSetFilters]
);
@ -125,9 +130,7 @@ export default function MapPage({
activeFeature,
dragValue,
dragData,
travelTimeEnabled: travelTime.enabled,
travelTimeDestination: travelTime.destination,
travelTimeMode: travelTime.mode,
travelTimeEntries: travelTime.entries,
});
// Keep filter bounds in sync with map data
@ -142,24 +145,42 @@ export default function MapPage({
resolution: mapData.resolution,
});
// Location search handler — selects postcode + shows stats
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
selection.handleLocationSearch(result.postcode, result.geometry);
if (isMobile) setMobileDrawerOpen(true);
} else {
selection.handleCloseSelection();
}
},
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
);
// POI data
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Compute data range for travel time slider
const travelTimeDataRange = useMemo((): [number, number] | null => {
if (!travelTime.enabled || !travelTime.destination) return null;
const vals: number[] = [];
for (const item of mapData.data) {
const val = item.travel_time;
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
// Compute data range for travel time slider per mode (full min/max for slider bounds)
const travelTimeDataRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
for (const mode of TRANSPORT_MODES) {
const entry = travelTime.entries[mode];
if (!entry?.destination) continue;
const vals: number[] = [];
for (const item of mapData.data) {
const val = item[`travel_time_${mode}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges[mode] = [vals[0], vals[vals.length - 1]];
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [vals[0], vals[vals.length - 1]];
}, [travelTime.enabled, travelTime.destination, mapData.data]);
return ranges;
}, [travelTime.entries, mapData.data]);
// Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
// Set initial view and tab from URL state
useEffect(() => {
@ -238,7 +259,7 @@ export default function MapPage({
link.click();
URL.revokeObjectURL(link.href);
})
.catch((err) => console.error('Export failed:', err))
.catch((err) => logNonAbortError('Export failed', err))
.finally(() => setExporting(false));
}, [mapData.bounds, filters, features, exporting]);
@ -258,10 +279,7 @@ export default function MapPage({
let min = Infinity;
let max = -Infinity;
for (const d of items) {
const c =
'count' in d
? (d as { count: number }).count
: (d as { properties: { count: number } }).properties.count;
const c = 'count' in d ? d.count : d.properties.count;
if (c < min) min = c;
if (c > max) max = c;
}
@ -301,10 +319,8 @@ export default function MapPage({
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeColorRange={mapData.travelTimeColorRange}
travelTimeRange={travelTime.timeRange}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
</div>
);
@ -373,19 +389,15 @@ export default function MapPage({
onCancelPin={handleCancelPin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeDestinationLabel={travelTime.destinationLabel}
travelTimeMode={travelTime.mode}
travelTimeRange={travelTime.timeRange}
travelTimeDataRange={travelTimeDataRange}
onTravelTimeEnable={travelTime.handleEnable}
onTravelTimeDisable={travelTime.handleDisable}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeEnableMode={travelTime.handleEnableMode}
onTravelTimeDisableMode={travelTime.handleDisableMode}
onTravelTimeSetDestination={travelTime.handleSetDestination}
onTravelTimeModeChange={travelTime.handleModeChange}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
aiFilterNotes={aiFilters.notes}
onAiFilterSubmit={handleAiFilterSubmit}
/>
);
@ -426,14 +438,12 @@ export default function MapPage({
initialViewState={initialViewState}
theme={theme}
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
hideLegend
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeColorRange={mapData.travelTimeColorRange}
travelTimeRange={travelTime.timeRange}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
{mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
@ -461,43 +471,54 @@ export default function MapPage({
style={{ flex: '55 0 0' }}
>
{/* Legend */}
{travelTime.enabled && travelTime.destination && mapData.travelTimeColorRange ? (
<MapLegend
featureLabel="Travel time"
range={mapData.travelTimeColorRange}
showCancel={false}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
) : viewFeature && mapData.colorRange && mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
/>
) : (
<MapLegend
featureLabel="Property density"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
{(() => {
const primaryMode = TRANSPORT_MODES.find(
(m) => travelTime.entries[m]?.destination && mapData.travelTimeColorRanges[m]
);
if (primaryMode) {
return (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[primaryMode]})`}
range={mapData.travelTimeColorRanges[primaryMode]!}
showCancel={false}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
);
}
if (viewFeature && mapData.colorRange && mobileLegendMeta) {
return (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
/>
);
}
return (
<MapLegend
featureLabel="Property density"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
);
})()}
{/* Filters content */}
<div className="flex-1 min-h-0">
{renderFilters()}
@ -565,13 +586,11 @@ export default function MapPage({
initialViewState={initialViewState}
theme={theme}
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeColorRange={mapData.travelTimeColorRange}
travelTimeRange={travelTime.timeRange}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
{mapData.loading && (
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">

View file

@ -1,300 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
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,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
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 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
useEffect(() => {
const handler = (e: MouseEvent) => {
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]);
// Focus input when expanding on mobile
useEffect(() => {
if (isMobile && expanded) {
inputRef.current?.focus();
}
}, [isMobile, expanded]);
const selectPostcode = useCallback(
async (postcode: string) => {
setError(null);
setLoading(true);
setOpen(false);
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(postcode.trim())}`,
authHeaders()
);
if (!res.ok) {
setError('Postcode not found');
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry });
setQuery('');
setResults([]);
if (isMobile) setExpanded(false);
} catch {
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[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 (
<button
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 places or postcodes"
>
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
</button>
);
}
return (
<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-warm-800/90 rounded px-2 py-0.5 shadow mt-1">
{error}
</span>
)}
</div>
);
}

View file

@ -221,7 +221,7 @@ function PropertyCard({ property }: { property: Property }) {
{age !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built:</span>{' '}
{formatAge(age, property.is_construction_date_approximate ?? true)}
{formatAge(age, property.is_construction_date_approximate)}
</div>
)}
{property.current_energy_rating && (

View file

@ -1,10 +1,52 @@
import { useEffect, useState } from 'react';
import type { HexagonLocation } from '../../lib/external-search';
import { apiUrl, logNonAbortError } from '../../lib/api';
interface StreetViewEmbedProps {
location: HexagonLocation;
}
type Status = 'loading' | 'ok' | 'none' | 'error';
export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
const [status, setStatus] = useState<Status>('loading');
const [panoId, setPanoId] = useState<string | null>(null);
useEffect(() => {
setStatus('loading');
setPanoId(null);
const controller = new AbortController();
const params = new URLSearchParams({
lat: String(location.lat),
lon: String(location.lon),
});
fetch(apiUrl('streetview', params), { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data: { status: string; pano_id?: string }) => {
if (data.status === 'OK' && data.pano_id) {
setPanoId(data.pano_id);
setStatus('ok');
} else {
setStatus('none');
}
})
.catch((err) => {
logNonAbortError('streetview', err);
if (!controller.signal.aborted) {
setStatus('error');
}
});
return () => controller.abort();
}, [location.lat, location.lon]);
if (status === 'none' || status === 'error') return null;
return (
<div>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
@ -12,13 +54,20 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
</div>
<div className="px-3 py-2">
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
<iframe
className="w-full"
style={{ height: 240, border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={`https://maps.google.com/maps?layer=c&cbll=${location.lat},${location.lon}&cbp=11,0,0,0,0&output=svembed`}
/>
{status === 'loading' ? (
<div
className="w-full animate-pulse bg-warm-200 dark:bg-warm-700"
style={{ height: 240 }}
/>
) : (
<iframe
className="w-full"
style={{ height: 240, border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={`https://maps.google.com/maps?layer=c&panoid=${panoId}&cbp=11,0,0,0,0&output=svembed`}
/>
)}
</div>
</div>
</div>

View file

@ -1,61 +1,69 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { Slider } from '../ui/Slider';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import { IconButton } from '../ui/IconButton';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { formatFilterValue } from '../../lib/format';
import { authHeaders } from '../../lib/api';
import type { TransportMode } from '../../hooks/useTravelTime';
const MODES: { value: TransportMode; label: string }[] = [
{ value: 'car', label: 'Car' },
{ value: 'bicycle', label: 'Bicycle' },
{ value: 'walking', label: 'Walking' },
{ value: 'transit', label: 'Transit' },
];
import { authHeaders, logNonAbortError } from '../../lib/api';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
interface TravelTimeCardProps {
mode: TransportMode;
destination: [number, number] | null;
destinationLabel: string;
mode: TransportMode;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
onSetDestination: (lat: number, lon: number, label: string) => void;
onModeChange: (mode: TransportMode) => void;
onTimeRangeChange: (range: [number, number]) => void;
onRemove: () => void;
}
export function TravelTimeCard({
mode,
destination,
destinationLabel,
mode,
timeRange,
dataRange,
onSetDestination,
onModeChange,
onTimeRangeChange,
onRemove,
}: TravelTimeCardProps) {
const [query, setQuery] = useState('');
const search = useLocationSearch();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handleSearch = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
search.close();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [search]);
const selectResult = useCallback(
async (result: SearchResult) => {
if (result.type === 'place') {
onSetDestination(result.lat, result.lon, result.name);
search.clear();
setError(null);
return;
}
// Postcode — fetch coordinates
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(trimmed)}`,
authHeaders()
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders(),
);
if (!res.ok) {
setError('Postcode not found');
@ -64,14 +72,15 @@ export function TravelTimeCard({
const json: { postcode: string; latitude: number; longitude: number } =
await res.json();
onSetDestination(json.latitude, json.longitude, json.postcode);
setQuery('');
} catch {
search.clear();
} catch (err) {
logNonAbortError('Postcode lookup failed', err);
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[query, onSetDestination]
[onSetDestination, search],
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
@ -85,7 +94,7 @@ export function TravelTimeCard({
<div className="flex items-center gap-1.5">
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time
Travel Time ({MODE_LABELS[mode]})
</span>
</div>
<IconButton onClick={() => onRemove()} title="Remove travel time">
@ -94,26 +103,17 @@ export function TravelTimeCard({
</div>
{/* Destination search */}
<div>
<form onSubmit={handleSearch} className="flex gap-1">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
placeholder={destination ? 'Change destination...' : 'Enter postcode...'}
className="flex-1 min-w-0 px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="px-2 py-1 text-xs rounded bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50"
>
{loading ? '...' : 'Go'}
</button>
</form>
<div ref={containerRef} className="relative">
<PlaceSearchInput
search={search}
onSelect={selectResult}
loading={loading}
placeholder={destination ? 'Change destination...' : 'Search destination...'}
size="xs"
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
onInputChange={() => setError(null)}
/>
{error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</p>
)}
@ -127,24 +127,6 @@ export function TravelTimeCard({
)}
</div>
{/* Mode selector */}
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Mode
</span>
<PillGroup className="mt-0.5">
{MODES.map((m) => (
<PillToggle
key={m.value}
label={m.label}
active={mode === m.value}
onClick={() => onModeChange(m.value)}
size="xs"
/>
))}
</PillGroup>
</div>
{/* Time range slider — only show when we have data */}
{destination && dataRange && (
<div>

View file

@ -0,0 +1,123 @@
import type React from 'react';
import type { SearchResult } from '../../hooks/useLocationSearch';
import { SearchIcon } from './icons/SearchIcon';
import { MapPinIcon } from './icons/MapPinIcon';
interface SearchHook {
query: string;
results: SearchResult[];
activeIndex: number;
setActiveIndex: (idx: number) => void;
open: boolean;
setOpen: (open: boolean) => void;
handleInputChange: (value: string) => void;
handleKeyDown: (
e: React.KeyboardEvent,
onSelect: (result: SearchResult) => void,
) => void;
}
interface PlaceSearchInputProps {
search: SearchHook;
onSelect: (result: SearchResult) => void;
loading?: boolean;
placeholder?: string;
size?: 'sm' | 'xs';
inputClassName?: string;
inputRef?: React.Ref<HTMLInputElement>;
onInputChange?: () => void;
}
export function PlaceSearchInput({
search,
onSelect,
loading,
placeholder,
size = 'sm',
inputClassName,
inputRef,
onInputChange,
}: PlaceSearchInputProps) {
const sm = size === 'sm';
const iconSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const spinnerSize = sm ? 'w-4 h-4' : 'w-3 h-3';
return (
<div className="relative flex-1 min-w-0">
<input
ref={inputRef}
type="text"
value={search.query}
onChange={(e) => {
search.handleInputChange(e.target.value);
onInputChange?.();
}}
onFocus={() => {
if (search.results.length > 0) search.setOpen(true);
}}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
placeholder={placeholder}
className={inputClassName}
/>
{loading && (
<div
className={`absolute right-2 top-1/2 -translate-y-1/2 ${spinnerSize} border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin`}
/>
)}
{search.open && search.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 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto z-20`}
>
{search.results.map((result, idx) => (
<button
key={
result.type === 'postcode'
? `pc-${result.label}`
: `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
} ${
idx === search.activeIndex
? 'bg-teal-50 dark:bg-teal-900/30'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => search.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(result);
}}
>
{result.type === 'postcode' ? (
<>
<SearchIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</>
) : (
<>
<MapPinIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<span className="text-warm-700 dark:text-warm-200">
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
({result.city})
</span>
)}
</span>
</>
)}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -2,24 +2,32 @@ import { useState, useCallback, useRef } from 'react';
import type { FeatureFilters } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
interface AiFiltersResult {
filters: FeatureFilters;
notes: string;
}
interface UseAiFiltersResult {
fetchAiFilters: (query: string) => Promise<FeatureFilters | null>;
fetchAiFilters: (query: string) => Promise<AiFiltersResult | null>;
loading: boolean;
error: string | null;
notes: string | null;
}
export function useAiFilters(): UseAiFiltersResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notes, setNotes] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchAiFilters = useCallback(async (query: string): Promise<FeatureFilters | null> => {
const fetchAiFilters = useCallback(async (query: string): Promise<AiFiltersResult | null> => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
setNotes(null);
try {
const url = apiUrl('ai-filters');
@ -39,8 +47,13 @@ export function useAiFilters(): UseAiFiltersResult {
}
const json = await response.json();
const result: AiFiltersResult = {
filters: json.filters as FeatureFilters,
notes: json.notes || '',
};
setNotes(result.notes || null);
setLoading(false);
return json.filters as FeatureFilters;
return result;
} catch (err) {
if (controller.signal.aborted) return null;
logNonAbortError('ai-filters', err);
@ -51,5 +64,5 @@ export function useAiFilters(): UseAiFiltersResult {
}
}, []);
return { fetchAiFilters, loading, error };
return { fetchAiFilters, loading, error, notes };
}

View file

@ -7,13 +7,14 @@ export interface AuthUser {
verified: boolean;
}
// PocketBase RecordModel stores user fields as dynamic properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function recordToUser(record: any): AuthUser {
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
if (typeof record.email !== 'string') {
throw new Error('PocketBase record missing email field');
}
return {
id: record.id || '',
email: record.email || '',
verified: record.verified || false,
id: record.id,
email: record.email,
verified: typeof record.verified === 'boolean' ? record.verified : false,
};
}

View file

@ -6,13 +6,18 @@ import type {
HexagonData,
PostcodeFeature,
PostcodeProperties,
PostcodeGeometry,
POI,
FeatureMeta,
Bounds,
} from '../types';
import type { SearchedPostcode } from '../components/map/PostcodeSearch';
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
} from './useTravelTime';
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
function osmIdToUrl(id: string): string | null {
@ -38,12 +43,10 @@ interface UseDeckLayersProps {
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
theme: 'light' | 'dark';
searchedPostcode?: SearchedPostcode | null;
selectedPostcodeGeometry?: PostcodeGeometry | null;
bounds?: Bounds | null;
travelTimeEnabled?: boolean;
travelTimeDestination?: [number, number] | null;
travelTimeColorRange?: [number, number] | null;
travelTimeRange?: [number, number] | null;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
}
export interface PopupInfo {
@ -54,6 +57,17 @@ export interface PopupInfo {
id: string;
}
/** Find the primary travel mode: first mode (in canonical order) with a destination and color range. */
function getPrimaryTravelMode(
entries: TravelTimeEntries,
colorRanges: Partial<Record<TransportMode, [number, number]>>
): TransportMode | null {
for (const mode of TRANSPORT_MODES) {
if (entries[mode]?.destination && colorRanges[mode]) return mode;
}
return null;
}
export function useDeckLayers({
data,
postcodeData,
@ -68,12 +82,10 @@ export function useDeckLayers({
onHexagonClick,
onHexagonHover,
theme,
searchedPostcode,
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEnabled = false,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
travelTimeEntries = {},
travelTimeColorRanges = {},
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -103,14 +115,17 @@ export function useDeckLayers({
const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode;
const travelTimeEnabledRef = useRef(travelTimeEnabled);
travelTimeEnabledRef.current = travelTimeEnabled;
const travelTimeDestinationRef = useRef(travelTimeDestination);
travelTimeDestinationRef.current = travelTimeDestination;
const travelTimeColorRangeRef = useRef(travelTimeColorRange);
travelTimeColorRangeRef.current = travelTimeColorRange;
const travelTimeRangeRef = useRef(travelTimeRange);
travelTimeRangeRef.current = travelTimeRange;
const travelTimeEntriesRef = useRef(travelTimeEntries);
travelTimeEntriesRef.current = travelTimeEntries;
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
travelTimeColorRangesRef.current = travelTimeColorRanges;
const primaryTravelMode = useMemo(
() => getPrimaryTravelMode(travelTimeEntries, travelTimeColorRanges),
[travelTimeEntries, travelTimeColorRanges]
);
const primaryTravelModeRef = useRef(primaryTravelMode);
primaryTravelModeRef.current = primaryTravelMode;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -238,7 +253,17 @@ export function useDeckLayers({
}, []);
// --- Color triggers ---
const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}|${travelTimeDestination?.[1]}`;
// Build travel time trigger from all entries
const ttTrigger = useMemo(() => {
const parts: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
const cr = travelTimeColorRanges[mode];
parts.push(`${mode}:${entry?.destination?.[0]}|${entry?.destination?.[1]}|${cr?.[0]}|${cr?.[1]}|${entry?.timeRange?.[0]}|${entry?.timeRange?.[1]}`);
}
return parts.join(';');
}, [travelTimeEntries, travelTimeColorRanges]);
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}`;
@ -251,17 +276,28 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
// Travel time coloring takes priority
if (travelTimeEnabledRef.current && travelTimeDestinationRef.current) {
const ttVal = d.travel_time;
const ttClr = travelTimeColorRangeRef.current;
const pm = primaryTravelModeRef.current;
const entries = travelTimeEntriesRef.current;
const colorRanges = travelTimeColorRangesRef.current;
// Travel time coloring: primary mode colors, others dim-filter
if (pm) {
const ttVal = d[`travel_time_${pm}`];
const ttClr = colorRanges[pm];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
const ttFr = travelTimeRangeRef.current;
if (ttFr && ((ttVal as number) < ttFr[0] || (ttVal as number) > ttFr[1])) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
// Check all modes with time ranges as filters (including primary)
for (const mode of TRANSPORT_MODES) {
const entry = entries[mode];
if (!entry?.timeRange) continue;
const modeVal = d[`travel_time_${mode}`];
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
}
}
if (ttClr) {
return getFeatureFillColor(
ttVal as number,
@ -464,19 +500,19 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
// Check if the searched postcode has data (passes current filters)
const searchedPostcodeHasData = useMemo(() => {
if (!searchedPostcode) return false;
return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode);
}, [searchedPostcode, postcodeData]);
// Check if the selected postcode has data (passes current filters)
const selectedPostcodeHasData = useMemo(() => {
if (!selectedPostcodeGeometry || !selectedHexagonId) return false;
return postcodeData.some((f) => f.properties.postcode === selectedHexagonId);
}, [selectedPostcodeGeometry, selectedHexagonId, postcodeData]);
// Highlight layer for searched postcode
const searchedPostcodeHighlightLayer = useMemo(() => {
if (!searchedPostcode) return null;
const hasData = searchedPostcodeHasData;
// Highlight layer for selected postcode (from search)
const selectedPostcodeHighlightLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null;
const hasData = selectedPostcodeHasData;
const feature = {
type: 'Feature' as const,
geometry: searchedPostcode.geometry,
geometry: selectedPostcodeGeometry,
properties: {},
};
return new GeoJsonLayer({
@ -494,13 +530,25 @@ export function useDeckLayers({
filled: true,
pickable: false,
});
}, [searchedPostcode, searchedPostcodeHasData]);
}, [selectedPostcodeGeometry, selectedPostcodeHasData]);
// Destination markers: one red dot per mode with a destination
const destinationMarkerData = useMemo(() => {
const points: { position: [number, number] }[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
points.push({ position: [entry.destination[1], entry.destination[0]] });
}
}
return points;
}, [travelTimeEntries]);
const destinationMarkerLayer = useMemo(() => {
if (!travelTimeEnabled || !travelTimeDestination) return null;
if (destinationMarkerData.length === 0) return null;
return new ScatterplotLayer({
id: 'travel-time-destination',
data: [{ position: [travelTimeDestination[1], travelTimeDestination[0]] }],
id: 'travel-time-destinations',
data: destinationMarkerData,
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 8,
getFillColor: [239, 68, 68, 220],
@ -511,14 +559,14 @@ export function useDeckLayers({
stroked: true,
pickable: false,
});
}, [travelTimeEnabled, travelTimeDestination]);
}, [destinationMarkerData]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (searchedPostcodeHighlightLayer) baseLayers.push(searchedPostcodeHighlightLayer);
if (selectedPostcodeHighlightLayer) baseLayers.push(selectedPostcodeHighlightLayer);
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
return baseLayers;
}, [
@ -527,7 +575,7 @@ export function useDeckLayers({
postcodeLayer,
postcodeLabelsLayer,
poiLayer,
searchedPostcodeHighlightLayer,
selectedPostcodeHighlightLayer,
destinationMarkerLayer,
]);
@ -548,5 +596,6 @@ export function useDeckLayers({
handleMouseLeave,
selectedPostcode,
hoveredPostcode,
primaryTravelMode,
};
}

View file

@ -78,7 +78,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const m = features.find((f) => f.name === n);
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
const [min, max] = value as [number, number];
return `${n}:${min}:${max}`;
const maxStr = m?.absolute && max === m.max ? 'inf' : String(max);
return `${n}:${min}:${maxStr}`;
})
.join(',');
}

View file

@ -3,10 +3,11 @@ import type {
FeatureMeta,
FeatureFilters,
Property,
PostcodeGeometry,
HexagonPropertiesResponse,
HexagonStatsResponse,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
interface SelectedHexagon {
id: string;
@ -30,6 +31,8 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] =
useState<PostcodeGeometry | null>(null);
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
@ -43,6 +46,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
params.set('fields', fields.join(','));
}
const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal }));
assertOk(response, 'hexagon-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
@ -54,6 +58,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
assertOk(response, 'postcode-stats');
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
@ -74,6 +79,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
assertOk(response, 'hexagon-properties');
const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) {
@ -84,7 +90,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
console.error('Failed to fetch properties:', err);
logNonAbortError('Failed to fetch properties', err);
} finally {
setLoadingProperties(false);
}
@ -94,6 +100,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const handleHexagonClick = useCallback(
(id: string, isPostcode = false) => {
setSelectedPostcodeGeometry(null);
if (selectedHexagon?.id === id) {
setSelectedHexagon(null);
setProperties([]);
@ -154,8 +161,27 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setSelectedPostcodeGeometry(null);
}, []);
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchPostcodeStats(postcode)
.then((stats) => setAreaStats(stats))
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
},
[resolution, fetchPostcodeStats]
);
return {
selectedHexagon,
properties,
@ -172,5 +198,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
};
}

View file

@ -0,0 +1,123 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api';
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
export function looksLikePostcode(s: string) {
return POSTCODE_RE.test(s.trim());
}
export type SearchResult =
| { type: 'postcode'; label: string }
| { type: 'place'; name: string; place_type: string; lat: number; lon: number; city?: string };
export function useLocationSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleInputChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(-1);
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;
}
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 close = useCallback(() => setOpen(false), []);
const clear = useCallback(() => {
setQuery('');
setResults([]);
setOpen(false);
setActiveIndex(-1);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => {
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) {
onSelect(results[activeIndex]);
} else if (looksLikePostcode(query)) {
onSelect({ type: 'postcode', label: query.trim().toUpperCase() });
}
} else if (e.key === 'Escape') {
setOpen(false);
}
},
[results, activeIndex, query],
);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return {
query,
results,
activeIndex,
setActiveIndex,
open,
setOpen,
handleInputChange,
handleKeyDown,
close,
clear,
};
}

View file

@ -8,9 +8,10 @@ import type {
ViewChangeParams,
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { TRANSPORT_MODES, type TransportMode, type TravelTimeEntries } from './useTravelTime';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -32,9 +33,7 @@ interface UseMapDataOptions {
activeFeature: string | null;
dragValue: [number, number] | null;
dragData: HexagonData[] | null;
travelTimeEnabled: boolean;
travelTimeDestination: [number, number] | null;
travelTimeMode: string;
travelTimeEntries: TravelTimeEntries;
}
export function useMapData({
@ -44,9 +43,7 @@ export function useMapData({
activeFeature,
dragValue,
dragData,
travelTimeEnabled,
travelTimeDestination,
travelTimeMode,
travelTimeEntries,
}: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
@ -71,6 +68,18 @@ export function useMapData({
[filters, features]
);
// Build the travel param string from entries with destinations
const travelParam = useMemo((): string => {
const segments: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
segments.push(`${entry.destination[0]},${entry.destination[1]},${mode}`);
}
}
return segments.join('|');
}, [travelTimeEntries]);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
if (!bounds) return;
@ -100,6 +109,7 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
assertOk(res, 'postcodes');
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features);
setRawData([]);
@ -110,9 +120,8 @@ export function useMapData({
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || '');
if (travelTimeEnabled && travelTimeDestination) {
params.set('destination', `${travelTimeDestination[0]},${travelTimeDestination[1]}`);
params.set('mode', travelTimeMode);
if (travelParam) {
params.set('travel', travelParam);
}
const res = await fetch(
apiUrl('hexagons', params),
@ -120,6 +129,7 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
assertOk(res, 'hexagons');
const json: ApiResponse = await res.json();
setRawData(json.features);
setPostcodeData([]);
@ -136,7 +146,7 @@ export function useMapData({
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelTimeEnabled, travelTimeDestination, travelTimeMode]);
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
const data = dragData ?? rawData;
@ -159,7 +169,7 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${viewFeature}`] ?? feat.properties[`min_${viewFeature}`];
const val = feat.properties[`avg_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
} else {
@ -170,7 +180,7 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${viewFeature}`] ?? item[`min_${viewFeature}`];
const val = item[`avg_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
}
@ -197,26 +207,32 @@ export function useMapData({
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
// Color range for travel time (computed from response data)
const travelTimeColorRange = useMemo((): [number, number] | null => {
if (!travelTimeEnabled || !travelTimeDestination) return null;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
// Color ranges for travel time per mode (computed from response data)
const travelTimeColorRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (!entry?.destination) continue;
const fieldName = `travel_time_${mode}`;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
const val = item.travel_time;
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges[mode] = [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [travelTimeEnabled, travelTimeDestination, data, bounds]);
return ranges;
}, [travelTimeEntries, data, bounds]);
const handleViewChange = useCallback(
({
@ -257,7 +273,7 @@ export function useMapData({
currentView,
usePostcodeView,
colorRange,
travelTimeColorRange,
travelTimeColorRanges,
handleViewChange,
setInitialView,
};

View file

@ -1,67 +1,83 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
export interface TravelTimeState {
enabled: boolean;
export const TRANSPORT_MODES: TransportMode[] = ['car', 'bicycle', 'walking', 'transit'];
export const MODE_LABELS: Record<TransportMode, string> = {
car: 'Car',
bicycle: 'Bicycle',
walking: 'Walking',
transit: 'Transit',
};
export interface TravelTimeEntry {
destination: [number, number] | null; // [lat, lon]
destinationLabel: string;
mode: TransportMode;
timeRange: [number, number] | null;
}
export type TravelTimeEntries = Partial<Record<TransportMode, TravelTimeEntry>>;
export interface TravelTimeInitial {
destination?: [number, number];
destinationLabel?: string;
mode?: TransportMode;
timeRange?: [number, number];
entries?: TravelTimeEntries;
}
export function useTravelTime(initial?: TravelTimeInitial) {
const [enabled, setEnabled] = useState(!!initial?.destination);
const [destination, setDestination] = useState<[number, number] | null>(
initial?.destination ?? null
);
const [destinationLabel, setDestinationLabel] = useState(initial?.destinationLabel ?? '');
const [mode, setMode] = useState<TransportMode>(initial?.mode ?? 'car');
const [timeRange, setTimeRange] = useState<[number, number] | null>(
initial?.timeRange ?? null
const [entries, setEntries] = useState<TravelTimeEntries>(initial?.entries ?? {});
const activeModes = useMemo(
() => TRANSPORT_MODES.filter((m) => m in entries),
[entries]
);
const handleEnable = useCallback(() => {
setEnabled(true);
const modesWithDestination = useMemo(
() => TRANSPORT_MODES.filter((m) => entries[m]?.destination != null),
[entries]
);
const handleEnableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => ({
...prev,
[mode]: { destination: null, destinationLabel: '', timeRange: null },
}));
}, []);
const handleDisable = useCallback(() => {
setEnabled(false);
setDestination(null);
setDestinationLabel('');
setTimeRange(null);
const handleDisableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => {
const next = { ...prev };
delete next[mode];
return next;
});
}, []);
const handleSetDestination = useCallback((lat: number, lon: number, label: string) => {
setDestination([lat, lon]);
setDestinationLabel(label);
}, []);
const handleSetDestination = useCallback(
(mode: TransportMode, lat: number, lon: number, label: string) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], destination: [lat, lon] as [number, number], destinationLabel: label },
}));
},
[]
);
const handleModeChange = useCallback((newMode: TransportMode) => {
setMode(newMode);
}, []);
const handleTimeRangeChange = useCallback((range: [number, number]) => {
setTimeRange(range);
}, []);
const handleTimeRangeChange = useCallback(
(mode: TransportMode, range: [number, number]) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], timeRange: range },
}));
},
[]
);
return {
enabled,
destination,
destinationLabel,
mode,
timeRange,
handleEnable,
handleDisable,
entries,
activeModes,
modesWithDestination,
handleEnableMode,
handleDisableMode,
handleSetDestination,
handleModeChange,
handleTimeRangeChange,
};
}

View file

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

View file

@ -10,6 +10,17 @@ export function logNonAbortError(label: string, error: unknown): void {
console.error(`${label}:`, error);
}
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
/** Throw if response is not 2xx. Call before `.json()`. */
export function assertOk(res: Response, label: string): void {
if (!res.ok) {
throw new Error(`${label}: HTTP ${res.status} ${res.statusText}`);
}
}
export function authHeaders(init?: RequestInit): RequestInit {
const headers: Record<string, string> = {};
if (pb.authStore.isValid && pb.authStore.token) {
@ -69,7 +80,8 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max);
return `${name}:${min}:${maxStr}`;
})
.join(',');
}

View file

@ -1,5 +1,10 @@
import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
import type { TransportMode, TravelTimeInitial } from '../hooks/useTravelTime';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
const filterParams = params.getAll('filter');
@ -65,26 +70,33 @@ export function parseUrlState(): {
result.tab = tab;
}
// Travel time
const dest = params.get('dest');
if (dest) {
const parts = dest.split(',').map(Number);
if (parts.length === 2 && parts.every((n) => !isNaN(n))) {
const tt: TravelTimeInitial = {
destination: [parts[0], parts[1]],
destinationLabel: params.get('destLabel') || '',
mode: (params.get('tmode') as TransportMode) || 'car',
};
const ttRange = params.get('tt');
if (ttRange) {
const [min, max] = ttRange.split(':').map(Number);
if (!isNaN(min) && !isNaN(max)) {
tt.timeRange = [min, max];
// Travel time: per-mode params (tt_car=lat,lon ttl_car=label ttr_car=min:max)
const entries: TravelTimeEntries = {};
for (const mode of TRANSPORT_MODES) {
const dest = params.get(`tt_${mode}`);
if (dest) {
const parts = dest.split(',').map(Number);
if (parts.length === 2 && parts.every((n) => !isNaN(n))) {
const label = params.get(`ttl_${mode}`) || '';
let timeRange: [number, number] | null = null;
const rangeStr = params.get(`ttr_${mode}`);
if (rangeStr) {
const [min, max] = rangeStr.split(':').map(Number);
if (!isNaN(min) && !isNaN(max)) {
timeRange = [min, max];
}
}
entries[mode] = {
destination: [parts[0], parts[1]],
destinationLabel: label,
timeRange,
};
}
result.travelTime = tt;
}
}
if (Object.keys(entries).length > 0) {
result.travelTime = { entries };
}
return result;
}
@ -95,7 +107,7 @@ export function stateToParams(
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTime?: { enabled: boolean; destination: [number, number] | null; destinationLabel: string; mode: string; timeRange: [number, number] | null }
travelTimeEntries?: TravelTimeEntries
): URLSearchParams {
const params = new URLSearchParams();
@ -123,16 +135,18 @@ export function stateToParams(
params.set('tab', 'properties');
}
if (travelTime?.enabled && travelTime.destination) {
params.set('dest', `${travelTime.destination[0].toFixed(5)},${travelTime.destination[1].toFixed(5)}`);
if (travelTime.destinationLabel) {
params.set('destLabel', travelTime.destinationLabel);
}
if (travelTime.mode !== 'car') {
params.set('tmode', travelTime.mode);
}
if (travelTime.timeRange) {
params.set('tt', `${travelTime.timeRange[0]}:${travelTime.timeRange[1]}`);
// Travel time: per-mode params
if (travelTimeEntries) {
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (!entry?.destination) continue;
params.set(`tt_${mode}`, `${entry.destination[0].toFixed(5)},${entry.destination[1].toFixed(5)}`);
if (entry.destinationLabel) {
params.set(`ttl_${mode}`, entry.destinationLabel);
}
if (entry.timeRange) {
params.set(`ttr_${mode}`, `${entry.timeRange[0]}:${entry.timeRange[1]}`);
}
}
}

View file

@ -17,6 +17,7 @@ export interface FeatureMeta {
prefix?: string;
suffix?: string;
raw?: boolean;
absolute?: boolean;
}
export interface FeatureGroup {
@ -104,6 +105,7 @@ export interface PlaceResult {
place_type: string;
lat: number;
lon: number;
city?: string;
}
export interface RenovationEvent {