This commit is contained in:
Andras Schmelczer 2026-02-10 22:21:15 +00:00
parent 1f68ca0512
commit 3599803589
43 changed files with 3578 additions and 262 deletions

View file

@ -9,6 +9,8 @@ import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { RouteIcon, PlusIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
@ -19,6 +21,8 @@ interface FeatureBrowserProps {
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEnabled?: boolean;
onEnableTravelTime?: () => void;
}
export default function FeatureBrowser({
@ -30,6 +34,8 @@ export default function FeatureBrowser({
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEnabled,
onEnableTravelTime,
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -60,6 +66,26 @@ export default function FeatureBrowser({
<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">
<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}>
<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
</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">
<PlusIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
</div>
)}
{grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (

View file

@ -1,14 +1,51 @@
import { memo, useState } from 'react';
import { memo, useState, useMemo } from 'react';
import { Slider } from '../ui/Slider';
import { FilterIcon, LightbulbIcon } from '../ui/icons';
import { EmptyState } from '../ui/EmptyState';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import InfoPopup from '../ui/InfoPopup';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import FeatureBrowser from './FeatureBrowser';
import { TravelTimeCard } from './TravelTimeCard';
import type { TransportMode } from '../../hooks/useTravelTime';
function SliderLabels({
min,
max,
value,
}: {
min: number;
max: number;
value: [number, number];
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
const rightPct = ((value[1] - min) / range) * 100;
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{formatFilterValue(value[0])}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{formatFilterValue(value[1])}
</span>
</div>
);
}
interface FiltersProps {
features: FeatureMeta[];
@ -22,15 +59,23 @@ interface FiltersProps {
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
zoom: number;
itemCount: number;
usePostcodeView: boolean;
pinnedFeature: string | null;
onTogglePin: (name: string) => void;
onCancelPin: () => void;
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;
}
export default memo(function Filters({
@ -45,21 +90,34 @@ export default memo(function Filters({
onDragStart,
onDragChange,
onDragEnd,
zoom,
itemCount,
usePostcodeView,
pinnedFeature,
onTogglePin,
onCancelPin: _onCancelPin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEnabled,
travelTimeDestination,
travelTimeDestinationLabel,
travelTimeMode,
travelTimeRange,
travelTimeDataRange,
onTravelTimeEnable,
onTravelTimeDisable,
onTravelTimeSetDestination,
onTravelTimeModeChange,
onTravelTimeRangeChange,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const [showPhilosophy, setShowPhilosophy] = useState(false);
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const enabledGroups = useMemo(
() => groupFeaturesByCategory(enabledFeatureList),
[enabledFeatureList]
);
return (
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
@ -72,118 +130,146 @@ export default memo(function Filters({
Finding the Perfect Postcode
</button>
</div>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:max-h-[65%]">
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
Active Filters
</span>
{enabledFeatureList.length > 0 && (
{(enabledFeatureList.length > 0 || travelTimeEnabled) && (
<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}
{enabledFeatureList.length + (travelTimeEnabled ? 1 : 0)}
</span>
)}
</div>
<span className="text-xs text-warm-500 dark:text-warm-400">
{itemCount.toLocaleString()} {usePostcodeView ? 'postcodes' : 'hexagons'} · z
{zoom.toFixed(1)}
</span>
</div>
<div className="md:flex-1 md:overflow-y-auto p-3 space-y-3">
{enabledFeatureList.length === 0 && (
<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>
)}
{enabledFeatureList.length === 0 && !travelTimeEnabled && (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title="No active filters"
description="Browse features below and click + to add a filter"
className="px-3 py-4"
/>
)}
{enabledFeatureList.map((feature) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div
key={feature.name}
className={`space-y-1 p-3 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<div className="space-y-0.5 max-h-40 overflow-y-auto">
{allValues.map((val) => (
<label
key={val}
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedValues.includes(val)}
onChange={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
className="rounded accent-teal-600"
/>
{val}
</label>
))}
</div>
</div>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
{enabledGroups.map((group) => {
const isExpanded = !collapsedGroups.has(group.name);
return (
<div
key={feature.name}
className={`space-y-1 p-3 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<div className="flex items-center justify-between">
<span className="text-sm text-warm-500 dark:text-warm-400">
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
<div key={group.name}>
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
<FeatureActions
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
</CollapsibleGroupHeader>
{isExpanded && (
<div className="px-2 py-1 space-y-1">
{group.features.map((feature) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div
key={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={val}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
/>
))}
</PillGroup>
</div>
);
}
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
return (
<div
key={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between gap-1">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
<FeatureActions
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onRemove={onRemoveFilter}
/>
</div>
<div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels min={feature.min!} max={feature.max!} value={displayValue} />
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
<div className="shrink-0 md:shrink md:min-h-0 md:flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[60%] border-t border-warm-200 dark:border-warm-700">
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
</div>
@ -197,6 +283,8 @@ export default memo(function Filters({
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEnabled={travelTimeEnabled}
onEnableTravelTime={onTravelTimeEnable}
/>
</div>
</div>

View file

@ -46,6 +46,10 @@ interface MapProps {
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEnabled?: boolean;
travelTimeDestination?: [number, number] | null;
travelTimeColorRange?: [number, number] | null;
travelTimeRange?: [number, number] | null;
}
interface Dimensions {
@ -98,6 +102,10 @@ export default memo(function Map({
onPostcodeSearched,
bounds: viewportBounds,
hideLegend = false,
travelTimeEnabled = false,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
@ -176,6 +184,10 @@ export default memo(function Map({
theme,
searchedPostcode,
bounds: viewportBounds,
travelTimeEnabled,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
});
return (
@ -204,7 +216,7 @@ export default memo(function Map({
className="text-5xl font-bold text-white drop-shadow-lg"
style={{ textShadow: '0 2px 8px rgba(0,0,0,0.6)' }}
>
Your perfect postcodes
Your perfect postcode
</h1>
</div>
) : null
@ -212,7 +224,17 @@ export default memo(function Map({
<>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{!hideLegend &&
(viewFeature && colorRange && colorFeatureMeta ? (
(travelTimeEnabled && travelTimeDestination && travelTimeColorRange ? (
<MapLegend
featureLabel="Travel time"
range={travelTimeColorRange}
showCancel={false}
onCancel={onCancelPin}
mode="feature"
theme={theme}
suffix=" min"
/>
) : viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'

View file

@ -13,6 +13,7 @@ export default function MapLegend({
enumValues,
theme = 'light',
inline = false,
suffix,
}: {
featureLabel: string;
range: [number, number];
@ -22,6 +23,7 @@ export default function MapLegend({
enumValues?: string[];
theme?: 'light' | 'dark';
inline?: boolean;
suffix?: string;
}) {
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
@ -61,8 +63,8 @@ export default function MapLegend({
</>
) : (
<>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
<TickerValue text={formatValue(range[0]) + (suffix || '')} />
<TickerValue text={formatValue(range[1]) + (suffix || '')} />
</>
)}
</div>

View file

@ -8,7 +8,6 @@ import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import DataSources from '../data-sources/DataSources';
import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton';
import { useMapData } from '../../hooks/useMapData';
@ -18,6 +17,7 @@ import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTravelTime, type TravelTimeInitial } from '../../hooks/useTravelTime';
import { apiUrl, buildFilterString } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -44,6 +44,7 @@ interface MapPageProps {
screenshotMode?: boolean;
ogMode?: boolean;
isMobile?: boolean;
initialTravelTime?: TravelTimeInitial;
}
export default function MapPage({
@ -62,6 +63,7 @@ export default function MapPage({
screenshotMode,
ogMode,
isMobile = false,
initialTravelTime,
}: MapPageProps) {
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] =
@ -99,6 +101,9 @@ export default function MapPage({
features,
});
// Travel time hook
const travelTime = useTravelTime(initialTravelTime);
// Map data hook
const mapData = useMapData({
filters,
@ -107,6 +112,9 @@ export default function MapPage({
activeFeature,
dragValue,
dragData,
travelTimeEnabled: travelTime.enabled,
travelTimeDestination: travelTime.destination,
travelTimeMode: travelTime.mode,
});
// Keep filter bounds in sync with map data
@ -124,8 +132,21 @@ export default function MapPage({
// 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);
}
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]);
// Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime);
// Set initial view and tab from URL state
useEffect(() => {
@ -201,7 +222,7 @@ export default function MapPage({
.then((blob) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'perfect-postcodes-export.xlsx';
link.download = 'perfect-postcode-export.xlsx';
link.click();
URL.revokeObjectURL(link.href);
})
@ -292,7 +313,6 @@ export default function MapPage({
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
@ -307,7 +327,6 @@ export default function MapPage({
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
);
@ -317,7 +336,6 @@ export default function MapPage({
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
);
@ -334,15 +352,22 @@ export default function MapPage({
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={mapData.zoom}
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
usePostcodeView={mapData.usePostcodeView}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
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}
onTravelTimeSetDestination={travelTime.handleSetDestination}
onTravelTimeModeChange={travelTime.handleModeChange}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
/>
);
@ -386,13 +411,16 @@ export default function MapPage({
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
hideLegend
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeColorRange={mapData.travelTimeColorRange}
travelTimeRange={travelTime.timeRange}
/>
{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">
Loading...
</div>
)}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div>
{/* Bottom panel — 55% */}
@ -401,7 +429,18 @@ export default function MapPage({
style={{ flex: '55 0 0' }}
>
{/* Legend */}
{viewFeature && mapData.colorRange && mobileLegendMeta ? (
{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'
@ -516,13 +555,16 @@ export default function MapPage({
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
travelTimeEnabled={travelTime.enabled}
travelTimeDestination={travelTime.destination}
travelTimeColorRange={mapData.travelTimeColorRange}
travelTimeRange={travelTime.timeRange}
/>
{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">
Loading...
</div>
)}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div>
{/* Right Pane */}

View file

@ -3,6 +3,8 @@ import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import { InfoIcon, ChevronIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
@ -162,39 +164,32 @@ export default function POIPane({
>
<ChevronIcon direction="right" className="w-3 h-3" />
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
{group.name}
</span>
</label>
<span className="text-xs text-warm-400">
<PillToggle
label={group.name}
active={allInGroupSelected}
indeterminate={someInGroupSelected}
onClick={() => toggleGroup(group.name)}
size="xs"
/>
<span className="text-xs text-warm-400 ml-auto">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
{!isCollapsed && (
<div className="px-3 py-2">
<PillGroup>
{group.categories.map((category) => (
<PillToggle
key={category}
label={category}
active={selectedCategories.has(category)}
onClick={() => toggleCategory(category)}
size="xs"
/>
))}
</PillGroup>
</div>
)}
</div>
);
})}

View file

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber } from '../../lib/format';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -145,6 +145,7 @@ function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const estPricePerSqm = getNum(property, 'Est. price per sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum(
property,
@ -152,6 +153,7 @@ function PropertyCard({ property }: { property: Property }) {
'number_habitable_rooms'
);
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
const transactionDate = getNum(property, 'Date of last transaction', 'date_of_transfer');
const councilTax = getNum(property, 'Council tax (£/yr)');
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
@ -165,10 +167,16 @@ function PropertyCard({ property }: { property: Property }) {
{price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{formatNumber(price)}
{transactionDate !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
({formatTransactionDate(transactionDate)})
</span>
)}
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
(£{formatNumber(pricePerSqm)}/m²)
£{formatNumber(pricePerSqm)}/m²
</span>
)}
</div>
@ -179,6 +187,9 @@ function PropertyCard({ property }: { property: Property }) {
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(estimatedPrice)}
</span>
{estPricePerSqm !== undefined && (
<span> (£{formatNumber(estPricePerSqm)}/m²)</span>
)}
</div>
)}

View file

@ -0,0 +1,172 @@
import { useState, useCallback } from 'react';
import { Slider } from '../ui/Slider';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import { IconButton } from '../ui/IconButton';
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: 'transit', label: 'Transit' },
{ value: 'car', label: 'Car' },
{ value: 'bicycle', label: 'Bicycle' },
];
interface TravelTimeCardProps {
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({
destination,
destinationLabel,
mode,
timeRange,
dataRange,
onSetDestination,
onModeChange,
onTimeRangeChange,
onRemove,
}: TravelTimeCardProps) {
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSearch = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
setError(null);
setLoading(true);
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(trimmed)}`,
authHeaders()
);
if (!res.ok) {
setError('Postcode not found');
return;
}
const json: { postcode: string; latitude: number; longitude: number } =
await res.json();
onSetDestination(json.latitude, json.longitude, json.postcode);
setQuery('');
} catch {
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[query, onSetDestination]
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
const displayRange = timeRange ?? [sliderMin, sliderMax];
return (
<div className="space-y-2 px-2 py-2 rounded ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20">
{/* Header */}
<div className="flex items-center justify-between">
<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
</span>
</div>
<IconButton onClick={() => onRemove()} title="Remove travel time">
<CloseIcon className="w-3.5 h-3.5" />
</IconButton>
</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>
{error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</p>
)}
{destination && destinationLabel && (
<div className="flex items-center gap-1 mt-1">
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
<span className="text-xs text-warm-600 dark:text-warm-300">
{destinationLabel}
</span>
</div>
)}
</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>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time
</span>
<Slider
min={sliderMin}
max={sliderMax}
step={1}
value={[displayRange[0], displayRange[1]]}
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} min
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} min
</span>
</div>
</div>
)}
</div>
);
}