all good
This commit is contained in:
parent
e9a06417ad
commit
5e5d9f9a1c
16 changed files with 280 additions and 44 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
82
frontend/src/components/map/JourneyInstructions.test.tsx
Normal file
82
frontend/src/components/map/JourneyInstructions.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
22
frontend/src/components/ui/IndeterminateProgressBar.tsx
Normal file
22
frontend/src/components/ui/IndeterminateProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue