diff --git a/src/tabs/TimeDistanceGraphTab.tsx b/src/tabs/TimeDistanceGraphTab.tsx index 6d464d6..0ede906 100644 --- a/src/tabs/TimeDistanceGraphTab.tsx +++ b/src/tabs/TimeDistanceGraphTab.tsx @@ -20,6 +20,8 @@ import { import { LineChart, Line, + BarChart, + Bar, XAxis, YAxis, CartesianGrid, @@ -59,6 +61,14 @@ interface ChartDataPoint { [key: string]: number | string | undefined // For trip data: tripId_theoretical, tripId_realtime } +interface SpeedDataPoint { + segmentIndex: number + segmentLabel: string + fromStop: string + toStop: string + [key: string]: number | string | undefined // For trip data: tripId_theoretical_speed, tripId_realtime_speed +} + // Fixed color palette with 20 very distinct colors for trips const TRIP_COLORS = [ '#e6194b', // Red @@ -101,6 +111,12 @@ const formatDuration = (seconds: number): string => { return `${minutes}m ${secs.toString().padStart(2, '0')}s` } +// Format speed as km/h +const formatSpeed = (speed: number): string => { + if (speed < 0 || !isFinite(speed)) return 'N/A' + return `${speed.toFixed(1)} km/h` +} + export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: TimeDistanceGraphTabProps) { const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) const [selectedRoute, setSelectedRoute] = useState(null) @@ -406,6 +422,156 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti // Check if distance data is available const hasDistanceData = chartData.some(p => p.distanceTraveled !== undefined && p.distanceTraveled > 0) + // Build speed chart data - calculates speed between consecutive stops + const buildSpeedData = (): SpeedDataPoint[] => { + if (!hasDistanceData || chartTrips.length === 0) return [] + + const speedData: SpeedDataPoint[] = [] + + // Get sorted stop sequences from chart data (excluding the virtual "Departure" point at 0) + const stopSequences = chartData + .filter(p => p.stopSequence > 0) + .map(p => p.stopSequence) + .sort((a, b) => a - b) + + // For each pair of consecutive stops, calculate speed + for (let i = 0; i < stopSequences.length - 1; i++) { + const fromSeq = stopSequences[i] + const toSeq = stopSequences[i + 1] + + const fromPoint = chartData.find(p => p.stopSequence === fromSeq) + const toPoint = chartData.find(p => p.stopSequence === toSeq) + + if (!fromPoint || !toPoint) continue + if (fromPoint.distanceTraveled === undefined || toPoint.distanceTraveled === undefined) continue + + const distanceMeters = toPoint.distanceTraveled - fromPoint.distanceTraveled + if (distanceMeters <= 0) continue + + const distanceKm = distanceMeters / 1000 + + const speedPoint: SpeedDataPoint = { + segmentIndex: i, + segmentLabel: `${fromSeq}→${toSeq}`, + fromStop: fromPoint.stopName, + toStop: toPoint.stopName + } + + // Calculate speed for each trip + chartTrips.forEach(({ trip, stopTimes }) => { + const tripKey = trip.trip_short_name || trip.trip_id + + // Find stop times for this segment + const fromStopTime = stopTimes.find(st => st.stop_sequence === fromSeq) + const toStopTime = stopTimes.find(st => st.stop_sequence === toSeq) + + if (fromStopTime && toStopTime) { + // Theoretical speed: time from departure at fromStop to arrival at toStop + const departureSeconds = timeToSeconds(fromStopTime.departure_time) + const arrivalSeconds = timeToSeconds(toStopTime.arrival_time || toStopTime.departure_time) + const travelTimeSeconds = arrivalSeconds - departureSeconds + + if (travelTimeSeconds > 0) { + const travelTimeHours = travelTimeSeconds / 3600 + const theoreticalSpeed = distanceKm / travelTimeHours + speedPoint[`${tripKey}_theoretical`] = theoreticalSpeed + } + + // Real-time speed (if available) + if (showRealtime && isToday && fromStopTime.realtime && toStopTime.realtime) { + let rtDepartureSeconds: number | null = null + let rtArrivalSeconds: number | null = null + + const agencyTimezone = agencies.length > 0 && agencies[0].agency_timezone + ? agencies[0].agency_timezone + : Intl.DateTimeFormat().resolvedOptions().timeZone + + // Get real-time departure from fromStop + if (fromStopTime.realtime.departure_time) { + const date = new Date(fromStopTime.realtime.departure_time * 1000) + const timeString = date.toLocaleString('en-US', { + timeZone: agencyTimezone, + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + rtDepartureSeconds = timeToSeconds(timeString) + } else if (fromStopTime.realtime.departure_delay !== undefined) { + rtDepartureSeconds = timeToSeconds(fromStopTime.departure_time) + fromStopTime.realtime.departure_delay + } + + // Get real-time arrival at toStop + if (toStopTime.realtime.arrival_time) { + const date = new Date(toStopTime.realtime.arrival_time * 1000) + const timeString = date.toLocaleString('en-US', { + timeZone: agencyTimezone, + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + rtArrivalSeconds = timeToSeconds(timeString) + } else if (toStopTime.realtime.arrival_delay !== undefined) { + rtArrivalSeconds = timeToSeconds(toStopTime.arrival_time || toStopTime.departure_time) + toStopTime.realtime.arrival_delay + } + + if (rtDepartureSeconds !== null && rtArrivalSeconds !== null) { + const rtTravelTimeSeconds = rtArrivalSeconds - rtDepartureSeconds + if (rtTravelTimeSeconds > 0) { + const rtTravelTimeHours = rtTravelTimeSeconds / 3600 + const realtimeSpeed = distanceKm / rtTravelTimeHours + speedPoint[`${tripKey}_realtime`] = realtimeSpeed + } + } + } + } + }) + + speedData.push(speedPoint) + } + + return speedData + } + + const speedData = buildSpeedData() + + // Custom tooltip for speed chart + const SpeedTooltip = ({ active, payload, label }: { active?: boolean, payload?: Array<{ name: string, value: number, color: string, dataKey: string }>, label?: string }) => { + if (!active || !payload || payload.length === 0) return null + + const point = speedData.find(p => p.segmentLabel === label) + + return ( + + + {point?.fromStop} + + + {point?.toStop} + + {payload.map((entry, index) => { + const isRealtime = entry.name.includes('(RT)') + return ( + + + + {entry.name}: {formatSpeed(entry.value)} + + + ) + })} + + ) + } + // Custom tooltip 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 @@ -771,6 +937,111 @@ export default function TimeDistanceGraphTab({ routes, workerApi, agencies }: Ti )} + {/* Speed Chart - displayed when there's distance data */} + {chartData.length > 0 && chartTrips.length > 0 && speedData.length > 0 && ( + + + Speed Between Stops + + + Expected vehicle speed between consecutive stops (km/h) + {showRealtime && isToday && ' | Lighter bars: Real-time speed'} + + + + + + + { + const point = speedData.find(p => p.segmentLabel === props.payload.value) + const fromName = point?.fromStop || '' + const displayName = fromName.length > 15 ? fromName.substring(0, 13) + '...' : fromName + return ( + + + {displayName} + + + ) + }} + interval={0} + /> + `${value.toFixed(0)}`} + label={{ value: 'Speed (km/h)', angle: -90, position: 'insideLeft', offset: 10 }} + tick={{ fontSize: 11 }} + /> + } /> + { + const isRealtime = value.includes('(RT)') + return ( + + {value} + + ) + }} + /> + + {chartTrips.map(({ trip, stopTimes }) => { + const tripKey = trip.trip_short_name || trip.trip_id + const color = getColorForTrip(trip.trip_id) + const hasRealtime = showRealtime && isToday && stopTimes.some(st => st.realtime) + + return ( + + {/* Theoretical speed bar */} + + + {/* Realtime speed bar (if available) */} + {hasRealtime && ( + + )} + + ) + })} + + + + + )} + + {/* Info when no distance data available for speed chart */} + {chartData.length > 0 && chartTrips.length > 0 && !hasDistanceData && ( + + Speed chart is not available because the GTFS feed does not include distance data (shape_dist_traveled). + + )} + {/* Empty state */} {selectedRoute && directions.length > 0 && selectedTripIds.size === 0 && !loading && (