Good changes

This commit is contained in:
Andras Schmelczer 2026-03-11 20:44:34 +00:00
parent 80a5a2a774
commit 791bc6976b
24 changed files with 890 additions and 312 deletions

View file

@ -6,6 +6,7 @@ import type {
HexagonStatsResponse,
PostcodeFeature,
} from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
@ -16,14 +17,14 @@ import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from '../ui/icons';
import { InfoIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { IconButton } from '../ui/IconButton';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import StreetViewEmbed from './StreetViewEmbed';
import HistogramLegend from './HistogramLegend';
import JourneyInstructions from './JourneyInstructions';
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
@ -33,10 +34,10 @@ interface AreaPaneProps {
isPostcode?: boolean;
postcodeData?: PostcodeFeature | null;
onViewProperties: () => void;
onClose: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
}
export default function AreaPane({
@ -47,10 +48,10 @@ export default function AreaPane({
isPostcode = false,
postcodeData,
onViewProperties,
onClose,
hexagonLocation,
filters,
onNavigateToSource,
travelTimeEntries,
}: AreaPaneProps) {
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
@ -84,16 +85,10 @@ export default function AreaPane({
}
return (
<div className="relative h-full">
<div className="absolute top-2 right-2 z-20">
<IconButton onClick={onClose} title="Close">
<CloseIcon />
</IconButton>
</div>
<div className="h-full overflow-y-auto">
<>
<div className="h-full overflow-y-auto">
<div className="p-3">
<div className="flex items-center gap-2 pr-8">
<div className="flex items-center gap-2">
<div>
<h2 className="text-sm font-semibold dark:text-warm-100">
{isPostcode ? hexagonId : 'Area Statistics'}
@ -129,6 +124,16 @@ export default function AreaPane({
{hexagonLocation && stats && (
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{(() => {
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
<JourneyInstructions
postcode={journeyPostcode}
entries={travelTimeEntries}
label={!isPostcode ? journeyPostcode : undefined}
/>
) : null;
})()}
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
@ -260,7 +265,7 @@ export default function AreaPane({
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={formatFilterValue}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
) : (
<DualHistogram
@ -268,7 +273,7 @@ export default function AreaPane({
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
))}
</div>
@ -386,6 +391,6 @@ export default function AreaPane({
onNavigateToSource={onNavigateToSource}
/>
)}
</div>
</>
);
}

View file

@ -1,34 +1,19 @@
import { useMemo, useState, useEffect } from 'react';
import { useMemo } from 'react';
import type { FeatureFilters } from '../../types';
import {
buildPropertySearchUrls,
H3_RADIUS_MILES,
type HexagonLocation,
} from '../../lib/external-search';
import { apiUrl, logNonAbortError } from '../../lib/api';
import outcodeIds from '../../lib/rightmove-outcodes.json';
function useRightmoveLocationId(postcode: string | undefined): string | undefined {
const [locationId, setLocationId] = useState<string | undefined>();
const rightmoveOutcodes = outcodeIds as Record<string, string>;
useEffect(() => {
if (!postcode) {
setLocationId(undefined);
return;
}
setLocationId(undefined);
const controller = new AbortController();
fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), {
signal: controller.signal,
})
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.location_identifier) setLocationId(data.location_identifier);
})
.catch((err) => logNonAbortError('rightmove-location', err));
return () => controller.abort();
}, [postcode]);
return locationId;
function getRightmoveLocationId(postcode: string | undefined): string | undefined {
if (!postcode) return undefined;
const outcode = postcode.trim().split(/\s+/)[0].toUpperCase();
const id = rightmoveOutcodes[outcode];
return id ? `OUTCODE^${id}` : undefined;
}
export default function ExternalSearchLinks({
@ -38,7 +23,7 @@ export default function ExternalSearchLinks({
location: HexagonLocation;
filters: FeatureFilters;
}) {
const rightmoveLocationId = useRightmoveLocationId(location.postcode);
const rightmoveLocationId = getRightmoveLocationId(location.postcode);
const urls = useMemo(
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
@ -69,7 +54,7 @@ export default function ExternalSearchLinks({
Rightmove
</a>
) : (
<span className={disabledClass} title="Loading...">
<span className={disabledClass} title="Outcode not recognised">
Rightmove
</span>
)}

View file

@ -32,6 +32,7 @@ function SliderLabels({
displayValues,
isAtMin,
isAtMax,
raw,
}: {
min: number;
max: number;
@ -39,6 +40,7 @@ function SliderLabels({
displayValues?: [number, number];
isAtMin?: boolean;
isAtMax?: boolean;
raw?: boolean;
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
@ -50,13 +52,13 @@ function SliderLabels({
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{isAtMin ? 'min' : formatFilterValue(labels[0])}
{isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{isAtMax ? 'max' : formatFilterValue(labels[1])}
{isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
</span>
</div>
);
@ -224,6 +226,15 @@ export default memo(function Filters({
[onAddFilter, features, expandGroup]
);
const handleAddTravelTimeAndScroll = useCallback(
(mode: TransportMode) => {
expandGroup('Travel Time');
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
onTravelTimeAddEntry(mode);
},
[onTravelTimeAddEntry, travelTimeEntries.length, expandGroup]
);
useEffect(() => {
const name = pendingScrollRef.current;
if (!name) return;
@ -232,7 +243,7 @@ export default memo(function Filters({
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [enabledFeatureList]);
}, [enabledFeatureList, travelTimeEntries]);
const enabledGroups = useMemo(
() => groupFeaturesByCategory(enabledFeatureList),
[enabledFeatureList]
@ -311,8 +322,8 @@ export default memo(function Filters({
{!collapsedGroups.has('Travel Time') && (
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-7">
<TravelTimeCard
key={index}
mode={entry.mode}
slug={entry.slug}
label={entry.label}
@ -325,6 +336,7 @@ export default memo(function Filters({
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
</div>
)}
@ -460,6 +472,7 @@ export default memo(function Filters({
displayValues={scale ? displayValue : undefined}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
/>
</div>
</div>
@ -488,7 +501,7 @@ export default memo(function Filters({
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onTravelTimeAddEntry}
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
isLicensed={isLicensed}
onUpgradeClick={onUpgradeClick}
/>

View file

@ -12,7 +12,7 @@ export default function HistogramLegend() {
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the
<span className="font-medium text-warm-900 dark:text-warm-100">Grey bars</span> show the
overall distribution across all areas
</span>
</div>

View file

@ -0,0 +1,262 @@
import { useState, useEffect } from 'react';
import type { JourneyLeg } from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import { apiUrl, logNonAbortError } from '../../lib/api';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
import { BicycleIcon } from '../ui/icons/BicycleIcon';
interface JourneyInstructionsProps {
postcode: string;
entries: TravelTimeEntry[];
/** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */
label?: string;
}
interface JourneyData {
slug: string;
label: string;
legs: JourneyLeg[] | null;
/** Median (50th percentile) total travel time from R5, including waiting. */
minutes: number | null;
/** Best-case (5th percentile) total travel time from R5. */
bestMinutes: number | null;
loading: boolean;
}
// Official TfL line colors + other known London transit
const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
Bakerloo: { color: '#B36305' },
Central: { color: '#E32017' },
Circle: { color: '#FFD300', darkText: true },
District: { color: '#00782A' },
'Elizabeth line': { color: '#6950A1' },
Elizabeth: { color: '#6950A1' },
'Hammersmith & City': { color: '#F3A9BB', darkText: true },
'Hammersmith and City': { color: '#F3A9BB', darkText: true },
Jubilee: { color: '#A0A5A9', darkText: true },
Metropolitan: { color: '#9B0056' },
Northern: { color: '#333333' },
Piccadilly: { color: '#003688' },
Victoria: { color: '#0098D4' },
'Waterloo & City': { color: '#95CDBA', darkText: true },
'Waterloo and City': { color: '#95CDBA', darkText: true },
DLR: { color: '#00A4A7' },
'London Overground': { color: '#EE7C0E' },
};
const NON_TUBE_NAMES = new Set(['DLR', 'London Overground', 'Elizabeth line']);
function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } {
const known = ROUTE_COLORS[mode];
if (known) {
const label = NON_TUBE_NAMES.has(mode) || mode.includes('line') ? mode : `${mode} line`;
return { label, color: known.color, darkText: !!known.darkText };
}
if (/^\d+[A-Za-z]?$/.test(mode.trim())) {
return { label: `Bus ${mode}`, color: '#0d9488', darkText: false };
}
return { label: mode, color: '#6b7280', darkText: false };
}
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
return [...legs]
.reverse()
.map((leg) => (leg.from && leg.to ? { ...leg, from: leg.to, to: leg.from } : leg));
}
function RouteBadge({ mode }: { mode: string }) {
const { label, color, darkText } = getRouteDisplay(mode);
return (
<span
className="inline-flex items-center text-[10px] font-bold px-1.5 py-px rounded-sm leading-tight tracking-wide"
style={{ backgroundColor: color, color: darkText ? '#292524' : '#fff' }}
>
{label}
</span>
);
}
function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
const isAccess = leg.mode === 'walk' || leg.mode === 'bicycle';
if (isAccess) {
return (
<div className="flex">
<div className="flex flex-col items-center w-4 mr-2">
<div className="w-2 h-2 rounded-full border-[1.5px] border-warm-400 dark:border-warm-500 shrink-0 mt-1" />
{!isLast && (
<div className="flex-1 min-h-[4px] border-l border-dashed border-warm-300 dark:border-warm-600" />
)}
</div>
<div className="flex items-center gap-1.5 py-0.5 min-w-0">
{leg.mode === 'walk' ? (
<WalkingIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
) : (
<BicycleIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
)}
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.mode === 'walk' ? 'Walk' : 'Cycle'} · {leg.minutes} min
</span>
</div>
</div>
);
}
const { color } = getRouteDisplay(leg.mode);
return (
<div className="flex">
<div className="flex flex-col items-center w-4 mr-2">
<div
className="w-2.5 h-2.5 rounded-full shrink-0 mt-0.5"
style={{ backgroundColor: color }}
/>
{!isLast && (
<div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />
)}
</div>
<div className="pb-1.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<RouteBadge mode={leg.mode} />
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} min</span>
</div>
{leg.from && leg.to && (
<div className="text-[11px] text-warm-600 dark:text-warm-300 mt-0.5 truncate">
{leg.from} {leg.to}
</div>
)}
</div>
</div>
);
}
export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) {
const [journeys, setJourneys] = useState<JourneyData[]>([]);
// Only transit entries with a destination set
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
useEffect(() => {
if (transitEntries.length === 0) {
setJourneys([]);
return;
}
const controller = new AbortController();
const results: JourneyData[] = transitEntries.map((e) => ({
slug: e.slug,
label: e.label,
legs: null,
minutes: null,
bestMinutes: null,
loading: true,
}));
setJourneys([...results]);
transitEntries.forEach((entry, idx) => {
const params = new URLSearchParams({
postcode,
mode: 'transit',
slug: entry.slug,
});
fetch(apiUrl('journey', params), { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(
(data: {
journey: JourneyLeg[] | null;
minutes: number | null;
best_minutes: number | null;
}) => {
setJourneys((prev) =>
prev.map((j, i) =>
i === idx
? {
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
loading: false,
}
: j
)
);
}
)
.catch((err) => {
logNonAbortError('journey', err);
setJourneys((prev) =>
prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))
);
});
});
return () => controller.abort();
}, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
if (transitEntries.length === 0) return null;
return (
<div className="mx-3 mt-2 space-y-2">
{label && (
<div className="text-xs text-warm-500 dark:text-warm-400">Journeys from {label}</div>
)}
{journeys.map((j) => {
const displayLegs = j.legs ? invertLegs(j.legs) : null;
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.minutes ?? legSum;
const waitingMin = j.minutes != null ? Math.max(0, j.minutes - legSum) : null;
const bestWaitingMin =
j.bestMinutes != null ? Math.max(0, j.bestMinutes - legSum) : null;
return (
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
<div className="flex items-baseline justify-between mb-2">
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
To {j.label || j.slug}
</span>
{displayLegs && displayLegs.length > 0 && (
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
{totalMin} min
</span>
)}
</div>
{j.loading ? (
<div className="flex items-center gap-2 py-1">
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-warm-500 dark:text-warm-400">Loading...</span>
</div>
) : displayLegs && displayLegs.length > 0 ? (
<div>
{displayLegs.map((leg, i) => (
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
))}
{waitingMin != null && waitingMin > 0 && (
<div className="mt-1.5 pt-1.5 border-t border-warm-200 dark:border-warm-700 flex items-baseline justify-between">
<span className="text-[11px] text-warm-500 dark:text-warm-400">
Waiting & transfers
</span>
<span className="text-[11px] text-warm-600 dark:text-warm-300">
{waitingMin} min
{bestWaitingMin != null && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
(best: {bestWaitingMin === 0 ? '~0' : bestWaitingMin} min)
</span>
)}
</span>
</div>
)}
</div>
) : (
<span className="text-xs text-warm-500 dark:text-warm-400">
No journey data available
</span>
)}
</div>
);
})}
</div>
);
}

View file

@ -244,6 +244,7 @@ export default memo(function Map({
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
raw={colorFeatureMeta.raw}
/>
) : null
) : (

View file

@ -14,6 +14,7 @@ export default function MapLegend({
theme = 'light',
inline = false,
suffix,
raw,
}: {
featureLabel: string;
range: [number, number];
@ -24,26 +25,65 @@ export default function MapLegend({
theme?: 'light' | 'dark';
inline?: boolean;
suffix?: string;
raw?: boolean;
}) {
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
const fmt = raw ? { raw: true } : undefined;
const rangeMin =
mode === 'density' ? (
<TickerValue text={formatValue(range[0])} />
) : enumValues && enumValues.length > 0 ? (
<span>{enumValues[0]}</span>
) : (
<TickerValue text={formatValue(range[0], fmt) + (suffix || '')} />
);
const rangeMax =
mode === 'density' ? (
<TickerValue text={formatValue(range[1])} />
) : enumValues && enumValues.length > 0 ? (
<span>{enumValues[enumValues.length - 1]}</span>
) : (
<TickerValue text={formatValue(range[1], fmt) + (suffix || '')} />
);
if (inline) {
return (
<div className="bg-warm-100 dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-3 py-1.5 text-xs border-b border-warm-200 dark:border-warm-700 flex items-center gap-2">
<span className="font-semibold text-xs text-warm-800 dark:text-warm-200 truncate">
{featureLabel}
</span>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Clear colour view"
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
)}
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
{rangeMin}
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
{rangeMax}
</div>
</div>
);
}
return (
<div
className={
inline
? 'bg-white dark:bg-warm-800 dark:text-white p-3 text-xs border-b border-warm-200 dark:border-warm-700'
: 'absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]'
}
>
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view"
title="Clear colour view"
>
<CloseIcon className="w-4 h-4" />
</button>
@ -51,22 +91,8 @@ export default function MapLegend({
</div>
<div className="h-3 rounded" style={{ background: gradientStyle }} />
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? (
<>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
</>
) : enumValues && enumValues.length > 0 ? (
<>
<span>{enumValues[0]}</span>
<span>{enumValues[enumValues.length - 1]}</span>
</>
) : (
<>
<TickerValue text={formatValue(range[0]) + (suffix || '')} />
<TickerValue text={formatValue(range[1]) + (suffix || '')} />
</>
)}
{rangeMin}
{rangeMax}
</div>
</div>
);

View file

@ -161,10 +161,17 @@ export default function MapPage({
travelTimeEntries: travelTime.entries,
});
// First transit destination — used to pick the best central_postcode for journey display
const journeyDest = useMemo(() => {
const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug);
return entry ? { mode: entry.mode, slug: entry.slug } : null;
}, [travelTime.entries]);
const selection = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
journeyDest,
});
const handleLocationSearchResult = useCallback(
@ -196,6 +203,17 @@ export default function MapPage({
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Prevent browser back/forward navigation from horizontal trackpad swipes
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
e.preventDefault();
}
};
document.addEventListener('wheel', handleWheel, { passive: false });
return () => document.removeEventListener('wheel', handleWheel);
}, []);
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
@ -351,9 +369,9 @@ export default function MapPage({
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
travelTimeEntries={travelTime.activeEntries}
/>
);
@ -501,6 +519,7 @@ export default function MapPage({
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
raw={mobileLegendMeta.raw}
/>
) : null
) : (

View file

@ -1,8 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { Slider } from '../ui/Slider';
import { IconButton } from '../ui/IconButton';
import { PillToggle } from '../ui/PillToggle';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { DestinationDropdown } from '../ui/DestinationDropdown';
import InfoPopup from '../ui/InfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
@ -13,7 +13,7 @@ import { BicycleIcon } from '../ui/icons/BicycleIcon';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
import { TransitIcon } from '../ui/icons/TransitIcon';
import { formatFilterValue } from '../../lib/format';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
import type { ComponentType } from 'react';
@ -51,29 +51,14 @@ export function TravelTimeCard({
onToggleBest,
onRemove,
}: TravelTimeCardProps) {
const search = useLocationSearch(mode);
const containerRef = useRef<HTMLDivElement>(null);
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
const [showBestInfo, setShowBestInfo] = useState(false);
// 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.close]);
const selectResult = useCallback(
(result: SearchResult) => {
if (result.type === 'place') {
onSetDestination(result.slug, result.name);
search.clear();
}
const handleDestinationSelect = useCallback(
(selectedSlug: string, selectedLabel: string) => {
onSetDestination(selectedSlug, selectedLabel);
},
[onSetDestination, search.clear],
[onSetDestination],
);
const sliderMin = 0;
@ -120,16 +105,12 @@ export function TravelTimeCard({
</button>
</div>
) : (
<div ref={containerRef} className="relative">
<PlaceSearchInput
search={search}
onSelect={selectResult}
placeholder="Search stations..."
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"
portal
/>
</div>
<DestinationDropdown
destinations={destinations}
loading={destinationsLoading}
onSelect={handleDestinationSelect}
placeholder="Select destination..."
/>
)}
{/* Best-case toggle — transit only, shown when destination is set */}

View file

@ -0,0 +1,222 @@
import {
useState,
useRef,
useEffect,
useCallback,
useLayoutEffect,
useMemo,
} from 'react';
import { createPortal } from 'react-dom';
import type { Destination } from '../../hooks/useTravelDestinations';
import { MapPinIcon } from './icons/MapPinIcon';
import { ChevronIcon } from './icons/ChevronIcon';
interface DestinationDropdownProps {
destinations: Destination[];
loading: boolean;
onSelect: (slug: string, label: string) => void;
placeholder?: string;
}
export function DestinationDropdown({
destinations,
loading,
onSelect,
placeholder = 'Select destination...',
}: DestinationDropdownProps) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const filtered = useMemo(() => {
if (!filter) return destinations;
const lower = filter.toLowerCase();
return destinations.filter(
(d) =>
d.name.toLowerCase().includes(lower) ||
d.city?.toLowerCase().includes(lower),
);
}, [destinations, filter]);
// Position the dropdown portal
const updatePos = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('scroll', updatePos, true);
window.addEventListener('resize', updatePos);
return () => {
window.removeEventListener('scroll', updatePos, true);
window.removeEventListener('resize', updatePos);
};
}, [open, updatePos]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
setFilter('');
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Scroll active item into view
useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const item = listRef.current.children[activeIndex] as HTMLElement;
item?.scrollIntoView({ block: 'nearest' });
}, [activeIndex]);
const handleSelect = useCallback(
(dest: Destination) => {
onSelect(dest.slug, dest.name);
setOpen(false);
setFilter('');
setActiveIndex(-1);
},
[onSelect],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) =>
prev < filtered.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 < filtered.length) {
handleSelect(filtered[activeIndex]);
}
} else if (e.key === 'Escape') {
setOpen(false);
setFilter('');
}
},
[filtered, activeIndex, handleSelect],
);
const handleOpen = useCallback(() => {
setOpen(true);
setActiveIndex(-1);
// Focus input after opening
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
const dropdown = open && (
<div
className="bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 overflow-hidden"
style={
pos
? {
position: 'fixed',
top: pos.top,
left: pos.left,
width: pos.width,
zIndex: 50,
}
: undefined
}
>
{/* Filter input */}
<div className="p-1.5 border-b border-warm-100 dark:border-warm-700">
<input
ref={inputRef}
type="text"
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
placeholder="Type to filter..."
className="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
/>
</div>
{/* Results list */}
<div ref={listRef} className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-xs text-warm-400 dark:text-warm-500 text-center">
{loading ? 'Loading...' : 'No destinations found'}
</div>
) : (
filtered.map((dest, idx) => (
<button
key={dest.slug}
type="button"
className={`w-full text-left flex items-center cursor-pointer px-2 py-1.5 gap-1.5 text-xs ${
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();
handleSelect(dest);
}}
>
<MapPinIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
<span className="text-warm-700 dark:text-warm-200 truncate">
{dest.name}
{dest.city && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
({dest.city})
</span>
)}
</span>
</button>
))
)}
</div>
</div>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={handleOpen}
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 hover:border-warm-300 dark:hover:border-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
>
{loading ? (
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
) : (
<MapPinIcon className="w-3 h-3 shrink-0" />
)}
<span className="flex-1 text-left truncate">{placeholder}</span>
<ChevronIcon
direction={open ? 'up' : 'down'}
className="w-3 h-3 shrink-0"
/>
</button>
{open && createPortal(dropdown, document.body)}
</div>
);
}

View file

@ -28,7 +28,7 @@ export function FeatureActions({
)}
<IconButton
onClick={() => onTogglePin(feature.name)}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
active={isPinned}
size="md"
>

View file

@ -3,15 +3,21 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0f1528" />
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
<meta name="referrer" content="no-referrer" />
<title>Perfect Postcode — Every neighbourhood in England</title>
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script>
(function() {
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
var theme = localStorage.getItem('theme');
var isDark = theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
// Override theme-color when user has explicit preference
if (theme === 'dark' || theme === 'light') {
var color = theme === 'dark' ? '#0a0e1a' : '#fafaf9';
document.querySelectorAll('meta[name="theme-color"]').forEach(function(m) { m.setAttribute('content', color); });
}
})();
</script>