perfect-postcode/frontend/src/components/map/JourneyInstructions.tsx
2026-03-15 21:54:48 +00:00

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>
);
}