vibes
This commit is contained in:
parent
c997ea46a5
commit
30055ab870
13 changed files with 165 additions and 83 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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're looking for
|
describe what you'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
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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('|');
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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)': (
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue