Changes
This commit is contained in:
parent
3a3f899ea2
commit
128b3191e7
68 changed files with 28060 additions and 1152 deletions
|
|
@ -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));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
143
frontend/src/components/map/LocationSearch.tsx
Normal file
143
frontend/src/components/map/LocationSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
123
frontend/src/components/ui/PlaceSearchInput.tsx
Normal file
123
frontend/src/components/ui/PlaceSearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(',');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
123
frontend/src/hooks/useLocationSearch.ts
Normal file
123
frontend/src/hooks/useLocationSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 (0–100) 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(',');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue