Good changes
This commit is contained in:
parent
80a5a2a774
commit
791bc6976b
24 changed files with 890 additions and 312 deletions
262
frontend/src/components/map/JourneyInstructions.tsx
Normal file
262
frontend/src/components/map/JourneyInstructions.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
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']);
|
||||
|
||||
function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } {
|
||||
const known = ROUTE_COLORS[mode];
|
||||
if (known) {
|
||||
const label = NON_TUBE_NAMES.has(mode) || mode.includes('line') ? mode : `${mode} line`;
|
||||
return { label, color: known.color, darkText: !!known.darkText };
|
||||
}
|
||||
if (/^\d+[A-Za-z]?$/.test(mode.trim())) {
|
||||
return { label: `Bus ${mode}`, color: '#0d9488', darkText: false };
|
||||
}
|
||||
return { label: mode, color: '#6b7280', darkText: false };
|
||||
}
|
||||
|
||||
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 truncate">
|
||||
{leg.from} → {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;
|
||||
const waitingMin = j.minutes != null ? Math.max(0, j.minutes - legSum) : null;
|
||||
const bestWaitingMin =
|
||||
j.bestMinutes != null ? Math.max(0, j.bestMinutes - legSum) : null;
|
||||
|
||||
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} />
|
||||
))}
|
||||
{waitingMin != null && waitingMin > 0 && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-warm-200 dark:border-warm-700 flex items-baseline justify-between">
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
Waiting & transfers
|
||||
</span>
|
||||
<span className="text-[11px] text-warm-600 dark:text-warm-300">
|
||||
{waitingMin} min
|
||||
{bestWaitingMin != null && (
|
||||
<span className="text-warm-400 dark:text-warm-500">
|
||||
{' '}
|
||||
(best: {bestWaitingMin === 0 ? '~0' : bestWaitingMin} min)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
No journey data available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue