This commit is contained in:
Andras Schmelczer 2026-02-18 21:22:15 +00:00
parent 524580eb25
commit ffe080adef
82 changed files with 2652 additions and 2956 deletions

View file

@ -1,58 +0,0 @@
import { ChevronIcon } from '../ui/icons';
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
interface AISummaryCardProps {
summary?: string;
loading?: boolean;
error?: string | null;
expanded: boolean;
onToggleExpanded: () => void;
}
export default function AISummaryCard({
summary,
loading,
error,
expanded,
onToggleExpanded,
}: AISummaryCardProps) {
if (!summary && !loading && !error) return null;
return (
<div className="px-3 pt-3 pb-1">
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
<button
onClick={onToggleExpanded}
className="w-full flex items-center justify-between gap-1.5 mb-1.5"
>
<div className="flex items-center gap-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
AI Summary
</span>
</div>
<ChevronIcon
direction={expanded ? 'down' : 'right'}
className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400"
/>
</button>
{expanded && (
<>
{error ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
Failed to generate summary.
</div>
) : loading ? (
<div className="space-y-1.5">
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
</div>
) : (
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">{summary}</p>
)}
</>
)}
</div>
</div>
);
}

View file

@ -22,7 +22,6 @@ import { IconButton } from '../ui/IconButton';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import AISummaryCard from './AISummaryCard';
import StreetViewEmbed from './StreetViewEmbed';
import HistogramLegend from './HistogramLegend';
@ -38,9 +37,6 @@ interface AreaPaneProps {
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
onNavigateToSource?: (slug: string, featureName: string) => void;
aiSummary?: string;
aiSummaryLoading?: boolean;
aiSummaryError?: string | null;
}
export default function AreaPane({
@ -55,16 +51,11 @@ export default function AreaPane({
hexagonLocation,
filters,
onNavigateToSource,
aiSummary,
aiSummaryLoading,
aiSummaryError,
}: AreaPaneProps) {
// For postcodes, use local data for count
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true);
const numericByName = useMemo(() => {
if (!stats) return new Map();
@ -94,7 +85,7 @@ export default function AreaPane({
return (
<div className="flex flex-col h-full">
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="p-3 border-b border-warm-200 dark:border-warm-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div>
@ -133,13 +124,6 @@ export default function AreaPane({
)}
<div className="flex-1 overflow-y-auto">
<AISummaryCard
summary={aiSummary}
loading={aiSummaryLoading}
error={aiSummaryError}
expanded={aiSummaryExpanded}
onToggleExpanded={() => setAiSummaryExpanded(!aiSummaryExpanded)}
/>
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
@ -154,7 +138,6 @@ export default function AreaPane({
const stackedCharts = STACKED_GROUPS[group.name];
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
// Features that are part of a stacked enum config (rendered as compact charts)
const stackedEnumFeatureNames = new Set<string>(
stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
@ -173,11 +156,9 @@ export default function AreaPane({
/>
{isExpanded && (
<div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' &&
stats.price_history &&
(() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(
stats.price_history.map((p) => Math.floor(p.year))
);
@ -191,8 +172,7 @@ export default function AreaPane({
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
? stackedCharts.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
@ -200,7 +180,6 @@ export default function AreaPane({
}))
.filter((s) => s.value > 0);
// Use aggregate feature stats if available, otherwise sum components
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
@ -240,8 +219,7 @@ export default function AreaPane({
</div>
);
})
: // Default: render each feature individually (skip stacked enum features)
group.features
: group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
@ -306,13 +284,11 @@ export default function AreaPane({
return null;
})}
{/* Stacked enum charts */}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
// Single component: render as a stacked bar (like crime charts)
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
@ -355,7 +331,6 @@ export default function AreaPane({
);
}
// Multi-component: render as compact multi-row chart (like risk features)
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);

View file

@ -131,7 +131,7 @@ export function DualHistogram({
);
}
export function SkeletonHistogram() {
function SkeletonHistogram() {
return (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
<div className="flex justify-between items-baseline">

View file

@ -9,9 +9,9 @@ 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 { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
@ -71,26 +71,58 @@ 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">
{showTravelModes && TRANSPORT_MODES.map((mode) => (
<div key={mode} className="shrink-0 border-b border-warm-200 dark:border-warm-700">
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time ({MODE_LABELS[mode]})
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Filter by journey time to a destination
</span>
{showTravelModes && (
<div className="shrink-0">
<CollapsibleGroupHeader
name="Travel Time"
expanded={isSearching || expandedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
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">
{TRANSPORT_MODES.length}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
return (
<div
key={mode}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{MODE_LABELS[mode]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Filter by journey time to a destination
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{fieldKey && (
<IconButton
onClick={() => onTogglePin(fieldKey)}
active={isPinned}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
size="md"
>
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
)}
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
</div>
</div>
</div>
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
<PlusIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
);
})}
</div>
))}
)}
{grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (

View file

@ -20,8 +20,20 @@ import { TravelTimeCard } from './TravelTimeCard';
import {
type TransportMode,
type TravelTimeEntry,
travelFieldKey,
} from '../../hooks/useTravelTime';
type ListingType = 'historical' | 'buy' | 'rent';
const MODE_RESTRICTED_FEATURES: Record<string, Set<ListingType>> = {
'Bathrooms': new Set(['buy', 'rent']),
};
function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean {
const allowed = MODE_RESTRICTED_FEATURES[featureName];
return !allowed || allowed.has(mode);
}
function SliderLabels({
min,
max,
@ -33,7 +45,6 @@ function SliderLabels({
max: number;
value: [number, number];
displayValues?: [number, number];
/** When true and slider is at max, append "+" to indicate unrestricted upper bound */
absoluteMax?: boolean;
}) {
const range = max - min || 1;
@ -72,7 +83,6 @@ interface FiltersProps {
onDragEnd: () => void;
pinnedFeature: string | null;
onTogglePin: (name: string) => void;
onCancelPin: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
@ -102,7 +112,6 @@ export default memo(function Filters({
onDragEnd,
pinnedFeature,
onTogglePin,
onCancelPin: _onCancelPin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
@ -117,37 +126,43 @@ export default memo(function Filters({
aiFilterNotes,
onAiFilterSubmit,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter(
(f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'
);
const listingToggles = useMemo(() => {
const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined;
if (!val) return { historical: true, buy: true, rent: true };
return {
historical: val.includes('Historical sale'),
buy: val.includes('For sale'),
rent: val.includes('For rent'),
};
if (!val || val.length === 0) return 'historical';
if (val.includes('For sale')) return 'buy';
if (val.includes('For rent')) return 'rent';
return 'historical';
}, [filters]);
const handleListingToggle = useCallback(
(key: 'historical' | 'buy' | 'rent') => {
const next = { ...listingToggles, [key]: !listingToggles[key] };
const allOn = next.historical && next.buy && next.rent;
const allOff = !next.historical && !next.buy && !next.rent;
if (allOn || allOff) {
onRemoveFilter('Listing status');
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
[features, enabledFeatures, activeListingType]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
[features, enabledFeatures]
);
const handleListingSelect = useCallback(
(type: ListingType) => {
if (type === activeListingType && !filters['Listing status']) return;
for (const name of Object.keys(filters)) {
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
onRemoveFilter(name);
}
}
if (type === 'historical' && !filters['Listing status']) {
onFilterChange('Listing status', ['Historical sale']);
return;
}
const values: string[] = [];
if (next.historical) values.push('Historical sale');
if (next.buy) values.push('For sale');
if (next.rent) values.push('For rent');
onFilterChange('Listing status', values);
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
rent: 'For rent',
};
onFilterChange('Listing status', [valueMap[type]]);
},
[listingToggles, onFilterChange, onRemoveFilter]
[activeListingType, filters, onFilterChange, onRemoveFilter]
);
const containerRef = useRef<HTMLDivElement>(null);
@ -181,8 +196,7 @@ export default memo(function Filters({
return scales;
}, [features]);
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
const badgeCount = enabledFeatureList.length + activeEntryCount + (hasListingFilter ? 1 : 0);
const badgeCount = enabledFeatureList.length + activeEntryCount;
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
@ -198,16 +212,26 @@ export default memo(function Filters({
</button>
</div>
</div>
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-xs font-medium text-warm-500 dark:text-warm-400">Show</span>
<PillGroup>
<PillToggle label="Historical" active={listingToggles.historical}
onClick={() => handleListingToggle('historical')} size="xs" />
<PillToggle label="Buy" active={listingToggles.buy}
onClick={() => handleListingToggle('buy')} size="xs" />
<PillToggle label="Rent" active={listingToggles.rent}
onClick={() => handleListingToggle('rent')} size="xs" />
</PillGroup>
<div className="shrink-0 px-3 py-2.5 border-b border-warm-200 dark:border-navy-700">
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => {
const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' };
const isActive = activeListingType === type;
return (
<button
key={type}
onClick={() => handleListingSelect(type)}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
isActive
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
>
{labels[type]}
</button>
);
})}
</div>
</div>
<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">
@ -224,20 +248,39 @@ export default memo(function Filters({
</div>
<div className="md:flex-1 md:overflow-y-auto">
{travelTimeEntries.map((entry, index) => (
<div key={index} className="px-2 py-1">
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
dataRange={travelTimeDataRanges.get(index) ?? null}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
{travelTimeEntries.length > 0 && (
<div>
<CollapsibleGroupHeader
name="Travel Time"
expanded={!collapsedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
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">
{travelTimeEntries.length}
</span>
</CollapsibleGroupHeader>
{!collapsedGroups.has('Travel Time') && (
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<TravelTimeCard
key={index}
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
dataRange={travelTimeDataRanges.get(index) ?? null}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
))}
</div>
)}
</div>
)}
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">

View file

@ -29,9 +29,11 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
export default function LocationSearch({
onFlyTo,
onLocationSearched,
onMouseEnter,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onMouseEnter?: () => void;
}) {
const search = useLocationSearch();
const [error, setError] = useState<string | null>(null);
@ -118,8 +120,8 @@ export default function LocationSearch({
}
return (
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col">
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col" onMouseEnter={onMouseEnter}>
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
<PlaceSearchInput
search={search}

View file

@ -1,6 +1,5 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
@ -21,7 +20,7 @@ import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import type { FeatureFilters } from '../../types';
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } from '../../hooks/useTravelTime';
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
interface MapProps {
data: HexagonData[];
@ -40,6 +39,7 @@ interface MapProps {
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
@ -49,11 +49,9 @@ interface MapProps {
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_TRAVEL_RANGES = new globalThis.Map<number, [number, number]>();
interface Dimensions {
width: number;
@ -97,6 +95,7 @@ export default memo(function Map({
onHexagonClick,
onHexagonHover,
initialViewState,
flyToRef,
theme = 'light',
screenshotMode = false,
ogMode = false,
@ -106,12 +105,17 @@ export default memo(function Map({
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
travelTimeColorRanges = EMPTY_TRAVEL_RANGES,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
const [internalViewState, setInternalViewState] = useState<ViewState>(
initialViewState || INITIAL_VIEW_STATE
);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// In screenshot mode, use the prop directly for instant updates (no async lag)
const viewState =
screenshotMode && initialViewState ? initialViewState : internalViewState;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@ -130,8 +134,6 @@ export default memo(function Map({
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
// Send exact viewport bounds - server will filter to only return
// hexagons/postcodes that intersect this precise AABB
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
@ -145,19 +147,14 @@ export default memo(function Map({
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
setViewState(evt.viewState);
setInternalViewState(evt.viewState);
}, []);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const handleMapLoad = useCallback(
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
// Road opacity is set in getMapStyle
},
[]
);
if (flyToRef) flyToRef.current = handleFlyTo;
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
@ -169,7 +166,6 @@ export default memo(function Map({
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
primaryTravelIndex,
} = useDeckLayers({
data,
postcodeData,
@ -187,7 +183,6 @@ export default memo(function Map({
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEntries,
travelTimeColorRanges,
});
return (
@ -195,7 +190,7 @@ export default memo(function Map({
<MapGL
{...viewState}
onMove={handleMove}
onLoad={handleMapLoad as never}
onLoad={undefined}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
@ -222,35 +217,37 @@ export default memo(function Map({
) : null
) : (
<>
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} onMouseEnter={handleMouseLeave} />
{!hideLegend &&
(primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[travelTimeEntries[primaryTravelIndex].mode]})`}
range={travelTimeColorRanges.get(primaryTravelIndex)!}
showCancel={false}
onCancel={onCancelPin}
mode="feature"
theme={theme}
suffix=" min"
/>
) : viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
: colorFeatureMeta.name
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
theme={theme}
suffix=" min"
/>
) : colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
: colorFeatureMeta.name
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
) : null
) : (
<MapLegend
featureLabel="Property density"
featureLabel="Number of properties"
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
@ -16,7 +16,6 @@ import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
@ -28,6 +27,7 @@ import {
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -87,13 +87,9 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
// Mobile state
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
// POI floating panel state
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
// Initialize filters first
const {
filters,
activeFeature,
@ -112,6 +108,7 @@ export default function MapPage({
handleDragChange,
handleDragEnd,
handleTogglePin,
handleSetPin,
handleCancelPin,
updateBoundsInfo,
} = useFilters({
@ -119,7 +116,6 @@ export default function MapPage({
features,
});
// AI filters hook
const aiFilters = useAiFilters();
const handleAiFilterSubmit = useCallback(
async (query: string) => {
@ -129,13 +125,34 @@ export default function MapPage({
[aiFilters.fetchAiFilters, handleSetFilters]
);
// Travel time hook
const travelTime = useTravelTime(initialTravelTime);
// License hook
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string) => {
travelTime.handleSetDestination(index, slug, label);
const entry = travelTime.entries[index];
if (entry) {
handleSetPin(`tt_${entry.mode}_${slug}`);
}
},
[travelTime.handleSetDestination, travelTime.entries, handleSetPin]
);
const handleTravelTimeRemoveEntry = useCallback(
(index: number) => {
const entry = travelTime.entries[index];
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
handleCancelPin();
}
travelTime.handleRemoveEntry(index);
},
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
);
const license = useLicense();
// Map data hook
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapData = useMapData({
filters,
features,
@ -146,19 +163,16 @@ export default function MapPage({
travelTimeEntries: travelTime.entries,
});
// Keep filter bounds in sync with map data
useEffect(() => {
updateBoundsInfo(mapData.bounds, mapData.resolution);
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
// Hexagon selection hook
const selection = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
});
// Location search handler — selects postcode + shows stats
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
@ -171,10 +185,16 @@ export default function MapPage({
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
);
// POI data
const handleZoomToFreeZone = useCallback(() => {
mapFlyToRef.current?.(
INITIAL_VIEW_STATE.latitude,
INITIAL_VIEW_STATE.longitude,
INITIAL_VIEW_STATE.zoom
);
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Compute data range for travel time slider per entry index (full min/max for slider bounds)
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
const ranges = new globalThis.Map<number, [number, number]>();
for (let i = 0; i < travelTime.entries.length; i++) {
@ -193,16 +213,13 @@ export default function MapPage({
return ranges;
}, [travelTime.entries, mapData.data]);
// Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
// Set initial view and tab from URL state
useEffect(() => {
mapData.setInitialView(initialViewState);
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// On mobile, open drawer and switch tab when hexagon is clicked
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
@ -214,7 +231,6 @@ export default function MapPage({
[handleHexagonClick]
);
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const isPostcode = selection.selectedHexagon?.type === 'postcode';
@ -239,19 +255,8 @@ export default function MapPage({
mapData.resolution,
]);
// Tutorial
const tutorial = useTutorial(initialLoading, isMobile);
// AI area summary
const aiSummary = useAreaSummary({
stats: selection.areaStats,
hexagonId: selection.selectedHexagon?.id || null,
isPostcode: selection.selectedHexagon?.type === 'postcode',
filters,
features,
});
// Export to Excel
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(() => {
if (!mapData.bounds || exporting) return;
@ -280,12 +285,10 @@ export default function MapPage({
.finally(() => setExporting(false));
}, [mapData.bounds, filters, features, exporting]);
// Report export state to parent (Header)
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
// Mobile legend data (computed from API-fetched data, which is already viewport-scoped)
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
@ -305,7 +308,6 @@ export default function MapPage({
return [min, max];
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
// Signal screenshot readiness once map data has loaded
useEffect(() => {
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
window.__screenshot_ready = true;
@ -337,13 +339,11 @@ export default function MapPage({
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
</div>
);
}
// Shared pane content renderers
const renderAreaPane = () => (
<AreaPane
stats={selection.areaStats}
@ -362,9 +362,6 @@ export default function MapPage({
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
/>
);
@ -375,7 +372,6 @@ export default function MapPage({
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection}
/>
);
@ -403,14 +399,13 @@ export default function MapPage({
onDragEnd={handleDragEnd}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={travelTime.handleRemoveEntry}
onTravelTimeSetDestination={travelTime.handleSetDestination}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
@ -419,7 +414,6 @@ export default function MapPage({
/>
);
// Mobile layout
if (isMobile) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
@ -434,7 +428,6 @@ export default function MapPage({
</div>
)}
{/* Map — 45% */}
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
<Map
data={mapData.data}
@ -453,6 +446,7 @@ export default function MapPage({
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
@ -460,21 +454,21 @@ export default function MapPage({
bounds={mapData.bounds}
hideLegend
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
{mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
Loading...
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
</div>
</div>
)}
{/* Floating POI button */}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
</button>
{/* Floating POI panel */}
{poiPaneOpen && (
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
@ -482,67 +476,54 @@ export default function MapPage({
)}
</div>
{/* Bottom panel — 55% */}
<div
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
style={{ flex: '55 0 0' }}
>
{/* Legend */}
{(() => {
const primaryIdx = travelTime.entries.findIndex(
(e, i) => e.slug && mapData.travelTimeColorRanges.get(i)
);
if (primaryIdx >= 0) {
return (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[travelTime.entries[primaryIdx].mode]})`}
range={mapData.travelTimeColorRanges.get(primaryIdx)!}
showCancel={false}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
);
}
if (viewFeature && mapData.colorRange && mobileLegendMeta) {
return (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
/>
);
}
return (
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel="Property density"
range={mobileDensityRange}
showCancel={false}
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="density"
mode="feature"
theme={theme}
inline
suffix=" min"
/>
) : mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
/>
);
})()}
{/* Filters content */}
) : null
) : (
<MapLegend
featureLabel="Number of properties"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
<div className="flex-1 min-h-0">
{renderFilters()}
</div>
</div>
{/* Mobile drawer for full-screen hexagon details */}
{mobileDrawerOpen && selection.selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
@ -557,14 +538,13 @@ export default function MapPage({
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
onZoomToFreeZone={handleZoomToFreeZone}
/>
)}
</div>
);
}
// Desktop layout (unchanged)
return (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
@ -589,7 +569,6 @@ export default function MapPage({
disableScrolling
/>
{/* Left Pane */}
<div
data-tutorial="filters"
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
@ -604,7 +583,6 @@ export default function MapPage({
</div>
</div>
{/* Map */}
<div data-tutorial="map" className="flex-1 relative">
<Map
data={mapData.data}
@ -623,17 +601,20 @@ export default function MapPage({
onHexagonClick={selection.handleHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
bounds={mapData.bounds}
travelTimeEntries={travelTime.entries}
travelTimeColorRanges={mapData.travelTimeColorRanges}
/>
{mapData.loading && (
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
Loading...
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
</div>
</div>
)}
{/* Floating POI button */}
@ -652,7 +633,6 @@ export default function MapPage({
)}
</div>
{/* Right Pane */}
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
@ -692,7 +672,7 @@ export default function MapPage({
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
onZoomToFreeZone={handleZoomToFreeZone}
/>
)}
</div>

View file

@ -49,15 +49,30 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
if (arr) arr.push(p.price);
else byYear.set(yr, [p.price]);
}
const meds = Array.from(byYear.entries())
const yearlyMedians = Array.from(byYear.entries())
.map(([yr, prices]) => {
prices.sort((a, b) => a - b);
const mid = Math.floor(prices.length / 2);
const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
return { year: yr + 0.5, price: median };
return { year: yr, price: median };
})
.sort((a, b) => a.year - b.year);
// 3-year rolling average
const meds = yearlyMedians.map((pt, i) => {
let sum = pt.price;
let count = 1;
for (let j = i - 1; j >= 0 && pt.year - yearlyMedians[j].year <= 1; j--) {
sum += yearlyMedians[j].price;
count++;
}
for (let j = i + 1; j < yearlyMedians.length && yearlyMedians[j].year - pt.year <= 1; j++) {
sum += yearlyMedians[j].price;
count++;
}
return { year: pt.year + 0.5, price: sum / count };
});
const ticks = niceTicksForRange(pMin, pMax, 4);
return {

View file

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
@ -13,7 +13,6 @@ interface PropertiesPaneProps {
loading: boolean;
hexagonId: string | null;
onLoadMore: () => void;
onClose: () => void;
onNavigateToSource?: (slug: string) => void;
}
@ -23,7 +22,6 @@ export function PropertiesPane({
loading,
hexagonId,
onLoadMore,
onClose: _onClose,
onNavigateToSource,
}: PropertiesPaneProps) {
const [search, setSearch] = useState('');
@ -123,13 +121,9 @@ function PropertyLoadingSkeleton() {
<div className="space-y-0">
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
{/* Address */}
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
{/* Postcode */}
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Price */}
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Property details grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
@ -141,39 +135,93 @@ function PropertyLoadingSkeleton() {
);
}
const LISTING_STATUS_STYLES: Record<string, string> = {
'For sale': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
'For rent': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
'Historical sale': 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300',
};
function ListingStatusBadge({ status }: { status: string }) {
const style = LISTING_STATUS_STYLES[status] ?? LISTING_STATUS_STYLES['Historical sale'];
return <span className={`text-xs font-medium px-1.5 py-0.5 rounded ${style}`}>{status}</span>;
}
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm');
const estPricePerSqm = getNum(property, 'Est. price per sqm');
const floorArea = getNum(property, 'Total floor area (sqm)');
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)');
const age = getNum(property, 'Approximate construction age');
const rooms = getNum(property, 'Number of bedrooms & living rooms');
const age = getNum(property, 'Construction age');
const transactionDate = getNum(property, 'Date of last transaction');
const councilTax = getNum(property, 'Council tax (£/yr)');
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
const askingPrice = getNum(property, 'Asking price');
const askingRent = getNum(property, 'Asking rent (monthly)');
const bedrooms = getNum(property, 'Bedrooms');
const bathrooms = getNum(property, 'Bathrooms');
const listingDate = getNum(property, 'Listing date');
const listingStatus = property.listing_status;
return (
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
</div>
{listingStatus && <ListingStatusBadge status={listingStatus} />}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
{price !== undefined && (
{property.property_sub_type && (
<div className="text-sm text-warm-600 dark:text-warm-400 mt-1">
{property.property_sub_type}
</div>
)}
{askingPrice !== 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)})
{property.price_qualifier && (
<span className="text-sm font-normal text-warm-500 dark:text-warm-400">
{property.price_qualifier}{' '}
</span>
)}
{pricePerSqm !== undefined && (
£{formatNumber(askingPrice)}
</div>
)}
{askingRent !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{formatNumber(askingRent)}
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">/mo</span>
</div>
)}
{price !== undefined && (
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
{askingPrice !== undefined || askingRent !== undefined ? (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
£{formatNumber(pricePerSqm)}/m²
Last sold: £{formatNumber(price)}
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
</span>
) : (
<>
£{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²
</span>
)}
</>
)}
</div>
)}
@ -213,6 +261,18 @@ function PropertyCard({ property }: { property: Property }) {
{formatNumber(floorArea)}m²
</div>
)}
{bedrooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Bedrooms:</span>{' '}
{formatNumber(bedrooms)}
</div>
)}
{bathrooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Bathrooms:</span>{' '}
{formatNumber(bathrooms)}
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
@ -236,19 +296,30 @@ function PropertyCard({ property }: { property: Property }) {
{property.potential_energy_rating}
</div>
)}
{councilTax !== undefined ? (
{listingDate !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
{formatNumber(councilTax)}/yr
<span className="text-warm-500 dark:text-warm-400">Listed:</span>{' '}
{formatTransactionDate(listingDate)}
</div>
) : councilTaxD !== undefined ? (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
{formatNumber(councilTaxD)}/yr
</div>
) : null}
)}
</div>
{property.listing_features && property.listing_features.length > 0 && (
<div className="mt-2">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Key features</div>
<div className="flex flex-wrap gap-1">
{property.listing_features.map((feature, idx) => (
<span
key={idx}
className="text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
>
{feature}
</span>
))}
</div>
</div>
)}
{property.renovation_history && property.renovation_history.length > 0 && (
<div className="mt-2">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Renovations</div>
@ -265,6 +336,19 @@ function PropertyCard({ property }: { property: Property }) {
</div>
</div>
)}
{property.listing_url && (
<div className="mt-2">
<a
href={property.listing_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
View external listing &rarr;
</a>
</div>
)}
</div>
);
}

View file

@ -3,6 +3,7 @@ import { Slider } from '../ui/Slider';
import { IconButton } from '../ui/IconButton';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { formatFilterValue } from '../../lib/format';
@ -15,6 +16,8 @@ interface TravelTimeCardProps {
label: string;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
isPinned: boolean;
onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void;
onRemove: () => void;
@ -26,6 +29,8 @@ export function TravelTimeCard({
label,
timeRange,
dataRange,
isPinned,
onTogglePin,
onSetDestination,
onTimeRangeChange,
onRemove,
@ -59,7 +64,7 @@ export function TravelTimeCard({
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">
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? '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">
@ -68,9 +73,16 @@ export function TravelTimeCard({
Travel Time ({MODE_LABELS[mode]})
</span>
</div>
<IconButton onClick={() => onRemove()} title="Remove travel time">
<CloseIcon className="w-3.5 h-3.5" />
</IconButton>
<div className="flex items-center gap-0.5">
{slug && (
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title="Remove travel time">
<CloseIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
</div>
{/* Destination search */}
@ -81,6 +93,7 @@ export function TravelTimeCard({
placeholder={slug ? 'Change destination...' : 'Search destination...'}
size="xs"
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
portal
/>
{slug && label && (