All changes
This commit is contained in:
parent
593f380581
commit
49f7ec2f5a
60 changed files with 1783 additions and 679 deletions
|
|
@ -1,15 +1,6 @@
|
|||
import { memo, useState, useCallback } from 'react';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
function SparklesIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
|
||||
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
|
||||
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
import { SparklesIcon } from '../ui/icons/SparklesIcon';
|
||||
|
||||
interface AiFilterInputProps {
|
||||
loading: boolean;
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export default function AreaPane({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
/>
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { FeatureLabel } from '../ui/FeatureLabel';
|
|||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
car: CarIcon,
|
||||
|
|
@ -89,9 +89,9 @@ export default function FeatureBrowser({
|
|||
name="Travel Time"
|
||||
expanded={isSearching || expandedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{TRANSPORT_MODES.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
|
|
@ -109,7 +109,7 @@ export default function FeatureBrowser({
|
|||
{MODE_LABELS[mode]}
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
Filter by journey time to a destination
|
||||
{MODE_DESCRIPTIONS[mode]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -131,9 +131,9 @@ export default function FeatureBrowser({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
|
|
|
|||
|
|
@ -313,16 +313,16 @@ export default memo(function Filters({
|
|||
name="Travel Time"
|
||||
expanded={!collapsedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
<span className="text-xs 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) => (
|
||||
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-7">
|
||||
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
|
|
@ -357,9 +357,9 @@ export default memo(function Filters({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
|
|
@ -373,7 +373,7 @@ export default memo(function Filters({
|
|||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
||||
|
|
@ -430,7 +430,7 @@ export default memo(function Filters({
|
|||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import type { Page } from '../ui/Header';
|
||||
import Map from './Map';
|
||||
|
|
@ -56,9 +56,14 @@ interface MapPageProps {
|
|||
ogMode?: boolean;
|
||||
isMobile?: boolean;
|
||||
initialTravelTime?: TravelTimeInitial;
|
||||
initialPostcode?: string;
|
||||
user?: { id: string; subscription: string } | null;
|
||||
onLoginClick?: () => void;
|
||||
onRegisterClick?: () => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
||||
}
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -78,9 +83,14 @@ export default function MapPage({
|
|||
ogMode,
|
||||
isMobile = false,
|
||||
initialTravelTime,
|
||||
initialPostcode,
|
||||
user,
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onSaveProperty,
|
||||
onUnsaveProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
}: MapPageProps) {
|
||||
const [selectedPOICategories, setSelectedPOICategories] =
|
||||
useState<Set<string>>(initialPOICategories);
|
||||
|
|
@ -202,6 +212,31 @@ export default function MapPage({
|
|||
selection.setRightPaneTab(initialTab);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Navigate to a specific postcode on mount (e.g. from saved properties)
|
||||
useEffect(() => {
|
||||
if (!initialPostcode) return;
|
||||
// Strip the `pc` param from the URL so it doesn't persist
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('pc');
|
||||
const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard';
|
||||
window.history.replaceState(window.history.state, '', newUrl);
|
||||
|
||||
// Fetch postcode geometry and fly to it
|
||||
fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Postcode not found');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry }) => {
|
||||
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
|
||||
selection.handleLocationSearch(data.postcode, data.geometry);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail — postcode might not exist
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Prevent browser back/forward navigation from horizontal trackpad swipes
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
|
|
@ -381,6 +416,10 @@ export default function MapPage({
|
|||
loading={selection.loadingProperties}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
onLoadMore={selection.handleLoadMoreProperties}
|
||||
onSaveProperty={onSaveProperty}
|
||||
onUnsaveProperty={onUnsaveProperty}
|
||||
isPropertySaved={isPropertySaved}
|
||||
getSavedPropertyId={getSavedPropertyId}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { Property } from '../../types';
|
||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||
import { getNum } from '../../lib/property-fields';
|
||||
|
|
@ -6,6 +6,7 @@ import InfoPopup from '../ui/InfoPopup';
|
|||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { InfoIcon } from '../ui/icons';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
properties: Property[];
|
||||
|
|
@ -14,6 +15,10 @@ interface PropertiesPaneProps {
|
|||
hexagonId: string | null;
|
||||
onLoadMore: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
||||
}
|
||||
|
||||
export function PropertiesPane({
|
||||
|
|
@ -23,6 +28,10 @@ export function PropertiesPane({
|
|||
hexagonId,
|
||||
onLoadMore,
|
||||
onNavigateToSource,
|
||||
onSaveProperty,
|
||||
onUnsaveProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
}: PropertiesPaneProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
|
@ -70,7 +79,7 @@ export function PropertiesPane({
|
|||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
|
||||
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
|
||||
ratings, construction age, and tenure from EPC surveys, plus the most recent sale price
|
||||
ratings, construction year, and tenure from EPC surveys, plus the most recent sale price
|
||||
from the Land Registry.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
|
|
@ -91,7 +100,14 @@ export function PropertiesPane({
|
|||
) : (
|
||||
<>
|
||||
{filtered.map((property, idx) => (
|
||||
<PropertyCard key={idx} property={property} />
|
||||
<PropertyCard
|
||||
key={idx}
|
||||
property={property}
|
||||
onSave={onSaveProperty}
|
||||
onUnsave={onUnsaveProperty}
|
||||
isSaved={isPropertySaved?.(property.address, property.postcode)}
|
||||
savedId={getSavedPropertyId?.(property.address, property.postcode)}
|
||||
/>
|
||||
))}
|
||||
{properties.length < total && (
|
||||
<button
|
||||
|
|
@ -135,14 +151,33 @@ function PropertyLoadingSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
function PropertyCard({
|
||||
property,
|
||||
onSave,
|
||||
onUnsave,
|
||||
isSaved,
|
||||
savedId,
|
||||
}: {
|
||||
property: Property;
|
||||
onSave?: (property: Property) => void;
|
||||
onUnsave?: (id: string) => void;
|
||||
isSaved?: boolean;
|
||||
savedId?: string;
|
||||
}) {
|
||||
const handleToggleSave = useCallback(() => {
|
||||
if (isSaved && savedId && onUnsave) {
|
||||
onUnsave(savedId);
|
||||
} else if (onSave) {
|
||||
onSave(property);
|
||||
}
|
||||
}, [isSaved, savedId, onSave, onUnsave, 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, 'Number of bedrooms & living rooms');
|
||||
const age = getNum(property, 'Construction age');
|
||||
const age = getNum(property, 'Construction year');
|
||||
const transactionDate = getNum(property, 'Date of last transaction');
|
||||
const askingPrice = getNum(property, 'Asking price');
|
||||
const askingRent = getNum(property, 'Asking rent (monthly)');
|
||||
|
|
@ -152,11 +187,26 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
|
||||
return (
|
||||
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
||||
<div>
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
{onSave && (
|
||||
<button
|
||||
onClick={handleToggleSave}
|
||||
className={`shrink-0 p-1 rounded ${
|
||||
isSaved
|
||||
? 'text-teal-600 dark:text-teal-400'
|
||||
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
|
||||
}`}
|
||||
title={isSaved ? 'Unsave property' : 'Save property'}
|
||||
>
|
||||
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.property_sub_type && (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import InfoPopup from '../ui/InfoPopup';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
import { CarIcon } from '../ui/icons/CarIcon';
|
||||
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
||||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||
|
|
@ -52,6 +51,7 @@ export function TravelTimeCard({
|
|||
onRemove,
|
||||
}: TravelTimeCardProps) {
|
||||
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [showBestInfo, setShowBestInfo] = useState(false);
|
||||
|
||||
const handleDestinationSelect = useCallback(
|
||||
|
|
@ -78,6 +78,9 @@ export function TravelTimeCard({
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
{slug && (
|
||||
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
|
|
@ -90,28 +93,14 @@ export function TravelTimeCard({
|
|||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
{slug && label ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
|
||||
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
|
||||
<span className="text-xs text-navy-950 dark:text-warm-200 flex-1 truncate">
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onSetDestination('', '')}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Clear destination"
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<DestinationDropdown
|
||||
destinations={destinations}
|
||||
loading={destinationsLoading}
|
||||
onSelect={handleDestinationSelect}
|
||||
placeholder="Select destination..."
|
||||
/>
|
||||
)}
|
||||
<DestinationDropdown
|
||||
destinations={destinations}
|
||||
loading={destinationsLoading}
|
||||
onSelect={handleDestinationSelect}
|
||||
value={label || undefined}
|
||||
onClear={() => onSetDestination('', '')}
|
||||
placeholder="Select destination..."
|
||||
/>
|
||||
|
||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||
{slug && mode === 'transit' && (
|
||||
|
|
@ -123,10 +112,26 @@ export function TravelTimeCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showInfo && (
|
||||
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={() => setShowInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Shows how long it takes to reach the selected destination from each area
|
||||
{mode === 'transit'
|
||||
? ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.'
|
||||
: mode === 'car'
|
||||
? ' by car, based on typical road speeds and the road network.'
|
||||
: mode === 'bicycle'
|
||||
? ' by bicycle, using cycle-friendly routes.'
|
||||
: ' on foot, using pedestrian paths and pavements.'}
|
||||
{' '}Use the slider to filter areas within your preferred commute time.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{showBestInfo && (
|
||||
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Uses the <strong>5th percentile</strong> travel time — the fastest realistic journey
|
||||
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey
|
||||
if you time your departure to catch optimal connections. The default uses the{' '}
|
||||
<strong>median</strong>, representing a typical journey regardless of when you leave.
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue