vibes
This commit is contained in:
parent
80c093b7ba
commit
f72c43a9fa
101 changed files with 2168 additions and 1177 deletions
|
|
@ -114,7 +114,9 @@ export default memo(function AiFilterInput({
|
|||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">AI Search</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">— describe what you're looking for</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
— describe what you're looking for
|
||||
</span>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
|
|
@ -141,11 +143,7 @@ export default memo(function AiFilterInput({
|
|||
)}
|
||||
</button>
|
||||
</form>
|
||||
{loading && (
|
||||
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
|
||||
{loadingMessage}
|
||||
</p>
|
||||
)}
|
||||
{loading && <p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{loadingMessage}</p>}
|
||||
{showExamples && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{EXAMPLE_QUERIES.map((example) => (
|
||||
|
|
@ -162,12 +160,13 @@ export default memo(function AiFilterInput({
|
|||
)}
|
||||
{error && errorType === 'verification' && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
Please verify your email address to use AI-powered search. Check your inbox for a verification link.
|
||||
Please verify your email address to use AI-powered search. Check your inbox for a
|
||||
verification link.
|
||||
</p>
|
||||
)}
|
||||
{error && errorType === 'limit' && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||
</p>
|
||||
)}
|
||||
{error && errorType === 'error' && (
|
||||
|
|
@ -176,14 +175,10 @@ export default memo(function AiFilterInput({
|
|||
</p>
|
||||
)}
|
||||
{summary && !error && !loading && (
|
||||
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
|
||||
{summary}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{summary}</p>
|
||||
)}
|
||||
{notes && !error && !loading && (
|
||||
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">
|
||||
{notes}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">{notes}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
|
|
@ -107,8 +107,8 @@ export default function AreaPane({
|
|||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
Stats for {isPostcode ? 'current and historical' : 'all'} properties
|
||||
in this {isPostcode ? 'postcode' : 'area'}
|
||||
Stats for {isPostcode ? 'current and historical' : 'all'} properties in this{' '}
|
||||
{isPostcode ? 'postcode' : 'area'}
|
||||
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
|
||||
</p>
|
||||
{stats && stats.count > 0 && (
|
||||
|
|
@ -142,15 +142,11 @@ export default function AreaPane({
|
|||
<HistogramLegend />
|
||||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(
|
||||
stats.price_history.map((p) => Math.floor(p.year))
|
||||
);
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
Price History
|
||||
</span>
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,12 +45,7 @@ export default function ExternalSearchLinks({
|
|||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{urls.rightmove ? (
|
||||
<a
|
||||
href={urls.rightmove}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
Rightmove
|
||||
</a>
|
||||
) : (
|
||||
|
|
@ -58,20 +53,10 @@ export default function ExternalSearchLinks({
|
|||
Rightmove
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={urls.onthemarket}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
<a href={urls.onthemarket} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
OnTheMarket
|
||||
</a>
|
||||
<a
|
||||
href={urls.zoopla}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
Zoopla
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ import { FeatureActions } from '../ui/FeatureIcons';
|
|||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, 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,
|
||||
|
|
@ -45,7 +51,7 @@ export default function FeatureBrowser({
|
|||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
travelTimeEntries,
|
||||
travelTimeEntries: _travelTimeEntries,
|
||||
onAddTravelTimeEntry,
|
||||
isLicensed,
|
||||
onUpgradeClick,
|
||||
|
|
@ -77,12 +83,13 @@ export default function FeatureBrowser({
|
|||
// Only show modes that have precomputed travel time data
|
||||
const visibleModes = useMemo(
|
||||
() => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []),
|
||||
[availableTravelModes],
|
||||
[availableTravelModes]
|
||||
);
|
||||
|
||||
const showTravelModes =
|
||||
visibleModes.length > 0 &&
|
||||
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
|
||||
(!search ||
|
||||
'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -102,36 +109,40 @@ export default function FeatureBrowser({
|
|||
{visibleModes.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{(isSearching || expandedGroups.has('Travel Time')) && visibleModes.map((mode) => {
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-center 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)}>
|
||||
<ModeIcon 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">
|
||||
{MODE_DESCRIPTIONS[mode]}
|
||||
</span>
|
||||
{(isSearching || expandedGroups.has('Travel Time')) &&
|
||||
visibleModes.map((mode) => {
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-center 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)}
|
||||
>
|
||||
<ModeIcon 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">
|
||||
{MODE_DESCRIPTIONS[mode]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => onAddTravelTimeEntry(mode)}
|
||||
title={`Add ${MODE_LABELS[mode]} travel time`}
|
||||
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
||||
>
|
||||
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => onAddTravelTimeEntry(mode)}
|
||||
title={`Add ${MODE_LABELS[mode]} travel time`}
|
||||
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
||||
>
|
||||
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{grouped.map((group) => {
|
||||
|
|
@ -203,8 +214,15 @@ export default function FeatureBrowser({
|
|||
>
|
||||
Upgrade to full map
|
||||
</button>
|
||||
<svg viewBox="0 120 1600 230" className="w-full mt-4 block shrink-0" preserveAspectRatio="xMidYMax meet">
|
||||
<path d="M0,350 C400,150 1200,150 1600,350 Z" className="fill-green-500 dark:fill-green-600" />
|
||||
<svg
|
||||
viewBox="0 120 1600 230"
|
||||
className="w-full mt-4 block shrink-0"
|
||||
preserveAspectRatio="xMidYMax meet"
|
||||
>
|
||||
<path
|
||||
d="M0,350 C400,150 1200,150 1600,350 Z"
|
||||
className="fill-green-500 dark:fill-green-600"
|
||||
/>
|
||||
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
|
||||
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
|
||||
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
||||
|
|
|
|||
|
|
@ -49,16 +49,10 @@ function SliderLabels({
|
|||
const labels = displayValues || value;
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
<span className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }}>
|
||||
{isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
|
||||
</span>
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${rightPct}%` }}
|
||||
>
|
||||
<span className="absolute -translate-x-1/2" style={{ left: `${rightPct}%` }}>
|
||||
{isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -175,7 +169,8 @@ export default memo(function Filters({
|
|||
}, [filters]);
|
||||
|
||||
const availableFeatures = useMemo(
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
|
||||
() =>
|
||||
features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType, isAllowed]
|
||||
);
|
||||
const enabledFeatureList = useMemo(
|
||||
|
|
@ -271,7 +266,10 @@ export default memo(function Filters({
|
|||
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">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full"
|
||||
>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -287,7 +285,16 @@ export default memo(function Filters({
|
|||
</div>
|
||||
|
||||
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
|
||||
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} errorType={aiFilterErrorType} notes={aiFilterNotes} summary={aiFilterSummary} onSubmit={onAiFilterSubmit} isLoggedIn={isLoggedIn} onLoginRequired={onLoginRequired} />
|
||||
<AiFilterInput
|
||||
loading={aiFilterLoading}
|
||||
error={aiFilterError}
|
||||
errorType={aiFilterErrorType}
|
||||
notes={aiFilterNotes}
|
||||
summary={aiFilterSummary}
|
||||
onSubmit={onAiFilterSubmit}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onLoginRequired={onLoginRequired}
|
||||
/>
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
|
|
@ -332,19 +339,21 @@ export default memo(function Filters({
|
|||
<div className="px-2 py-1 space-y-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) =>
|
||||
onTravelTimeSetDestination(index, slug, label)
|
||||
}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -385,7 +394,11 @@ export default memo(function Filters({
|
|||
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" />
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
size="sm"
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
|
|
@ -419,7 +432,10 @@ export default memo(function Filters({
|
|||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[feature.name] as [number, number]) || [hist?.min ?? feature.min!, hist?.max ?? feature.max!];
|
||||
: (filters[feature.name] as [number, number]) || [
|
||||
hist?.min ?? feature.min!,
|
||||
hist?.max ?? feature.max!,
|
||||
];
|
||||
const scale = percentileScales.get(feature.name);
|
||||
const dataMin = hist?.min ?? feature.min!;
|
||||
const dataMax = hist?.max ?? feature.max!;
|
||||
|
|
@ -442,7 +458,12 @@ export default memo(function Filters({
|
|||
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" />
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
|
|
@ -454,7 +475,9 @@ export default memo(function Filters({
|
|||
<Slider
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||
step={
|
||||
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
|
||||
}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
|
|
@ -462,14 +485,19 @@ export default memo(function Filters({
|
|||
const step = feature.step ?? 1;
|
||||
const snap = (v: number) => Math.round(v / step) * step;
|
||||
onDragChange([
|
||||
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
|
||||
pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)),
|
||||
pMin <= 0
|
||||
? (hist?.min ?? feature.min!)
|
||||
: snap(scale.toValue(pMin)),
|
||||
pMax >= 100
|
||||
? (hist?.max ?? feature.max!)
|
||||
: snap(scale.toValue(pMax)),
|
||||
]);
|
||||
}
|
||||
: ([min, max]) => onDragChange([
|
||||
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
|
||||
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
|
||||
])
|
||||
: ([min, max]) =>
|
||||
onDragChange([
|
||||
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
|
||||
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(feature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
|
|
@ -521,8 +549,8 @@ export default memo(function Filters({
|
|||
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Start with your must-haves, then layer on nice-to-haves.
|
||||
The map narrows down as you add filters — the areas that survive are your best matches.
|
||||
Start with your must-haves, then layer on nice-to-haves. The map narrows down as you
|
||||
add filters — the areas that survive are your best matches.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
|
|
@ -530,9 +558,9 @@ export default memo(function Filters({
|
|||
1. Budget & property basics
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Set your price range, minimum floor area, and property type.
|
||||
If you need a lease over freehold (or vice versa), filter for that too.
|
||||
This eliminates most of the map immediately.
|
||||
Set your price range, minimum floor area, and property type. If you need a lease
|
||||
over freehold (or vice versa), filter for that too. This eliminates most of the map
|
||||
immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -541,9 +569,9 @@ export default memo(function Filters({
|
|||
2. Commute & transport
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Add a travel time filter to your workplace — choose public transport or cycling
|
||||
and set your maximum tolerable commute. You can also filter by
|
||||
how many stations are within walking distance.
|
||||
Add a travel time filter to your workplace — choose public transport or
|
||||
cycling and set your maximum tolerable commute. You can also filter by how many
|
||||
stations are within walking distance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -552,9 +580,9 @@ export default memo(function Filters({
|
|||
3. Safety & environment
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Use the crime filters to cap serious or minor crime rates.
|
||||
Check road noise levels if you're a light sleeper, and
|
||||
environmental risk filters for ground stability concerns.
|
||||
Use the crime filters to cap serious or minor crime rates. Check road noise levels
|
||||
if you're a light sleeper, and environmental risk filters for ground stability
|
||||
concerns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -563,9 +591,9 @@ export default memo(function Filters({
|
|||
4. Schools & education
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Filter by the number of Ofsted-rated Good or Outstanding primary and
|
||||
secondary schools nearby. The education deprivation score captures
|
||||
broader area-level attainment.
|
||||
Filter by the number of Ofsted-rated Good or Outstanding primary and secondary
|
||||
schools nearby. The education deprivation score captures broader area-level
|
||||
attainment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -574,9 +602,8 @@ export default memo(function Filters({
|
|||
5. Lifestyle & amenities
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Want restaurants, parks, or grocery shops within walking distance?
|
||||
Filter by nearby amenity counts. Broadband speed filters help if
|
||||
you work from home.
|
||||
Want restaurants, parks, or grocery shops within walking distance? Filter by nearby
|
||||
amenity counts. Broadband speed filters help if you work from home.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -585,16 +612,15 @@ export default memo(function Filters({
|
|||
6. Energy & running costs
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
EPC ratings from A to G indicate energy efficiency.
|
||||
Filter for better ratings to find homes with lower bills and
|
||||
fewer upgrade headaches.
|
||||
EPC ratings from A to G indicate energy efficiency. Filter for better ratings to
|
||||
find homes with lower bills and fewer upgrade headaches.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-1 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-warm-500 dark:text-warm-400 italic">
|
||||
Tip: if nothing survives your filters, relax one constraint at a time
|
||||
to see which compromise unlocks the most options.
|
||||
Tip: if nothing survives your filters, relax one constraint at a time to see which
|
||||
compromise unlocks the most options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,18 @@ interface HoverCardProps {
|
|||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) {
|
||||
export default memo(function HoverCard({
|
||||
x,
|
||||
y,
|
||||
id,
|
||||
isPostcode,
|
||||
data,
|
||||
filters,
|
||||
features,
|
||||
}: HoverCardProps) {
|
||||
const activeFilterNames = Object.keys(filters);
|
||||
|
||||
const featureMap = useMemo(
|
||||
() => new Map(features.map((f) => [f.name, f])),
|
||||
[features]
|
||||
);
|
||||
const featureMap = useMemo(() => new Map(features.map((f) => [f.name, f])), [features]);
|
||||
|
||||
// Get key stats to show from local data (min_<feature> values)
|
||||
const getDisplayStats = () => {
|
||||
|
|
|
|||
|
|
@ -116,9 +116,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
className="w-2.5 h-2.5 rounded-full shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />
|
||||
)}
|
||||
{!isLast && <div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />}
|
||||
</div>
|
||||
<div className="pb-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
|
|
@ -135,7 +133,11 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) {
|
||||
export default function JourneyInstructions({
|
||||
postcode,
|
||||
entries,
|
||||
label,
|
||||
}: JourneyInstructionsProps) {
|
||||
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
||||
|
||||
// Only transit entries with a destination set
|
||||
|
|
@ -192,9 +194,7 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
|
|||
)
|
||||
.catch((err) => {
|
||||
logNonAbortError('journey', err);
|
||||
setJourneys((prev) =>
|
||||
prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))
|
||||
);
|
||||
setJourneys((prev) => prev.map((j, i) => (i === idx ? { ...j, loading: false } : j)));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -78,10 +78,7 @@ export default function LocationSearch({
|
|||
setLoading(true);
|
||||
search.close();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(result.label)}`,
|
||||
authHeaders(),
|
||||
);
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
return;
|
||||
|
|
@ -102,7 +99,7 @@ export default function LocationSearch({
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[onFlyTo, onLocationSearched, isMobile, search],
|
||||
[onFlyTo, onLocationSearched, isMobile, search]
|
||||
);
|
||||
|
||||
// Mobile collapsed state: just a search icon button
|
||||
|
|
@ -120,7 +117,12 @@ export default function LocationSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col" onMouseEnter={onMouseEnter}>
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ import type {
|
|||
} from '../../types';
|
||||
|
||||
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS, POI_DEFAULT_COLOR } from '../../lib/consts';
|
||||
import {
|
||||
INITIAL_VIEW_STATE,
|
||||
MAP_MIN_ZOOM,
|
||||
MAP_BOUNDS,
|
||||
POI_GROUP_COLORS,
|
||||
POI_DEFAULT_COLOR,
|
||||
} from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
|
|
@ -114,8 +120,7 @@ export default memo(function Map({
|
|||
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;
|
||||
const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
|
@ -245,10 +250,7 @@ export default memo(function Map({
|
|||
Transport
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-teal-600 font-semibold"
|
||||
style={{ fontSize: '1rem' }}
|
||||
>
|
||||
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
|
||||
perfect-postcode.co.uk
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -256,7 +258,11 @@ export default memo(function Map({
|
|||
) : null
|
||||
) : (
|
||||
<>
|
||||
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} onMouseEnter={handleMouseLeave} />
|
||||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onMouseEnter={handleMouseLeave}
|
||||
/>
|
||||
{!hideLegend &&
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
|
|
@ -280,7 +286,9 @@ export default memo(function Map({
|
|||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
enumValues={
|
||||
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
||||
}
|
||||
theme={theme}
|
||||
raw={colorFeatureMeta.raw}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { formatValue } from '../../lib/format';
|
||||
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, ENUM_PALETTE } from '../../lib/consts';
|
||||
import {
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
ENUM_PALETTE,
|
||||
} from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
|
|
@ -34,7 +39,9 @@ function InlineEnumSwatches({ values }: { values: string[] }) {
|
|||
className="w-2.5 h-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">{label}</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -106,7 +113,10 @@ export default function MapLegend({
|
|||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
|
||||
{rangeMin}
|
||||
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
|
||||
<div
|
||||
className="h-2.5 rounded flex-1 min-w-[40px]"
|
||||
style={{ background: gradientStyle }}
|
||||
/>
|
||||
{rangeMax}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } 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';
|
||||
|
|
@ -103,9 +110,7 @@ export default function MapPage({
|
|||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
|
||||
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
||||
const bookmarkToastDismissed = useRef(
|
||||
localStorage.getItem('bookmark_toast_dismissed') === '1'
|
||||
);
|
||||
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
|
||||
|
||||
const handleSavePropertyWithToast = useCallback(
|
||||
(property: Property) => {
|
||||
|
|
@ -158,8 +163,7 @@ export default function MapPage({
|
|||
max: entry.timeRange?.[1],
|
||||
})),
|
||||
};
|
||||
const hasContext =
|
||||
Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
|
||||
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
|
||||
|
||||
const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
|
||||
if (!result) return;
|
||||
|
|
@ -174,7 +178,13 @@ export default function MapPage({
|
|||
}));
|
||||
travelTime.handleSetEntries(newEntries);
|
||||
},
|
||||
[aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries, travelTime.activeEntries, filters]
|
||||
[
|
||||
aiFilters.fetchAiFilters,
|
||||
handleSetFilters,
|
||||
travelTime.handleSetEntries,
|
||||
travelTime.activeEntries,
|
||||
filters,
|
||||
]
|
||||
);
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
|
|
@ -246,7 +256,14 @@ export default function MapPage({
|
|||
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
|
||||
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
|
||||
useUrlSync(
|
||||
mapData.currentView,
|
||||
filters,
|
||||
features,
|
||||
selectedPOICategories,
|
||||
selection.rightPaneTab,
|
||||
travelTime.entries
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mapData.setInitialView(initialViewState);
|
||||
|
|
@ -268,11 +285,18 @@ export default function MapPage({
|
|||
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);
|
||||
})
|
||||
.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
|
||||
});
|
||||
|
|
@ -397,7 +421,13 @@ export default function MapPage({
|
|||
window.__screenshot_ready = true;
|
||||
}
|
||||
}
|
||||
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
|
||||
}, [
|
||||
screenshotMode,
|
||||
mapData.loading,
|
||||
mapData.data.length,
|
||||
mapData.postcodeData.length,
|
||||
mapData.usePostcodeView,
|
||||
]);
|
||||
|
||||
const bookmarkToast = showBookmarkToast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
||||
|
|
@ -580,7 +610,9 @@ export default function MapPage({
|
|||
<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>
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -641,9 +673,7 @@ export default function MapPage({
|
|||
inline
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderFilters()}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">{renderFilters()}</div>
|
||||
</div>
|
||||
|
||||
{mobileDrawerOpen && selection.selectedHexagon && (
|
||||
|
|
@ -746,7 +776,9 @@ export default function MapPage({
|
|||
<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>
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -794,9 +826,7 @@ export default function MapPage({
|
|||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderAreaPane()}
|
||||
{selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export default function MobileDrawer({
|
|||
tab,
|
||||
onTabChange,
|
||||
}: MobileDrawerProps) {
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function POIPane({
|
|||
groups,
|
||||
selectedCategories,
|
||||
onCategoriesChange,
|
||||
poiCount,
|
||||
poiCount: _poiCount,
|
||||
onNavigateToSource,
|
||||
}: POIPaneProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
|
@ -136,7 +136,6 @@ export default function POIPane({
|
|||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||
|
|
|
|||
|
|
@ -234,7 +234,9 @@ function PropertyCard({
|
|||
)}
|
||||
|
||||
{price !== undefined && (
|
||||
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
|
||||
<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">
|
||||
Last sold: £{formatNumber(price)}
|
||||
|
|
@ -265,9 +267,7 @@ function PropertyCard({
|
|||
<span className="font-semibold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(estimatedPrice)}
|
||||
</span>
|
||||
{estPricePerSqm !== undefined && (
|
||||
<span> (£{formatNumber(estPricePerSqm)}/m²)</span>
|
||||
)}
|
||||
{estPricePerSqm !== undefined && <span> (£{formatNumber(estPricePerSqm)}/m²)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function TravelTimeCard({
|
|||
(selectedSlug: string, selectedLabel: string) => {
|
||||
onSetDestination(selectedSlug, selectedLabel);
|
||||
},
|
||||
[onSetDestination],
|
||||
[onSetDestination]
|
||||
);
|
||||
|
||||
const sliderMin = 0;
|
||||
|
|
@ -68,7 +68,9 @@ export function TravelTimeCard({
|
|||
const ModeIcon = MODE_ICONS[mode];
|
||||
|
||||
return (
|
||||
<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' : ''}`}>
|
||||
<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">
|
||||
|
|
@ -86,7 +88,11 @@ export function TravelTimeCard({
|
|||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{slug && (
|
||||
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
||||
<IconButton
|
||||
onClick={onTogglePin}
|
||||
active={isPinned}
|
||||
title={isPinned ? 'Stop previewing' : 'Preview on map'}
|
||||
>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -126,8 +132,8 @@ export function TravelTimeCard({
|
|||
? ' 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.
|
||||
: ' on foot, using pedestrian paths and pavements.'}{' '}
|
||||
Use the slider to filter areas within your preferred commute time.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
@ -135,8 +141,8 @@ export function TravelTimeCard({
|
|||
{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
|
||||
if you time your departure to catch optimal connections. The default uses the{' '}
|
||||
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>
|
||||
</InfoPopup>
|
||||
|
|
@ -156,12 +162,8 @@ export function TravelTimeCard({
|
|||
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
|
||||
/>
|
||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span className="absolute left-0">
|
||||
{formatFilterValue(displayRange[0])} min
|
||||
</span>
|
||||
<span className="absolute right-0">
|
||||
{formatFilterValue(displayRange[1])} min
|
||||
</span>
|
||||
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span>
|
||||
<span className="absolute right-0">{formatFilterValue(displayRange[1])} min</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue