281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { JourneyLeg } from '../../types';
|
|
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
|
import { apiUrl, logNonAbortError } from '../../lib/api';
|
|
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
|
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
|
|
|
interface JourneyInstructionsProps {
|
|
postcode: string;
|
|
entries: TravelTimeEntry[];
|
|
/** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */
|
|
label?: string;
|
|
}
|
|
|
|
interface JourneyData {
|
|
slug: string;
|
|
label: string;
|
|
legs: JourneyLeg[] | null;
|
|
/** Median (50th percentile) total travel time from R5, including waiting. */
|
|
minutes: number | null;
|
|
/** Best-case (5th percentile) total travel time from R5. */
|
|
bestMinutes: number | null;
|
|
loading: boolean;
|
|
}
|
|
|
|
// Official TfL line colors + other known London transit
|
|
const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
|
|
Bakerloo: { color: '#B36305' },
|
|
Central: { color: '#E32017' },
|
|
Circle: { color: '#FFD300', darkText: true },
|
|
District: { color: '#00782A' },
|
|
'Elizabeth line': { color: '#6950A1' },
|
|
Elizabeth: { color: '#6950A1' },
|
|
'Hammersmith & City': { color: '#F3A9BB', darkText: true },
|
|
'Hammersmith and City': { color: '#F3A9BB', darkText: true },
|
|
Jubilee: { color: '#A0A5A9', darkText: true },
|
|
Metropolitan: { color: '#9B0056' },
|
|
Northern: { color: '#333333' },
|
|
Piccadilly: { color: '#003688' },
|
|
Victoria: { color: '#0098D4' },
|
|
'Waterloo & City': { color: '#95CDBA', darkText: true },
|
|
'Waterloo and City': { color: '#95CDBA', darkText: true },
|
|
DLR: { color: '#00A4A7' },
|
|
'London Overground': { color: '#EE7C0E' },
|
|
};
|
|
|
|
const NON_TUBE_NAMES = new Set(['DLR', 'London Overground', 'Elizabeth line']);
|
|
|
|
/** Strip trailing parenthesized GTFS route IDs and NaPTAN stop codes (e.g. "(6757261)", "(9400ZZLUCGT1)") */
|
|
function stripId(label: string): string {
|
|
return label.replace(/\s+\([A-Za-z0-9]+\)$/, '');
|
|
}
|
|
|
|
function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } {
|
|
const clean = stripId(mode);
|
|
const known = ROUTE_COLORS[clean];
|
|
if (known) {
|
|
const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`;
|
|
return { label, color: known.color, darkText: !!known.darkText };
|
|
}
|
|
if (/^\d+[A-Za-z]?$/.test(clean.trim())) {
|
|
return { label: `Bus ${clean}`, color: '#0d9488', darkText: false };
|
|
}
|
|
return { label: clean, color: '#6b7280', darkText: false };
|
|
}
|
|
|
|
/** Returns a Unix timestamp for the next Monday at 07:30 local time. */
|
|
function nextMondayAt730(): number {
|
|
const now = new Date();
|
|
const day = now.getDay(); // 0=Sun … 6=Sat
|
|
const daysUntil = day === 0 ? 1 : day === 1 ? 7 : 8 - day;
|
|
const monday = new Date(now);
|
|
monday.setDate(now.getDate() + daysUntil);
|
|
monday.setHours(7, 30, 0, 0);
|
|
return Math.floor(monday.getTime() / 1000);
|
|
}
|
|
|
|
function googleMapsUrl(postcode: string, destination: string): string {
|
|
const params = new URLSearchParams({
|
|
api: '1',
|
|
origin: postcode,
|
|
destination,
|
|
travelmode: 'transit',
|
|
});
|
|
return `https://www.google.com/maps/dir/?${params}&departure_time=${nextMondayAt730()}`;
|
|
}
|
|
|
|
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
|
return [...legs]
|
|
.reverse()
|
|
.map((leg) => (leg.from && leg.to ? { ...leg, from: leg.to, to: leg.from } : leg));
|
|
}
|
|
|
|
function RouteBadge({ mode }: { mode: string }) {
|
|
const { label, color, darkText } = getRouteDisplay(mode);
|
|
return (
|
|
<span
|
|
className="inline-flex items-center text-[10px] font-bold px-1.5 py-px rounded-sm leading-tight tracking-wide"
|
|
style={{ backgroundColor: color, color: darkText ? '#292524' : '#fff' }}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|
const isAccess = leg.mode === 'walk' || leg.mode === 'bicycle';
|
|
|
|
if (isAccess) {
|
|
return (
|
|
<div className="flex">
|
|
<div className="flex flex-col items-center w-4 mr-2">
|
|
<div className="w-2 h-2 rounded-full border-[1.5px] border-warm-400 dark:border-warm-500 shrink-0 mt-1" />
|
|
{!isLast && (
|
|
<div className="flex-1 min-h-[4px] border-l border-dashed border-warm-300 dark:border-warm-600" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 py-0.5 min-w-0">
|
|
{leg.mode === 'walk' ? (
|
|
<WalkingIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
|
|
) : (
|
|
<BicycleIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
|
|
)}
|
|
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
|
{leg.mode === 'walk' ? 'Walk' : 'Cycle'} · {leg.minutes} min
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { color } = getRouteDisplay(leg.mode);
|
|
return (
|
|
<div className="flex">
|
|
<div className="flex flex-col items-center w-4 mr-2">
|
|
<div
|
|
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" />}
|
|
</div>
|
|
<div className="pb-1.5 min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<RouteBadge mode={leg.mode} />
|
|
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} min</span>
|
|
</div>
|
|
{leg.from && leg.to && (
|
|
<div className="text-[11px] text-warm-600 dark:text-warm-300 mt-0.5">
|
|
{stripId(leg.from)} → {stripId(leg.to)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function JourneyInstructions({
|
|
postcode,
|
|
entries,
|
|
label,
|
|
}: JourneyInstructionsProps) {
|
|
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
|
|
|
// Only transit entries with a destination set
|
|
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
|
|
|
|
useEffect(() => {
|
|
if (transitEntries.length === 0) {
|
|
setJourneys([]);
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const results: JourneyData[] = transitEntries.map((e) => ({
|
|
slug: e.slug,
|
|
label: e.label,
|
|
legs: null,
|
|
minutes: null,
|
|
bestMinutes: null,
|
|
loading: true,
|
|
}));
|
|
setJourneys([...results]);
|
|
|
|
transitEntries.forEach((entry, idx) => {
|
|
const params = new URLSearchParams({
|
|
postcode,
|
|
mode: 'transit',
|
|
slug: entry.slug,
|
|
});
|
|
fetch(apiUrl('journey', params), { signal: controller.signal })
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.json();
|
|
})
|
|
.then(
|
|
(data: {
|
|
journey: JourneyLeg[] | null;
|
|
minutes: number | null;
|
|
best_minutes: number | null;
|
|
}) => {
|
|
setJourneys((prev) =>
|
|
prev.map((j, i) =>
|
|
i === idx
|
|
? {
|
|
...j,
|
|
legs: data.journey,
|
|
minutes: data.minutes,
|
|
bestMinutes: data.best_minutes,
|
|
loading: false,
|
|
}
|
|
: j
|
|
)
|
|
);
|
|
}
|
|
)
|
|
.catch((err) => {
|
|
logNonAbortError('journey', err);
|
|
setJourneys((prev) => prev.map((j, i) => (i === idx ? { ...j, loading: false } : j)));
|
|
});
|
|
});
|
|
|
|
return () => controller.abort();
|
|
}, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (transitEntries.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mx-3 mt-2 space-y-2">
|
|
{label && (
|
|
<div className="text-xs text-warm-500 dark:text-warm-400">Journeys from {label}</div>
|
|
)}
|
|
{journeys.map((j) => {
|
|
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
|
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
|
const totalMin = j.minutes ?? legSum;
|
|
|
|
return (
|
|
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
|
To {j.label || j.slug}
|
|
</span>
|
|
{displayLegs && displayLegs.length > 0 && (
|
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
|
{totalMin} min
|
|
</span>
|
|
)}
|
|
</div>
|
|
{j.loading ? (
|
|
<div className="flex items-center gap-2 py-1">
|
|
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
|
<span className="text-xs text-warm-500 dark:text-warm-400">Loading...</span>
|
|
</div>
|
|
) : displayLegs && displayLegs.length > 0 ? (
|
|
<div>
|
|
{displayLegs.map((leg, i) => (
|
|
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
|
|
))}
|
|
<a
|
|
href={googleMapsUrl(postcode, j.label || j.slug)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
|
>
|
|
View on Google Maps
|
|
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-warm-500 dark:text-warm-400">
|
|
No journey data available
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|