This commit is contained in:
Andras Schmelczer 2026-05-16 16:26:36 +01:00
parent e9a06417ad
commit 5e5d9f9a1c
16 changed files with 280 additions and 44 deletions

View file

@ -33,6 +33,7 @@ import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
import StreetViewEmbed from './StreetViewEmbed';
import HistogramLegend from './HistogramLegend';
import JourneyInstructions from './JourneyInstructions';
@ -164,7 +165,9 @@ export default function AreaPane({
return (
<>
<div className="h-full overflow-y-auto">
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && stats != null} />
<div className="flex-1 overflow-y-auto">
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
<div className="space-y-3 p-3">
<div className="flex items-start justify-between gap-3">
@ -611,6 +614,7 @@ export default function AreaPane({
})}
</div>
) : null}
</div>
</div>
{infoFeature && (

View file

@ -0,0 +1,82 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import JourneyInstructions, { googleMapsUrl } from './JourneyInstructions';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string | number>) => {
if (key === 'areaPane.to') return `To ${values?.destination}`;
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
if (key === 'common.min') return 'min';
if (key === 'common.loading') return 'Loading';
if (key === 'travel.bestCase') return 'Best case';
if (key === 'areaPane.walk') return 'Walk';
if (key === 'areaPane.cycle') return 'Cycle';
if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps';
if (key === 'areaPane.noJourneyData') return 'No journey data';
return key;
},
}),
}));
describe('JourneyInstructions', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('keeps the transit leg breakdown visible when best-case time is selected', () => {
render(
<JourneyInstructions
postcode="E14 2DG"
entries={[]}
presetJourneys={[
{
slug: 'bank',
label: 'Bank',
minutes: 42,
bestMinutes: 25,
useBest: true,
legs: [
{ mode: 'walk', minutes: 8 },
{
mode: 'Jubilee',
from: 'Canary Wharf (9400ZZLUCAW)',
to: 'London Bridge (9400ZZLULNB)',
minutes: 7,
},
{
mode: 'Northern',
from: 'London Bridge (9400ZZLULNB)',
to: 'Bank (9400ZZLUBNK)',
minutes: 3,
},
],
},
]}
showGoogleMapsLink={false}
/>
);
expect(screen.getByText(/Best case/)).toBeTruthy();
expect(screen.getByText('Jubilee line')).toBeTruthy();
expect(screen.getByText('Northern line')).toBeTruthy();
expect(screen.getByText(/Canary Wharf/)).toBeTruthy();
});
it('builds explicit Google Maps transit directions instead of a path URL', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T12:00:00Z'));
const url = googleMapsUrl('NW7 2GA', 'Bank tube station');
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe('https://www.google.com/maps/dir/');
expect(parsed.searchParams.get('api')).toBe('1');
expect(parsed.searchParams.get('origin')).toBe('NW7 2GA');
expect(parsed.searchParams.get('destination')).toBe('Bank Station, London');
expect(parsed.searchParams.get('travelmode')).toBe('transit');
expect(parsed.searchParams.get('departure_time')).toBe('1779085800');
});
});

View file

@ -94,15 +94,24 @@ function nextMondayAt730(): number {
return Math.floor(monday.getTime() / 1000);
}
function googleMapsUrl(origin: string, destination: string): string {
function googleMapsDestination(destination: string): string {
const clean = stripId(destination).trim();
if (/\btube station$/i.test(clean)) {
return `${clean.replace(/\s+tube station$/i, ' Station')}, London`;
}
return clean;
}
export function googleMapsUrl(origin: string, destination: string): string {
const ts = nextMondayAt730();
const encodedOrigin = encodeURIComponent(origin);
const encodedDestination = encodeURIComponent(destination);
// The official api=1 URL scheme doesn't support departure_time.
// Use the undocumented data= path parameter with protobuf-like encoding:
// !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
return `https://www.google.com/maps/dir/${encodedOrigin}/${encodedDestination}/data=${data}`;
const params = new URLSearchParams({
api: '1',
origin,
destination: googleMapsDestination(destination),
travelmode: 'transit',
departure_time: ts.toString(),
});
return `https://www.google.com/maps/dir/?${params.toString()}`;
}
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
@ -287,7 +296,7 @@ export default function JourneyInstructions({
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum);
const isBestCase = j.useBest && j.bestMinutes != null;
const displayLegs = !isBestCase && j.legs ? invertLegs(j.legs) : null;
const displayLegs = j.legs ? invertLegs(j.legs) : null;
const destination = j.label || j.slug;
return (

View file

@ -7,6 +7,7 @@ import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
import { InfoIcon } from '../ui/icons';
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
import { ts } from '../../i18n/server';
interface PropertiesPaneProps {
@ -57,7 +58,9 @@ export function PropertiesPane({
}
return (
<div className="h-full overflow-y-auto">
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && properties.length > 0} />
<div className="flex-1 overflow-y-auto">
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}
@ -116,6 +119,7 @@ export function PropertiesPane({
</>
)}
</div>
</div>
</div>
);
}

View file

@ -110,14 +110,14 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-navy-800 dark:border-navy-700 bg-navy-900 dark:bg-navy-900 cursor-pointer hover:bg-navy-800 dark:hover:bg-navy-800"
className="shrink-0 flex items-center justify-between border-b border-l-4 border-warm-200 border-l-teal-500 bg-white px-3 py-2 cursor-pointer shadow-sm hover:bg-warm-50 dark:border-navy-700 dark:border-l-teal-400 dark:bg-navy-900 dark:hover:bg-navy-800"
>
<span className="text-sm font-semibold text-warm-100 dark:text-warm-100">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
</span>
<ChevronIcon
direction={collapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-300 dark:text-warm-300"
className="w-4 h-4 text-warm-500 dark:text-warm-300"
/>
</button>
{(!collapsed || !isLicensed) && (

View file

@ -8,6 +8,7 @@ import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
import type { SearchedLocation } from '../LocationSearch';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { LoadingOverlay } from './LoadingOverlay';
@ -151,6 +152,7 @@ export function DesktopMapPage({
</div>
<div data-tutorial="map" className="flex-1 relative">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}

View file

@ -6,6 +6,7 @@ import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { SearchedLocation } from '../LocationSearch';
import MobileBottomSheet from '../MobileBottomSheet';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { LoadingOverlay } from './LoadingOverlay';
@ -100,6 +101,7 @@ export function MobileMapPage({
<LoadingOverlay show={initialLoading} />
<div className="absolute inset-0">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}

View file

@ -0,0 +1,22 @@
interface IndeterminateProgressBarProps {
show: boolean;
className?: string;
}
export function IndeterminateProgressBar({
show,
className = '',
}: IndeterminateProgressBarProps) {
if (!show) return null;
return (
<div
role="progressbar"
aria-busy="true"
aria-valuetext="loading"
className={`pointer-events-none absolute top-0 left-0 right-0 z-30 h-0.5 overflow-hidden bg-teal-500/10 dark:bg-teal-400/10 animate-fade-in ${className}`}
>
<div className="h-full w-1/4 bg-teal-500 dark:bg-teal-400 animate-indeterminate-progress" />
</div>
);
}

View file

@ -194,6 +194,82 @@ describe('useMapData', () => {
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
});
it('does not use metadata min/max while slider preview colour data is loading', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 100,
},
];
const filters = { price: [20, 80] as [number, number] };
const { result, rerender } = renderHook(
({
viewFeature,
activeFeature,
}: {
viewFeature: string | null;
activeFeature: string | null;
}) =>
useMapData({
filters,
features,
viewFeature,
activeFeature,
pinnedFeature: null,
travelTimeEntries: noTravelTimeEntries,
}),
{
initialProps: {
viewFeature: null as string | null,
activeFeature: null as string | null,
},
}
);
await act(async () => {
result.current.handleViewChange(viewChange(bounds));
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await act(async () => {
requests[0].resolve(
response([
{ h3: 'density-low', count: 1, lat: 1.25, lon: 1.25 },
{ h3: 'density-high', count: 1, lat: 1.75, lon: 1.75 },
])
);
await flushPromises();
});
await act(async () => {
rerender({
viewFeature: 'price',
activeFeature: 'price',
});
await flushPromises();
});
expect(result.current.colorRange).toBeNull();
await act(async () => {
requests[1].resolve(
response([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
});
it('does not reuse cached drag preview data when the drag request changes', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [

View file

@ -516,9 +516,19 @@ export function useMapData({
return [0, meta.values.length - 1];
}
if (dataRange) return dataRange;
if (activeFeature && !hasMatchingDragData) return null;
if (loadedDataKey !== dataRequestKey) return null;
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [dataViewFeature, features, dataRange]);
}, [
activeFeature,
dataRequestKey,
dataRange,
dataViewFeature,
features,
hasMatchingDragData,
loadedDataKey,
]);
const isEyePreviewingPinnedFeature =
!activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;
@ -642,13 +652,19 @@ export function useMapData({
[]
);
// Treat the map as loading whenever the rendered hexagons don't match the
// current request — covers the brief window between a slider release and
// the main fetch effect actually firing setLoading(true).
const isLoading =
loading || (bounds != null && !licenseRequired && loadedDataKey !== dataRequestKey);
return {
data,
committedHexagonData: rawData,
postcodeData: effectivePostcodeData,
resolution,
bounds,
loading,
loading: isLoading,
zoom,
currentView,
currentVisibleView,

View file

@ -806,7 +806,7 @@ const en = {
lowerMinTo: 'Lower minimum to {{value}}',
raiseMaxTo: 'Raise maximum to {{value}}',
allowCategory: 'Allow {{value}}',
missingFilterValue: 'No value for this filter; remove it or allow missing values',
missingFilterValue: 'No value for this filter; remove it',
noFilterDataShort: 'No data',
travelTo: 'Travel to {{destination}}',
viewProperties: 'View {{count}} Properties',

View file

@ -55,9 +55,14 @@ module.exports = {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'indeterminate-progress': {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(400%)' },
},
},
animation: {
'fade-in': 'fade-in 0.2s ease-out forwards',
'indeterminate-progress': 'indeterminate-progress 1.1s ease-in-out infinite',
},
},
},