From 14f8b9f30f350db47befd60ff03e05db535563ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 18:50:24 +0000 Subject: [PATCH] Improve Time-Distance Graph tab with multiple enhancements - Remove auto-scroll when selecting trips - Add X-axis mode selection: stop sequence (with names) or distance traveled - Show stop names as X-axis labels when in stop sequence mode - Add Select All / Deselect All button for each direction group - Use fixed color palette with 20 distinct colors for better trip differentiation --- src/tabs/TimeDistanceGraphTab.tsx | 217 +++++++++++++++++++++++------- 1 file changed, 171 insertions(+), 46 deletions(-) diff --git a/src/tabs/TimeDistanceGraphTab.tsx b/src/tabs/TimeDistanceGraphTab.tsx index 706255a..6d464d6 100644 --- a/src/tabs/TimeDistanceGraphTab.tsx +++ b/src/tabs/TimeDistanceGraphTab.tsx @@ -11,7 +11,11 @@ import { Alert, FormControlLabel, Checkbox, - Chip + Chip, + RadioGroup, + Radio, + FormControl, + FormLabel } from '@mui/material' import { LineChart, @@ -46,25 +50,42 @@ interface DirectionGroup { trips: TripWithTimes[] } +type XAxisMode = 'stop_sequence' | 'distance_traveled' + interface ChartDataPoint { stopSequence: number stopName: string + distanceTraveled?: number [key: string]: number | string | undefined // For trip data: tripId_theoretical, tripId_realtime } -// Hash-based color generation from string -const stringToColor = (str: string): string => { - let hash = 0 - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash) - } - - // Generate HSL color with good saturation and lightness for visibility - const h = Math.abs(hash) % 360 - const s = 65 + (Math.abs(hash >> 8) % 20) // 65-85% - const l = 45 + (Math.abs(hash >> 16) % 15) // 45-60% - - return `hsl(${h}, ${s}%, ${l}%)` +// Fixed color palette with 20 very distinct colors for trips +const TRIP_COLORS = [ + '#e6194b', // Red + '#3cb44b', // Green + '#4363d8', // Blue + '#f58231', // Orange + '#911eb4', // Purple + '#42d4f4', // Cyan + '#f032e6', // Magenta + '#bfef45', // Lime + '#fabed4', // Pink + '#469990', // Teal + '#dcbeff', // Lavender + '#9A6324', // Brown + '#fffac8', // Beige + '#800000', // Maroon + '#aaffc3', // Mint + '#808000', // Olive + '#ffd8b1', // Apricot + '#000075', // Navy + '#a9a9a9', // Grey + '#000000', // Black +] + +// Get color for trip based on index within a direction group +const getTripColor = (tripIndex: number): string => { + return TRIP_COLORS[tripIndex % TRIP_COLORS.length] } // Format seconds as duration (e.g., "1h 23m" or "45m 30s") @@ -89,9 +110,9 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [stopsMap, setStopsMap] = useState>(new Map()) + const [xAxisMode, setXAxisMode] = useState('stop_sequence') const directionsRef = useRef(null) - const graphRef = useRef(null) const isToday = selectedDate === new Date().toISOString().split('T')[0] @@ -107,18 +128,6 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti } }, [selectedRoute, directions.length]) - // Scroll to graph when trips are selected - useEffect(() => { - if (selectedTripIds.size > 0 && graphRef.current) { - setTimeout(() => { - graphRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'start' - }) - }, 100) - } - }, [selectedTripIds.size]) - // Load trips for selected route and date useEffect(() => { if (!workerApi || !selectedRoute) { @@ -211,6 +220,38 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti }) } + // Toggle all trips in a direction group + const toggleAllTripsInDirection = (direction: DirectionGroup) => { + const tripIdsInDirection = direction.trips.map(t => t.trip.trip_id) + const allSelected = tripIdsInDirection.every(id => selectedTripIds.has(id)) + + setSelectedTripIds(prev => { + const newSet = new Set(prev) + if (allSelected) { + // Deselect all + tripIdsInDirection.forEach(id => newSet.delete(id)) + } else { + // Select all + tripIdsInDirection.forEach(id => newSet.add(id)) + } + return newSet + }) + } + + // Build a map of trip_id to color index + const tripColorMap = new Map() + directions.forEach(dir => { + dir.trips.forEach((tripWithTimes, tripIndex) => { + tripColorMap.set(tripWithTimes.trip.trip_id, tripIndex) + }) + }) + + // Get color for a trip + const getColorForTrip = (tripId: string): string => { + const index = tripColorMap.get(tripId) ?? 0 + return getTripColor(index) + } + // Get selected trips data const getSelectedTrips = (): TripWithTimes[] => { const allTrips: TripWithTimes[] = [] @@ -242,21 +283,27 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti // Build data points for each stop sequence const dataPoints: ChartDataPoint[] = [] - // Create a map of stop_sequence to stop_name (using first trip that has it) + // Create maps of stop_sequence to stop_name and distance (using first trip that has it) const stopNameMap = new Map() + const distanceMap = new Map() selectedTrips.forEach(({ stopTimes }) => { stopTimes.forEach(st => { if (!stopNameMap.has(st.stop_sequence)) { const stop = stopsMap.get(st.stop_id) stopNameMap.set(st.stop_sequence, stop?.stop_name || `Stop ${st.stop_sequence}`) } + // Get shape_dist_traveled if available + if (!distanceMap.has(st.stop_sequence) && st.shape_dist_traveled !== undefined && st.shape_dist_traveled !== null) { + distanceMap.set(st.stop_sequence, st.shape_dist_traveled) + } }) }) // Always start at (0, 0) - virtual first point const firstPoint: ChartDataPoint = { stopSequence: 0, - stopName: 'Departure' + stopName: 'Departure', + distanceTraveled: 0 } selectedTrips.forEach(({ trip }) => { const tripKey = trip.trip_short_name || trip.trip_id @@ -276,7 +323,8 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti stopSequences.forEach(seq => { const point: ChartDataPoint = { stopSequence: seq, - stopName: stopNameMap.get(seq) || `Stop ${seq}` + stopName: stopNameMap.get(seq) || `Stop ${seq}`, + distanceTraveled: distanceMap.get(seq) } selectedTrips.forEach(({ trip, stopTimes }) => { @@ -355,19 +403,26 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti const { data: chartData, trips: chartTrips } = buildChartData() + // Check if distance data is available + const hasDistanceData = chartData.some(p => p.distanceTraveled !== undefined && p.distanceTraveled > 0) + // Custom tooltip - const CustomTooltip = ({ active, payload, label }: { active?: boolean, payload?: Array<{ name: string, value: number, color: string }>, label?: number }) => { + const CustomTooltip = ({ active, payload, label }: { active?: boolean, payload?: Array<{ name: string, value: number, color: string }>, label?: number | string }) => { if (!active || !payload || payload.length === 0) return null - const point = chartData.find(p => p.stopSequence === label) + // Find point based on x-axis mode + const point = xAxisMode === 'stop_sequence' + ? chartData.find(p => p.stopSequence === label) + : chartData.find(p => p.distanceTraveled === label) return ( - {point?.stopName || `Stop ${label}`} + {point?.stopName || `Stop`} - Stop sequence: {label} + Stop sequence: {point?.stopSequence} + {point?.distanceTraveled !== undefined && ` | Distance: ${(point.distanceTraveled / 1000).toFixed(2)} km`} {payload.map((entry, index) => { const isRealtime = entry.name.includes('(RT)') @@ -440,8 +495,8 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti - {/* Date selection */} - + {/* Date and Options */} + Date @@ -474,6 +529,35 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti } /> + + + X-Axis + setXAxisMode(e.target.value as XAxisMode)} + > + } + label={Stop sequence (with names)} + /> + } + label={ + + Distance traveled + {!hasDistanceData && chartData.length > 0 && ( + + (no data available) + + )} + + } + disabled={!hasDistanceData && chartData.length > 0} + /> + + @@ -483,6 +567,9 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti {directions.map((dir) => { const bgColor = selectedRoute.route_color ? `#${selectedRoute.route_color}` : '#CCCCCC' const textColor = selectedRoute.route_text_color ? `#${selectedRoute.route_text_color}` : '#000000' + const tripIdsInDirection = dir.trips.map(t => t.trip.trip_id) + const allSelected = tripIdsInDirection.every(id => selectedTripIds.has(id)) + const someSelected = tripIdsInDirection.some(id => selectedTripIds.has(id)) return ( @@ -502,7 +589,7 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti > {selectedRoute.route_short_name || selectedRoute.route_long_name?.substring(0, 3)} - + {dir.headsign} @@ -510,13 +597,20 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti {dir.trips.length} trips - Click to select/deselect + toggleAllTripsInDirection(dir)} + size="small" + variant={allSelected ? 'filled' : 'outlined'} + color={allSelected ? 'primary' : 'default'} + /> - {dir.trips.map(({ trip, stopTimes }) => { + {dir.trips.map(({ trip, stopTimes }, tripIndex) => { const tripKey = trip.trip_short_name || trip.trip_id const isSelected = selectedTripIds.has(trip.trip_id) - const color = stringToColor(tripKey) + const color = getTripColor(tripIndex) const firstDeparture = stopTimes[0]?.departure_time?.substring(0, 5) || '' return ( @@ -567,23 +661,54 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti {/* Graph */} {chartData.length > 0 && chartTrips.length > 0 && ( - + Time-Distance Graph - X-axis: Stop sequence | Y-axis: Time since departure + X-axis: {xAxisMode === 'stop_sequence' ? 'Stop sequence' : 'Distance traveled'} | Y-axis: Time since departure {showRealtime && isToday && ' | Dashed lines: Real-time data'} - + { + const point = chartData.find(p => p.stopSequence === props.payload.value) + const name = point?.stopName || `Stop ${props.payload.value}` + const displayName = name.length > 20 ? name.substring(0, 18) + '...' : name + return ( + + + {displayName} + + + ) + } + : { fontSize: 12 } + } + tickFormatter={xAxisMode === 'distance_traveled' + ? (value) => (value / 1000).toFixed(1) + : undefined + } + interval={0} /> formatDuration(value)} @@ -608,7 +733,7 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti {chartTrips.map(({ trip, stopTimes }) => { const tripKey = trip.trip_short_name || trip.trip_id - const color = stringToColor(tripKey) + const color = getColorForTrip(trip.trip_id) const hasRealtime = showRealtime && isToday && stopTimes.some(st => st.realtime) return (