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