274 lines
9.6 KiB
TypeScript
274 lines
9.6 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { Slider } from '../ui/Slider';
|
|
import { IconButton } from '../ui/IconButton';
|
|
import { PillToggle } from '../ui/PillToggle';
|
|
import { DestinationDropdown } from '../ui/DestinationDropdown';
|
|
import InfoPopup from '../ui/InfoPopup';
|
|
import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
|
|
import { CloseIcon } from '../ui/icons/CloseIcon';
|
|
import { EyeIcon } from '../ui/icons/EyeIcon';
|
|
import { InfoIcon } from '../ui/icons/InfoIcon';
|
|
import { SliderLabels } from './filters/SliderLabels';
|
|
import { formatNumber } from '../../lib/format';
|
|
import type { FeatureMeta } from '../../types';
|
|
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
|
import {
|
|
MAX_TRAVEL_MINUTES,
|
|
MODE_ICONS,
|
|
useTranslatedModes,
|
|
type TransportMode,
|
|
} from '../../hooks/useTravelTime';
|
|
|
|
interface TravelTimeCardProps {
|
|
mode: TransportMode;
|
|
slug: string;
|
|
label: string;
|
|
timeRange: [number, number] | null;
|
|
useBest: boolean;
|
|
noChange: boolean;
|
|
oneChange: boolean;
|
|
noBuses: boolean;
|
|
isPinned: boolean;
|
|
isActive: boolean;
|
|
dragValue: [number, number] | null;
|
|
onTogglePin: () => void;
|
|
onSetDestination: (slug: string, label: string, lat: number, lon: number) => void;
|
|
onTimeRangeChange: (range: [number, number]) => void;
|
|
onDragStart: (range: [number, number]) => void;
|
|
onDragChange: (value: [number, number]) => void;
|
|
onDragEnd: () => void;
|
|
onToggleBest: () => void;
|
|
onToggleNoChange: () => void;
|
|
onToggleOneChange: () => void;
|
|
onToggleNoBuses: () => void;
|
|
onRemove: () => void;
|
|
filterImpact?: number;
|
|
destinationDropdownPortal?: boolean;
|
|
}
|
|
|
|
export function TravelTimeCard({
|
|
mode,
|
|
slug,
|
|
label,
|
|
timeRange,
|
|
useBest,
|
|
noChange,
|
|
oneChange,
|
|
noBuses,
|
|
isPinned,
|
|
isActive,
|
|
dragValue,
|
|
onTogglePin,
|
|
onSetDestination,
|
|
onTimeRangeChange,
|
|
onDragStart,
|
|
onDragChange,
|
|
onDragEnd,
|
|
onToggleBest,
|
|
onToggleNoChange,
|
|
onToggleOneChange,
|
|
onToggleNoBuses,
|
|
onRemove,
|
|
filterImpact,
|
|
destinationDropdownPortal = true,
|
|
}: TravelTimeCardProps) {
|
|
const { t } = useTranslation();
|
|
const modes = useTranslatedModes();
|
|
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
|
const [showInfo, setShowInfo] = useState(false);
|
|
const [showBestInfo, setShowBestInfo] = useState(false);
|
|
const [showNoChangeInfo, setShowNoChangeInfo] = useState(false);
|
|
const [showOneChangeInfo, setShowOneChangeInfo] = useState(false);
|
|
const [showNoBusesInfo, setShowNoBusesInfo] = useState(false);
|
|
|
|
const handleDestinationSelect = useCallback(
|
|
(selectedSlug: string, selectedLabel: string, lat: number, lon: number) => {
|
|
onSetDestination(selectedSlug, selectedLabel, lat, lon);
|
|
},
|
|
[onSetDestination]
|
|
);
|
|
|
|
const sliderMin = 0;
|
|
const sliderMax = MAX_TRAVEL_MINUTES;
|
|
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
|
|
|
// Synthetic feature so the time labels reuse the shared SliderLabels (matching
|
|
// every other filter card): editable, thumb-following, with the minute unit.
|
|
const travelFeature: FeatureMeta = {
|
|
name: 'travelTime',
|
|
type: 'numeric',
|
|
min: sliderMin,
|
|
max: sliderMax,
|
|
suffix: ` ${t('common.minute')}`,
|
|
raw: true,
|
|
};
|
|
|
|
const ModeIcon = MODE_ICONS[mode];
|
|
|
|
return (
|
|
<div
|
|
className={`space-y-2 px-2 py-2 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-1">
|
|
<div className="flex min-w-0 items-start gap-2">
|
|
<ModeIcon className="w-4 h-4 shrink-0 text-teal-600 dark:text-teal-400" />
|
|
<span className="min-w-0 flex-1 break-words text-sm font-medium leading-snug text-navy-950 dark:text-warm-100">
|
|
{t('travel.travelTime', { mode: modes.label(mode) })}
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2 md:gap-0.5">
|
|
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')} size="md">
|
|
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
|
</IconButton>
|
|
{slug && (
|
|
<IconButton
|
|
onClick={onTogglePin}
|
|
active={isPinned || isActive}
|
|
title={isPinned ? t('filters.clearColourMap') : t('filters.colourMap')}
|
|
size="md"
|
|
>
|
|
<EyeIcon className="w-5 h-5 md:w-3.5 md:h-3.5" filled={isPinned || isActive} />
|
|
</IconButton>
|
|
)}
|
|
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
|
|
<CloseIcon className="w-3.5 h-3.5" />
|
|
</IconButton>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Destination */}
|
|
<DestinationDropdown
|
|
destinations={destinations}
|
|
loading={destinationsLoading}
|
|
onSelect={handleDestinationSelect}
|
|
value={label || undefined}
|
|
onClear={() => onSetDestination('', '', 0, 0)}
|
|
placeholder={t('travel.selectDestination')}
|
|
portal={destinationDropdownPortal}
|
|
/>
|
|
|
|
{/* Transit-only toggles, shown when destination is set */}
|
|
{slug && mode === 'transit' && (
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
|
<div className="flex items-center gap-0.5">
|
|
<PillToggle
|
|
label={t('travel.bestCase')}
|
|
active={useBest}
|
|
onClick={onToggleBest}
|
|
size="xs"
|
|
/>
|
|
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
|
|
<InfoIcon className="w-3 h-3" />
|
|
</IconButton>
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
<PillToggle
|
|
label={t('travel.noChange')}
|
|
active={noChange}
|
|
onClick={onToggleNoChange}
|
|
size="xs"
|
|
/>
|
|
<IconButton onClick={() => setShowNoChangeInfo(true)} title={t('travel.noChangeTitle')}>
|
|
<InfoIcon className="w-3 h-3" />
|
|
</IconButton>
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
<PillToggle
|
|
label={t('travel.oneChange')}
|
|
active={oneChange}
|
|
onClick={onToggleOneChange}
|
|
size="xs"
|
|
/>
|
|
<IconButton
|
|
onClick={() => setShowOneChangeInfo(true)}
|
|
title={t('travel.oneChangeTitle')}
|
|
>
|
|
<InfoIcon className="w-3 h-3" />
|
|
</IconButton>
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
<PillToggle
|
|
label={t('travel.noBuses')}
|
|
active={noBuses}
|
|
onClick={onToggleNoBuses}
|
|
size="xs"
|
|
/>
|
|
<IconButton onClick={() => setShowNoBusesInfo(true)} title={t('travel.noBusesTitle')}>
|
|
<InfoIcon className="w-3 h-3" />
|
|
</IconButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />}
|
|
|
|
{showBestInfo && (
|
|
<InfoPopup title={t('travel.bestCaseTitle')} onClose={() => setShowBestInfo(false)}>
|
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
|
<Trans i18nKey="travel.bestCaseDesc" components={{ strong: <strong /> }} />
|
|
</p>
|
|
</InfoPopup>
|
|
)}
|
|
|
|
{showNoChangeInfo && (
|
|
<InfoPopup title={t('travel.noChangeTitle')} onClose={() => setShowNoChangeInfo(false)}>
|
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
|
<Trans i18nKey="travel.noChangeDesc" components={{ strong: <strong /> }} />
|
|
</p>
|
|
</InfoPopup>
|
|
)}
|
|
|
|
{showOneChangeInfo && (
|
|
<InfoPopup title={t('travel.oneChangeTitle')} onClose={() => setShowOneChangeInfo(false)}>
|
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
|
<Trans i18nKey="travel.oneChangeDesc" components={{ strong: <strong /> }} />
|
|
</p>
|
|
</InfoPopup>
|
|
)}
|
|
|
|
{showNoBusesInfo && (
|
|
<InfoPopup title={t('travel.noBusesTitle')} onClose={() => setShowNoBusesInfo(false)}>
|
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
|
<Trans i18nKey="travel.noBusesDesc" components={{ strong: <strong /> }} />
|
|
</p>
|
|
</InfoPopup>
|
|
)}
|
|
|
|
{/* Time range slider: only show when we have data */}
|
|
{slug && (
|
|
<div>
|
|
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
|
{t('travel.maxTime')}
|
|
</span>
|
|
<Slider
|
|
min={sliderMin}
|
|
max={sliderMax}
|
|
step={1}
|
|
value={[displayRange[0], displayRange[1]]}
|
|
onValueChange={([min, max]) => onDragChange([min, max])}
|
|
onPointerDown={() => onDragStart(displayRange)}
|
|
onPointerUp={() => onDragEnd()}
|
|
/>
|
|
<SliderLabels
|
|
min={sliderMin}
|
|
max={sliderMax}
|
|
value={[displayRange[0], displayRange[1]]}
|
|
isAtMin={displayRange[0] <= sliderMin}
|
|
isAtMax={displayRange[1] >= sliderMax}
|
|
raw
|
|
showUnit
|
|
feature={travelFeature}
|
|
onValueChange={onTimeRangeChange}
|
|
/>
|
|
{filterImpact != null && filterImpact > 0 && (
|
|
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
|
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|