More
This commit is contained in:
parent
1f68ca0512
commit
3599803589
43 changed files with 3578 additions and 262 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
172
frontend/src/components/map/TravelTimeCard.tsx
Normal file
172
frontend/src/components/map/TravelTimeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue