245 lines
7.9 KiB
TypeScript
245 lines
7.9 KiB
TypeScript
import { useState, useCallback, useMemo } from 'react';
|
|
import type { ComponentType } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons';
|
|
import { dedupeTravelTimeEntries } from '../lib/travel-params';
|
|
|
|
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
|
|
|
|
export const TRANSPORT_MODES: TransportMode[] = ['transit', 'car', 'bicycle', 'walking'];
|
|
|
|
export const MODE_LABELS: Record<TransportMode, string> = {
|
|
car: 'Car',
|
|
bicycle: 'Bicycle',
|
|
walking: 'Walking',
|
|
transit: 'Public Transport',
|
|
};
|
|
|
|
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
|
|
car: 'Drive time via the fastest road route',
|
|
bicycle: 'Cycling time using bike-friendly routes',
|
|
walking: 'Walking time along pedestrian paths and pavements',
|
|
transit: 'Journey time by train, tube, and bus',
|
|
};
|
|
|
|
export const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
|
car: CarIcon,
|
|
bicycle: BicycleIcon,
|
|
walking: WalkingIcon,
|
|
transit: TransitIcon,
|
|
};
|
|
|
|
/**
|
|
* Hook returning translated mode labels and descriptions.
|
|
*/
|
|
export function useTranslatedModes() {
|
|
const { t } = useTranslation();
|
|
const label = useCallback(
|
|
(mode: TransportMode): string =>
|
|
({
|
|
car: t('travel.modeCar'),
|
|
bicycle: t('travel.modeBicycle'),
|
|
walking: t('travel.modeWalking'),
|
|
transit: t('travel.modeTransit'),
|
|
})[mode],
|
|
[t]
|
|
);
|
|
const desc = useCallback(
|
|
(mode: TransportMode): string =>
|
|
({
|
|
car: t('travel.modeCarDesc'),
|
|
bicycle: t('travel.modeBicycleDesc'),
|
|
walking: t('travel.modeWalkingDesc'),
|
|
transit: t('travel.modeTransitDesc'),
|
|
})[mode],
|
|
[t]
|
|
);
|
|
return { label, desc };
|
|
}
|
|
|
|
export interface TravelTimeEntry {
|
|
mode: TransportMode;
|
|
slug: string;
|
|
label: string;
|
|
timeRange: [number, number] | null;
|
|
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
|
|
useBest: boolean;
|
|
/** Restrict transit to walk-transit-walk (0 changes). Optional; defaults to false. */
|
|
noChange?: boolean;
|
|
/** Restrict transit to at most one change. Optional; defaults to false.
|
|
* Mutually exclusive with noChange (the UI never sets both). */
|
|
oneChange?: boolean;
|
|
/** Drop buses from the allowed transit modes. Optional; defaults to false. */
|
|
noBuses?: boolean;
|
|
}
|
|
|
|
/**
|
|
* The Java pipeline emits 6 transit variants as separate parquet directories.
|
|
* The UI represents transit with two toggle booleans; this maps the toggle
|
|
* state back to the directory/mode name the server expects.
|
|
*
|
|
* For non-transit modes the entry.mode passes through unchanged.
|
|
*
|
|
* noChange and oneChange are mutually exclusive (the UI never sets both); if
|
|
* they ever were, noChange wins here.
|
|
*/
|
|
export function resolveTransitVariant(entry: TravelTimeEntry): string {
|
|
if (entry.mode !== 'transit') return entry.mode;
|
|
const nc = entry.noChange ?? false;
|
|
const oc = entry.oneChange ?? false;
|
|
const nb = entry.noBuses ?? false;
|
|
if (nc) return nb ? 'transit-no-change-no-bus' : 'transit-no-change';
|
|
if (oc) return nb ? 'transit-one-change-no-bus' : 'transit-one-change';
|
|
return nb ? 'transit-no-bus' : 'transit';
|
|
}
|
|
|
|
/**
|
|
* Parse a server-side mode string (incl. transit variants) back into a base
|
|
* TransportMode + UI toggle booleans. Returns null for mode strings the UI
|
|
* cannot represent. Callers should skip entries that parse to null rather than
|
|
* silently normalising to a different variant.
|
|
*/
|
|
export function parseServerMode(
|
|
modeStr: string
|
|
): { mode: TransportMode; noChange: boolean; oneChange: boolean; noBuses: boolean } | null {
|
|
if (modeStr === 'car' || modeStr === 'bicycle' || modeStr === 'walking') {
|
|
return { mode: modeStr, noChange: false, oneChange: false, noBuses: false };
|
|
}
|
|
switch (modeStr) {
|
|
case 'transit':
|
|
return { mode: 'transit', noChange: false, oneChange: false, noBuses: false };
|
|
case 'transit-no-bus':
|
|
return { mode: 'transit', noChange: false, oneChange: false, noBuses: true };
|
|
case 'transit-no-change':
|
|
return { mode: 'transit', noChange: true, oneChange: false, noBuses: false };
|
|
case 'transit-no-change-no-bus':
|
|
return { mode: 'transit', noChange: true, oneChange: false, noBuses: true };
|
|
case 'transit-one-change':
|
|
return { mode: 'transit', noChange: false, oneChange: true, noBuses: false };
|
|
case 'transit-one-change-no-bus':
|
|
return { mode: 'transit', noChange: false, oneChange: true, noBuses: true };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Field key matching the backend response: tt_{server-mode}_{slug} */
|
|
export function travelFieldKey(entry: TravelTimeEntry): string {
|
|
return `tt_${resolveTransitVariant(entry)}_${entry.slug}`;
|
|
}
|
|
|
|
/** Slider/data ceiling (minutes). Mirrors MAX_TRIP_DURATION_MINUTES in the R5 pipeline. */
|
|
export const MAX_TRAVEL_MINUTES = 90;
|
|
|
|
export interface TravelTimeInitial {
|
|
entries?: TravelTimeEntry[];
|
|
}
|
|
|
|
export function useTravelTime(initial?: TravelTimeInitial) {
|
|
const [entries, setEntries] = useState<TravelTimeEntry[]>(() =>
|
|
dedupeTravelTimeEntries(initial?.entries ?? [])
|
|
);
|
|
|
|
const handleAddEntry = useCallback((mode: TransportMode) => {
|
|
setEntries((prev) => [
|
|
...prev,
|
|
{
|
|
mode,
|
|
slug: '',
|
|
label: '',
|
|
timeRange: null,
|
|
useBest: false,
|
|
noChange: false,
|
|
oneChange: false,
|
|
noBuses: false,
|
|
},
|
|
]);
|
|
}, []);
|
|
|
|
const handleRemoveEntry = useCallback((index: number) => {
|
|
setEntries((prev) => prev.filter((_, i) => i !== index));
|
|
}, []);
|
|
|
|
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
|
|
setEntries((prev) =>
|
|
dedupeTravelTimeEntries(
|
|
prev.map((entry, i) =>
|
|
i === index
|
|
? { ...entry, slug, label, timeRange: slug ? [0, MAX_TRAVEL_MINUTES] : null }
|
|
: entry
|
|
)
|
|
)
|
|
);
|
|
}, []);
|
|
|
|
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
|
|
setEntries((prev) =>
|
|
dedupeTravelTimeEntries(
|
|
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
|
|
)
|
|
);
|
|
}, []);
|
|
|
|
const handleToggleBest = useCallback((index: number) => {
|
|
setEntries((prev) =>
|
|
dedupeTravelTimeEntries(
|
|
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
|
|
)
|
|
);
|
|
}, []);
|
|
|
|
// noChange and oneChange are mutually exclusive: enabling one clears the
|
|
// other so the resolved server variant is never ambiguous.
|
|
const handleToggleNoChange = useCallback((index: number) => {
|
|
setEntries((prev) =>
|
|
dedupeTravelTimeEntries(
|
|
prev.map((entry, i) =>
|
|
i === index ? { ...entry, noChange: !(entry.noChange ?? false), oneChange: false } : entry
|
|
)
|
|
)
|
|
);
|
|
}, []);
|
|
|
|
const handleToggleOneChange = useCallback((index: number) => {
|
|
setEntries((prev) =>
|
|
dedupeTravelTimeEntries(
|
|
prev.map((entry, i) =>
|
|
i === index
|
|
? { ...entry, oneChange: !(entry.oneChange ?? false), noChange: false }
|
|
: entry
|
|
)
|
|
)
|
|
);
|
|
}, []);
|
|
|
|
const handleToggleNoBuses = useCallback((index: number) => {
|
|
setEntries((prev) =>
|
|
dedupeTravelTimeEntries(
|
|
prev.map((entry, i) =>
|
|
i === index ? { ...entry, noBuses: !(entry.noBuses ?? false) } : entry
|
|
)
|
|
)
|
|
);
|
|
}, []);
|
|
|
|
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
|
setEntries(dedupeTravelTimeEntries(newEntries));
|
|
}, []);
|
|
|
|
/** Entries that have a destination selected (slug is set) */
|
|
const activeEntries = useMemo(() => entries.filter((e) => e.slug !== ''), [entries]);
|
|
|
|
return {
|
|
entries,
|
|
activeEntries,
|
|
handleAddEntry,
|
|
handleRemoveEntry,
|
|
handleSetDestination,
|
|
handleSetEntries,
|
|
handleTimeRangeChange,
|
|
handleToggleBest,
|
|
handleToggleNoChange,
|
|
handleToggleOneChange,
|
|
handleToggleNoBuses,
|
|
};
|
|
}
|