Lots of improvements

This commit is contained in:
Andras Schmelczer 2026-02-22 21:09:07 +00:00
parent 205302dbb8
commit eb02b5832b
39 changed files with 699 additions and 271 deletions

View file

@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
return (
<div className="px-3 py-2">
<form onSubmit={handleSubmit} className="flex gap-1.5">
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Describe your ideal property..."
className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white 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"
placeholder="Describe your ideal property and area..."
className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white 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"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="shrink-0 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5"
className="w-full px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center gap-1.5"
>
{loading ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
'AI'
'Set filters with AI'
)}
</button>
</form>

View file

@ -109,6 +109,11 @@ export default function AreaPane({
{propertyCount.toLocaleString()} properties
</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'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
</p>
{!isPostcode && stats && (
<button
onClick={onViewProperties}

View file

@ -1,10 +1,35 @@
import { useMemo } from 'react';
import { useMemo, useState, useEffect } from 'react';
import type { FeatureFilters } from '../../types';
import {
buildPropertySearchUrls,
H3_RADIUS_MILES,
type HexagonLocation,
} from '../../lib/external-search';
import { apiUrl, logNonAbortError } from '../../lib/api';
function useRightmoveLocationId(postcode: string | undefined): string | undefined {
const [locationId, setLocationId] = useState<string | undefined>();
useEffect(() => {
if (!postcode) {
setLocationId(undefined);
return;
}
setLocationId(undefined);
const controller = new AbortController();
fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), {
signal: controller.signal,
})
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.location_identifier) setLocationId(data.location_identifier);
})
.catch((err) => logNonAbortError('rightmove-location', err));
return () => controller.abort();
}, [postcode]);
return locationId;
}
export default function ExternalSearchLinks({
location,
@ -13,29 +38,46 @@ export default function ExternalSearchLinks({
location: HexagonLocation;
filters: FeatureFilters;
}) {
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
const rightmoveLocationId = useRightmoveLocationId(location.postcode);
const urls = useMemo(
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
);
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = `${radiusMiles}mi radius`;
if (!urls) return null;
const linkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium';
const disabledClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 font-medium cursor-default';
return (
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
Search {label} on
</h3>
<div className="flex gap-2">
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
Rightmove
</a>
{urls.rightmove ? (
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
Rightmove
</a>
) : (
<span className={disabledClass} title="Loading...">
Rightmove
</span>
)}
<a
href={urls.onthemarket}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
className={linkClass}
>
OnTheMarket
</a>
@ -43,7 +85,7 @@ export default function ExternalSearchLinks({
href={urls.zoopla}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
className={linkClass}
>
Zoopla
</a>

View file

@ -32,6 +32,8 @@ interface FeatureBrowserProps {
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
}
export default function FeatureBrowser({
@ -45,6 +47,8 @@ export default function FeatureBrowser({
onClearOpenInfoFeature,
travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
onUpgradeClick,
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -183,10 +187,31 @@ export default function FeatureBrowser({
}
className="px-3 py-4"
/>
) : (
) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Everyone cares about different things. Pick the filters that matter most to you.
</p>
) : (
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
The biggest financial decision of your life deserves proper tools behind it.
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
Don&apos;t leave it to chance.
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
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" />
<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" />
</svg>
</div>
)}
</div>
{infoFeature && (

View file

@ -39,13 +39,15 @@ function SliderLabels({
max,
value,
displayValues,
absoluteMax,
isAtMin,
isAtMax,
}: {
min: number;
max: number;
value: [number, number];
displayValues?: [number, number];
absoluteMax?: boolean;
isAtMin?: boolean;
isAtMax?: boolean;
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
@ -57,13 +59,13 @@ function SliderLabels({
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{formatFilterValue(labels[0])}
{isAtMin ? 'min' : formatFilterValue(labels[0])}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''}
{isAtMax ? 'max' : formatFilterValue(labels[1])}
</span>
</div>
);
@ -92,10 +94,14 @@ interface FiltersProps {
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeToggleBest: (index: number) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterNotes: string | null;
onAiFilterSubmit: (query: string) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
onResetTutorial?: () => void;
}
export default memo(function Filters({
@ -121,10 +127,14 @@ export default memo(function Filters({
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
onTravelTimeToggleBest,
aiFilterLoading,
aiFilterError,
aiFilterNotes,
onAiFilterSubmit,
isLicensed,
onUpgradeClick,
onResetTutorial,
}: FiltersProps) {
const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined;
@ -145,16 +155,11 @@ export default memo(function Filters({
const handleListingSelect = useCallback(
(type: ListingType) => {
if (type === activeListingType && !filters['Listing status']) return;
for (const name of Object.keys(filters)) {
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
onRemoveFilter(name);
}
}
if (type === 'historical' && !filters['Listing status']) {
onFilterChange('Listing status', ['Historical sale']);
return;
}
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
@ -162,7 +167,7 @@ export default memo(function Filters({
};
onFilterChange('Listing status', [valueMap[type]]);
},
[activeListingType, filters, onFilterChange, onRemoveFilter]
[filters, onFilterChange, onRemoveFilter]
);
const containerRef = useRef<HTMLDivElement>(null);
@ -205,7 +210,7 @@ export default memo(function Filters({
<div className="flex items-center gap-2 px-3 pb-2">
<button
onClick={() => setShowPhilosophy(true)}
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
className="flex-1 px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />
Finding the Perfect Postcode
@ -269,11 +274,13 @@ export default memo(function Filters({
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
dataRange={travelTimeDataRanges.get(index) ?? null}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
))}
@ -344,14 +351,25 @@ export default memo(function Filters({
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, 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!;
const isAtMin = displayValue[0] <= dataMin;
const isAtMax = displayValue[1] >= dataMax;
const sliderValue: [number, number] = scale
? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))]
: displayValue;
? [
isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
isAtMin ? feature.min! : displayValue[0],
isAtMax ? feature.max! : displayValue[1],
];
return (
<div
@ -375,8 +393,18 @@ export default memo(function Filters({
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)])
: ([min, max]) => onDragChange([min, max])
? ([pMin, pMax]) => {
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)),
]);
}
: ([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()}
@ -386,7 +414,8 @@ export default memo(function Filters({
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={scale ? displayValue : undefined}
absoluteMax={feature.absolute}
isAtMin={isAtMin}
isAtMax={isAtMax}
/>
</div>
</div>
@ -416,6 +445,8 @@ export default memo(function Filters({
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onTravelTimeAddEntry}
isLicensed={isLicensed}
onUpgradeClick={onUpgradeClick}
/>
</div>
</div>
@ -423,59 +454,95 @@ export default memo(function Filters({
{showPhilosophy && (
<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 &mdash; the areas that survive are your best matches.
</p>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Be intentional, not reactive
1. Budget &amp; property basics
</h4>
<p className="text-warm-600 dark:text-warm-300">
Your future home isn&apos;t a box of cereal you grab because it&apos;s on sale.
Don&apos;t let a seemingly good deal turn into lifelong regret. Instead of waiting
for listings to appear, define what you actually want and go find it.
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>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
See the full picture
2. Commute &amp; transport
</h4>
<p className="text-warm-600 dark:text-warm-300">
Current listings show only a fraction of the market. There are too few to give you a
complete picture, yet too many to evaluate one by one. We aggregate millions of
historical sales so you can understand what&apos;s truly available in any area.
Add a travel time filter to your workplace &mdash; 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>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Your priorities, your filters
3. Safety &amp; environment
</h4>
<p className="text-warm-600 dark:text-warm-300">
We all care about different things. Some want peace and quiet; others want to be
near the action. Use our filters to define exactly what matters to you and discover
postcodes that match.
Use the crime filters to cap serious or minor crime rates.
Check road noise levels if you&apos;re a light sleeper, and
environmental risk filters for ground stability concerns.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Find the right place, not just the right listing
4. Schools &amp; education
</h4>
<p className="text-warm-600 dark:text-warm-300">
The best areas to live don&apos;t always have properties listed right now. We help
you identify where you should be looking, so when something does come up,
you&apos;re ready.
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>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Know what&apos;s possible
5. Lifestyle &amp; amenities
</h4>
<p className="text-warm-600 dark:text-warm-300">
We&apos;d rather tell you upfront if your expectations are unrealistic than have you
spend months searching for something that doesn&apos;t exist.
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>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
6. Energy &amp; 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.
</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.
</p>
</div>
{onResetTutorial && (
<button
onClick={() => {
setShowPhilosophy(false);
onResetTutorial();
}}
className="w-full px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm"
>
Replay interactive tutorial
</button>
)}
</div>
</InfoPopup>
)}

View file

@ -1,5 +1,5 @@
import { memo } from 'react';
import type { FeatureFilters } from '../../types';
import { memo, useMemo } from 'react';
import type { FeatureFilters, FeatureMeta } from '../../types';
import { formatValue } from '../../lib/format';
interface HoverCardData {
@ -14,11 +14,17 @@ interface HoverCardProps {
isPostcode: boolean;
data: HoverCardData | null;
filters: FeatureFilters;
features: FeatureMeta[];
}
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: 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]
);
// Get key stats to show from local data (min_<feature> values)
const getDisplayStats = () => {
if (!data) return [];
@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const val = data[`avg_${name}`] ?? data[`min_${name}`];
if (val != null && typeof val === 'number') {
results.push({ name, value: formatValue(val) });
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(name);
if (meta?.type === 'enum' && meta.values) {
const label = meta.values[Math.round(val)];
if (label) results.push({ name, value: label });
} else {
results.push({ name, value: formatValue(val, meta) });
}
}

View file

@ -296,6 +296,7 @@ export default memo(function Map({
: data.find((d) => d.h3 === hoveredHexagonId) || null
}
filters={filters}
features={features}
/>
)}
</>

View file

@ -229,16 +229,21 @@ export default function MapPage({
const isPostcode = selection.selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData
// For postcodes, get centroid from postcodeData; postcode string is the selection id
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
if (!postcodeFeature?.properties.centroid) return null;
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution };
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
} else {
// For hexagons, get lat/lon from hexagon data
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
return {
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
postcode: selection.areaStats?.central_postcode,
};
}
}, [
selection.selectedHexagon?.id,
@ -246,6 +251,7 @@ export default function MapPage({
mapData.data,
mapData.postcodeData,
mapData.resolution,
selection.areaStats?.central_postcode,
]);
const tutorial = useTutorial(initialLoading, isMobile);
@ -400,10 +406,14 @@ export default function MapPage({
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
aiFilterNotes={aiFilters.notes}
onAiFilterSubmit={handleAiFilterSubmit}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
/>
);
@ -560,6 +570,7 @@ export default function MapPage({
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
locale={{ last: 'Finish' }}
/>
<div
@ -626,38 +637,40 @@ export default function MapPage({
)}
</div>
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
{selection.selectedHexagon && (
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderAreaPane()}
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderAreaPane()}
</div>
</div>
</div>
</div>
)}
{mapData.licenseRequired && (
<UpgradeModal

View file

@ -26,11 +26,13 @@ interface TravelTimeCardProps {
slug: string;
label: string;
timeRange: [number, number] | null;
useBest: boolean;
dataRange: [number, number] | null;
isPinned: boolean;
onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void;
onToggleBest: () => void;
onRemove: () => void;
}
@ -39,11 +41,13 @@ export function TravelTimeCard({
slug,
label,
timeRange,
useBest,
dataRange,
isPinned,
onTogglePin,
onSetDestination,
onTimeRangeChange,
onToggleBest,
onRemove,
}: TravelTimeCardProps) {
const search = useLocationSearch(mode);
@ -119,6 +123,24 @@ export function TravelTimeCard({
)}
</div>
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={useBest}
onChange={onToggleBest}
className="accent-teal-600 rounded"
/>
<span className="text-xs text-warm-600 dark:text-warm-300">
Best case
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
(optimal departure)
</span>
</label>
)}
{/* Time range slider — only show when we have data */}
{slug && dataRange && (
<div>