This commit is contained in:
Andras Schmelczer 2026-03-25 08:05:50 +00:00
parent c997ea46a5
commit 30055ab870
13 changed files with 165 additions and 83 deletions

View file

@ -24,6 +24,7 @@ import { useSavedProperties } from './hooks/useSavedProperties';
declare global { declare global {
interface Window { interface Window {
__screenshot_ready?: boolean; __screenshot_ready?: boolean;
__map_idle?: boolean;
} }
} }

View file

@ -1,6 +1,7 @@
import { memo, useState, useCallback, useEffect, useRef } from 'react'; import { memo, useState, useCallback, useEffect, useRef } from 'react';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { SparklesIcon } from '../ui/icons/SparklesIcon'; import { SparklesIcon } from '../ui/icons/SparklesIcon';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import type { AiFilterErrorType } from '../../hooks/useAiFilters'; import type { AiFilterErrorType } from '../../hooks/useAiFilters';
const EXAMPLE_QUERIES = [ const EXAMPLE_QUERIES = [
@ -65,18 +66,45 @@ export default memo(function AiFilterInput({
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const loadingMessage = useLoadingMessage(loading); const loadingMessage = useLoadingMessage(loading);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const queryRef = useRef(query);
queryRef.current = query;
useEffect(() => { useEffect(() => {
if (!expanded || loading) return; if (!expanded || loading) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setExpanded(false); if (!queryRef.current.trim()) setExpanded(false);
} }
}; };
document.addEventListener('mousedown', handler); document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [expanded, loading]); }, [expanded, loading]);
const resizeTextarea = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight}px`;
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed || loading) return;
if (!isLoggedIn) {
onLoginRequired();
return;
}
onSubmit(trimmed);
}
},
[query, loading, isLoggedIn, onLoginRequired, onSubmit]
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -132,14 +160,27 @@ export default memo(function AiFilterInput({
<span className="text-xs text-warm-400 dark:text-warm-500"> <span className="text-xs text-warm-400 dark:text-warm-500">
describe what you&apos;re looking for describe what you&apos;re looking for
</span> </span>
<button
type="button"
onClick={() => setExpanded(false)}
className="ml-auto text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-300 cursor-pointer"
>
<ChevronIcon direction="up" className="w-3.5 h-3.5" />
</button>
</div> </div>
<form onSubmit={handleSubmit} className="flex items-center gap-1.5"> <form onSubmit={handleSubmit} className="flex items-end gap-1.5">
<input <textarea
type="text" ref={textareaRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
setQuery(e.target.value);
resizeTextarea();
}}
onKeyDown={handleKeyDown}
placeholder="e.g. quiet area, under 400k, near good schools..." placeholder="e.g. quiet area, under 400k, near good schools..."
className="flex-1 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400 focus:bg-white dark:focus:bg-warm-800" className="flex-1 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400 focus:bg-white dark:focus:bg-warm-800 resize-none overflow-hidden"
rows={1}
style={{ maxHeight: '6rem' }}
disabled={loading} disabled={loading}
autoFocus autoFocus
/> />

View file

@ -182,7 +182,7 @@ interface FiltersProps {
travelTimeEntries: TravelTimeEntry[]; travelTimeEntries: TravelTimeEntry[];
onTravelTimeAddEntry: (mode: TransportMode) => void; onTravelTimeAddEntry: (mode: TransportMode) => void;
onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string, lat: number, lon: number) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void; onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void; onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void; onTravelTimeToggleBest: (index: number) => void;
@ -475,7 +475,7 @@ export default memo(function Filters({
isActive={activeFeature === travelFieldKey(entry)} isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null} dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))} onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)} onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))} onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange} onDragChange={onDragChange}

View file

@ -108,7 +108,7 @@ export default function LocationSearch({
<button <button
type="button" type="button"
onClick={() => setExpanded(true)} onClick={() => setExpanded(true)}
className="absolute top-3 left-3 z-10 p-2 bg-white dark:bg-warm-800 rounded shadow-lg" className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg pointer-events-auto"
aria-label="Search places or postcodes" aria-label="Search places or postcodes"
> >
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" /> <SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
@ -120,7 +120,7 @@ export default function LocationSearch({
<div <div
ref={containerRef} ref={containerRef}
data-tutorial="search" data-tutorial="search"
className="absolute top-3 left-3 z-10 flex flex-col" className="flex flex-col pointer-events-auto"
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
> >
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800"> <div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">

View file

@ -200,6 +200,7 @@ export default memo(function Map({
{...viewState} {...viewState}
onMove={handleMove} onMove={handleMove}
onLoad={undefined} onLoad={undefined}
onIdle={screenshotMode ? () => { window.__map_idle = true; } : undefined}
mapStyle={mapStyle} mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
attributionControl={false} attributionControl={false}
@ -223,10 +224,7 @@ export default memo(function Map({
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10"> <div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10">
<LogoIcon className="w-24 h-24 text-teal-400" /> <LogoIcon className="w-24 h-24 text-teal-400" />
<span <span className="font-bold text-white whitespace-nowrap" style={{ fontSize: '5rem' }}>
className="font-bold text-white"
style={{ fontSize: '5.5rem', letterSpacing: '-0.03em' }}
>
Your perfect postcode Your perfect postcode
</span> </span>
</div> </div>
@ -263,55 +261,57 @@ export default memo(function Map({
) : null ) : null
) : ( ) : (
<> <>
<LocationSearch <div className="absolute top-3 left-3 right-3 z-10 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
onFlyTo={handleFlyTo} <LocationSearch
onLocationSearched={onLocationSearched} onFlyTo={handleFlyTo}
onMouseEnter={handleMouseLeave} onLocationSearched={onLocationSearched}
/> onMouseEnter={handleMouseLeave}
{!hideLegend && />
(viewFeature && colorRange ? ( {!hideLegend &&
viewFeature.startsWith('tt_') ? ( (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}
raw={colorFeatureMeta.raw}
/>
) : null
) : (
<MapLegend <MapLegend
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`} featureLabel="Number of properties"
range={colorRange} range={
showCancel={viewSource === 'eye'} usePostcodeView
onCancel={onCancelPin} ? [postcodeCountRange.min, postcodeCountRange.max]
mode="feature" : [countRange.min, countRange.max]
theme={theme}
suffix=" min"
/>
) : colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
: colorFeatureMeta.name
} }
range={colorRange} showCancel={false}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin} onCancel={onCancelPin}
mode="feature" mode="density"
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
}
theme={theme} theme={theme}
raw={colorFeatureMeta.raw}
/> />
) : null ))}
) : ( </div>
<MapLegend
featureLabel="Number of properties"
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
/>
))}
{popupInfo && ( {popupInfo && (
<div <div
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white" className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"

View file

@ -125,7 +125,7 @@ export default function MapLegend({
} }
return ( return (
<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="bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[300px] pointer-events-auto">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span> <span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
{showCancel && ( {showCancel && (

View file

@ -39,6 +39,7 @@ import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts'; import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense'; import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal'; import UpgradeModal from '../ui/UpgradeModal';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
@ -203,13 +204,6 @@ export default function MapPage({
] ]
); );
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string) => {
travelTime.handleSetDestination(index, slug, label);
},
[travelTime.handleSetDestination]
);
const handleTravelTimeRemoveEntry = useCallback( const handleTravelTimeRemoveEntry = useCallback(
(index: number) => { (index: number) => {
const entry = travelTime.entries[index]; const entry = travelTime.entries[index];
@ -241,6 +235,16 @@ export default function MapPage({
travelTimeEntries: travelTime.entries, travelTimeEntries: travelTime.entries,
}); });
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string, lat: number, lon: number) => {
travelTime.handleSetDestination(index, slug, label);
if (slug) {
mapFlyToRef.current?.(lat, lon, mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom);
}
},
[travelTime.handleSetDestination, mapData.currentView?.zoom]
);
// First transit destination — used to pick the best central_postcode for journey display // First transit destination — used to pick the best central_postcode for journey display
const journeyDest = useMemo(() => { const journeyDest = useMemo(() => {
const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug); const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug);
@ -439,14 +443,22 @@ export default function MapPage({
? mapData.postcodeData.length > 0 ? mapData.postcodeData.length > 0
: mapData.data.length > 0; : mapData.data.length > 0;
if (hasData) { if (hasData) {
// Wait for deck.gl to actually paint: in interleaved MapboxOverlay mode, // Wait for both deck.gl data AND MapLibre base map tile rendering.
// hexagons render during MapLibre's rAF cycle. Double-rAF ensures at // __map_idle is set by Map's onIdle callback, which fires after all
// least one full paint has completed before we signal readiness. // tiles are loaded and rendered — critical for SwiftShader where
requestAnimationFrame(() => { // edge tiles can lag behind the center.
requestAnimationFrame(() => { const waitAndSignal = () => {
window.__screenshot_ready = true; if (window.__map_idle) {
}); requestAnimationFrame(() => {
}); requestAnimationFrame(() => {
window.__screenshot_ready = true;
});
});
} else {
requestAnimationFrame(waitAndSignal);
}
};
waitAndSignal();
} }
} }
}, [ }, [
@ -854,6 +866,13 @@ export default function MapPage({
isActive={selection.rightPaneTab === 'properties'} isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick} onClick={selection.handlePropertiesTabClick}
/> />
<button
onClick={selection.handleCloseSelection}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
>
<CloseIcon className="w-4 h-4" />
</button>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">

View file

@ -22,7 +22,7 @@ interface TravelTimeCardProps {
isActive: boolean; isActive: boolean;
dragValue: [number, number] | null; dragValue: [number, number] | null;
onTogglePin: () => void; onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void; onSetDestination: (slug: string, label: string, lat: number, lon: number) => void;
onTimeRangeChange: (range: [number, number]) => void; onTimeRangeChange: (range: [number, number]) => void;
onDragStart: () => void; onDragStart: () => void;
onDragChange: (value: [number, number]) => void; onDragChange: (value: [number, number]) => void;
@ -54,8 +54,8 @@ export function TravelTimeCard({
const [showBestInfo, setShowBestInfo] = useState(false); const [showBestInfo, setShowBestInfo] = useState(false);
const handleDestinationSelect = useCallback( const handleDestinationSelect = useCallback(
(selectedSlug: string, selectedLabel: string) => { (selectedSlug: string, selectedLabel: string, lat: number, lon: number) => {
onSetDestination(selectedSlug, selectedLabel); onSetDestination(selectedSlug, selectedLabel, lat, lon);
}, },
[onSetDestination] [onSetDestination]
); );
@ -103,7 +103,7 @@ export function TravelTimeCard({
loading={destinationsLoading} loading={destinationsLoading}
onSelect={handleDestinationSelect} onSelect={handleDestinationSelect}
value={label || undefined} value={label || undefined}
onClear={() => onSetDestination('', '')} onClear={() => onSetDestination('', '', 0, 0)}
placeholder="Select destination..." placeholder="Select destination..."
/> />

View file

@ -9,7 +9,7 @@ import { CloseIcon } from './icons/CloseIcon';
interface DestinationDropdownProps { interface DestinationDropdownProps {
destinations: Destination[]; destinations: Destination[];
loading: boolean; loading: boolean;
onSelect: (slug: string, label: string) => void; onSelect: (slug: string, label: string, lat: number, lon: number) => void;
onClear?: () => void; onClear?: () => void;
value?: string; value?: string;
placeholder?: string; placeholder?: string;
@ -66,7 +66,7 @@ export function DestinationDropdown({
const handleSelect = useCallback( const handleSelect = useCallback(
(dest: Destination) => { (dest: Destination) => {
onSelect(dest.slug, dest.name); onSelect(dest.slug, dest.name, dest.lat, dest.lon);
setOpen(false); setOpen(false);
setFilter(''); setFilter('');
setActiveIndex(-1); setActiveIndex(-1);

View file

@ -82,7 +82,9 @@ export function useMapData({
// Build the travel param string from entries with destinations. // Build the travel param string from entries with destinations.
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max]. // Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
// When excludeFieldKey is set, that entry's time range is omitted (for drag preview). // When excludeFieldKey is set, that entry uses a wide range (0:1440) instead of
// the committed range. This still filters out rows with no travel data (the server
// skips rows where minutes=None when any range is set) while including all actual values.
const buildTravelParam = useCallback( const buildTravelParam = useCallback(
(excludeFieldKey?: string): string => { (excludeFieldKey?: string): string => {
const segments: string[] = []; const segments: string[] = [];
@ -91,7 +93,11 @@ export function useMapData({
let seg = `${entry.mode}:${entry.slug}`; let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best'; if (entry.useBest) seg += ':best';
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`; const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
if (entry.timeRange && !isExcluded) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`; if (isExcluded) {
seg += ':0:1440';
} else if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(seg); segments.push(seg);
} }
return segments.join('|'); return segments.join('|');

View file

@ -7,6 +7,8 @@ export interface Destination {
slug: string; slug: string;
place_type: string; place_type: string;
city?: string; city?: string;
lat: number;
lon: number;
} }
/** Fetches all travel-time destinations for a mode once, with client-side caching. */ /** Fetches all travel-time destinations for a mode once, with client-side caching. */

View file

@ -181,6 +181,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" /> <path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</> </>
), ),
'Good+ primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Good+ secondary schools within 2km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
// ── Deprivation ────────────────────────────── // ── Deprivation ──────────────────────────────
'Income Score (rate)': ( 'Income Score (rate)': (

View file

@ -170,7 +170,7 @@ export function summarizeParams(queryString: string): string {
const colonIdx = entry.indexOf(':'); const colonIdx = entry.indexOf(':');
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry; return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
}) })
.filter(Boolean); .filter((n) => n && n !== 'Listing status');
if (filterNames.length > 0) { if (filterNames.length > 0) {
parts.push( parts.push(
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters` filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`