diff --git a/src/App.js b/src/App.js index f718c8a..ef1e15d 100644 --- a/src/App.js +++ b/src/App.js @@ -150,6 +150,7 @@ class App extends React.Component { activeMenuIndex: null, initialMapBounds: null, selectedRowIndexPerDataset: [-1, -1, -1, -1, -1], + useResponseLocationPerDataset: [false, false, false, false, false], currentLogData: { ...this.props.logData, taskLogs: new TaskLogs(this.props.logData.tripLogs), @@ -223,6 +224,16 @@ class App extends React.Component { this.setState({ featuredObject: featuredObject }); } + setUseResponseLocation = (useResponseLocation) => { + if (this.state.activeDatasetIndex !== null) { + this.setState((prevState) => { + const newValues = [...prevState.useResponseLocationPerDataset]; + newValues[prevState.activeDatasetIndex] = useResponseLocation; + return { useResponseLocationPerDataset: newValues }; + }); + } + }; + setFocusOnRowFunction = (func) => { this.focusOnRowFunction = func; }; @@ -261,31 +272,42 @@ class App extends React.Component { }); } + getFilteredLogs = () => { + const { currentLogData, timeRange, filters, activeDatasetIndex } = this.state; + if (!currentLogData || !currentLogData.tripLogs) return []; + + const cacheKey = `${activeDatasetIndex}-${timeRange.minTime}-${timeRange.maxTime}-${JSON.stringify(filters)}`; + + if (this._logsCache && this._logsCache.key === cacheKey) { + return this._logsCache.logs; + } + + const logs = currentLogData.tripLogs + .getLogs_(new Date(timeRange.minTime), new Date(timeRange.maxTime), filters) + .value(); + + this._logsCache = { key: cacheKey, logs }; + return logs; + }; + selectFirstRow = () => { return new Promise((resolve) => { - this.setState((prevState) => { - const minDate = new Date(prevState.timeRange.minTime); - const maxDate = new Date(prevState.timeRange.maxTime); - const logs = this.state.currentLogData.tripLogs.getLogs_(minDate, maxDate, prevState.filters).value(); - if (logs.length > 0) { - const firstRow = logs[0]; - setTimeout(() => this.focusOnSelectedRow(), 0); + const logs = this.getFilteredLogs(); + if (logs.length > 0) { + const firstRow = logs[0]; + this.setState({ featuredObject: firstRow }, () => { + this.focusOnSelectedRow(); resolve(firstRow); - return { featuredObject: firstRow }; - } else { - console.log("selectFirstRow: No logs found in the current time range"); - resolve(null); - return null; - } - }); + }); + } else { + console.log("selectFirstRow: No logs found in the current time range"); + resolve(null); + } }); }; selectLastRow = () => { - const minDate = new Date(this.state.timeRange.minTime); - const maxDate = new Date(this.state.timeRange.maxTime); - const logsWrapper = this.state.currentLogData.tripLogs.getLogs_(minDate, maxDate, this.state.filters); - const logs = logsWrapper.value(); + const logs = this.getFilteredLogs(); if (logs.length > 0) { const lastRow = logs[logs.length - 1]; this.setFeaturedObject(lastRow); @@ -296,10 +318,8 @@ class App extends React.Component { }; handleRowChange = async (direction) => { - const { featuredObject, filters } = this.state; - const minDate = new Date(this.state.timeRange.minTime); - const maxDate = new Date(this.state.timeRange.maxTime); - const logs = this.state.currentLogData.tripLogs.getLogs_(minDate, maxDate, filters).value(); + const { featuredObject } = this.state; + const logs = this.getFilteredLogs(); let newFeaturedObject = featuredObject; const currentIndex = logs.findIndex((log) => log.timestamp === featuredObject.timestamp); @@ -345,7 +365,14 @@ class App extends React.Component { handleSpeedChange = (event) => { const newSpeed = parseInt(event.target.value); - this.setState({ playSpeed: newSpeed }); + this.setState({ playSpeed: newSpeed }, () => { + if (this.state.isPlaying) { + clearInterval(this.timerID); + this.timerID = setInterval(() => { + this.handleNextEvent(); + }, newSpeed); + } + }); }; handleKeyPress = (event) => { @@ -909,9 +936,7 @@ class App extends React.Component { setTimeout(() => { if (savedRowIndex >= 0) { - const minDate = new Date(this.state.timeRange.minTime); - const maxDate = new Date(this.state.timeRange.maxTime); - const logs = tripLogs.getLogs_(minDate, maxDate).value(); + const logs = this.getFilteredLogs(); if (savedRowIndex < logs.length) { log(`Restoring row at index ${savedRowIndex}`); @@ -1014,6 +1039,12 @@ class App extends React.Component { focusSelectedRow={this.focusOnSelectedRow} initialMapBounds={this.state.initialMapBounds} filters={filters} + useResponseLocation={ + this.state.activeDatasetIndex !== null + ? this.state.useResponseLocationPerDataset[this.state.activeDatasetIndex] + : false + } + setUseResponseLocation={this.setUseResponseLocation} />
- + diff --git a/src/LogTable.js b/src/LogTable.js index 8d292ca..715a76a 100644 --- a/src/LogTable.js +++ b/src/LogTable.js @@ -165,7 +165,9 @@ function LogTable(props) { const [selectedRowIndex, setSelectedRowIndex] = useState(-1); const minTime = props.timeRange.minTime; const maxTime = props.timeRange.maxTime; - const data = props.logData.tripLogs.getLogs_(new Date(minTime), new Date(maxTime), props.filters).value(); + const data = React.useMemo(() => { + return props.logData.tripLogs.getLogs_(new Date(minTime), new Date(maxTime), props.filters).value(); + }, [props.logData.tripLogs, minTime, maxTime, props.filters]); const columnShortWidth = 50; const columnRegularWidth = 120; const columnLargeWidth = 150; diff --git a/src/Map.js b/src/Map.js index c5e1c2e..f253750 100644 --- a/src/Map.js +++ b/src/Map.js @@ -24,6 +24,8 @@ function MapComponent({ setCenterOnLocation, setRenderMarkerOnMap, filters, + useResponseLocation, + setUseResponseLocation, }) { const { tripLogs, taskLogs, jwt, projectId, mapId } = logData; @@ -112,6 +114,7 @@ function MapComponent({ mapRef.current = map; const tripObjects = new TripObjects({ map, setFeaturedObject, setTimeRange }); + mapDivRef.current.tripObjects = tripObjects; const addTripPolys = () => { const trips = tripLogs.getTrips(); @@ -163,15 +166,52 @@ function MapComponent({ }; map.controls[window.google.maps.ControlPosition.TOP_LEFT].push(polylineButton); + const bottomControlsWrapper = document.createElement("div"); + bottomControlsWrapper.className = "map-controls-bottom-left"; + const followButton = document.createElement("div"); followButton.className = "follow-vehicle-button"; followButton.innerHTML = ``; followButton.onclick = () => { log("Follow vehicle button clicked."); recenterOnVehicleWrapper(); - map.setZoom(17); }; - map.controls[window.google.maps.ControlPosition.LEFT_BOTTOM].push(followButton); + + const toggleContainer = document.createElement("div"); + toggleContainer.className = "map-toggle-container"; + + const updateToggleStyles = (reqActive) => { + reqBtn.className = reqActive ? "map-toggle-button active" : "map-toggle-button"; + resBtn.className = reqActive ? "map-toggle-button" : "map-toggle-button active"; + }; + + const reqBtn = document.createElement("button"); + reqBtn.textContent = "Request"; + reqBtn.className = "map-toggle-button"; + reqBtn.onclick = () => { + setUseResponseLocation(false); + updateToggleStyles(true); + }; + + const resBtn = document.createElement("button"); + resBtn.textContent = "Response"; + resBtn.className = "map-toggle-button"; + resBtn.onclick = () => { + setUseResponseLocation(true); + updateToggleStyles(false); + }; + + const separator = document.createElement("div"); + separator.className = "map-toggle-separator"; + + updateToggleStyles(!useResponseLocation); + + toggleContainer.appendChild(reqBtn); + toggleContainer.appendChild(resBtn); + + bottomControlsWrapper.appendChild(followButton); + bottomControlsWrapper.appendChild(toggleContainer); + map.controls[window.google.maps.ControlPosition.LEFT_BOTTOM].push(bottomControlsWrapper); const centerListener = map.addListener( "center_changed", @@ -245,16 +285,17 @@ function MapComponent({ if (!map) return; log("recenterOnVehicleWrapper called for follow mode."); - let position = null; - if (selectedRow?.lastlocation?.rawlocation) { - position = selectedRow.lastlocation.rawlocation; - } else if (lastValidPositionRef.current) { - position = lastValidPositionRef.current; + if (!isFollowingVehicle) { + const locationObj = useResponseLocation ? selectedRow?.lastlocationResponse : selectedRow?.lastlocation; + const position = locationObj?.location || locationObj?.rawlocation || lastValidPositionRef.current; + if (position) { + map.setCenter({ lat: position.latitude, lng: position.longitude }); + map.setZoom(17); + } } - if (position) map.setCenter({ lat: position.latitude, lng: position.longitude }); setIsFollowingVehicle((prev) => !prev); - }, [selectedRow]); + }, [selectedRow, useResponseLocation, isFollowingVehicle]); useEffect(() => { const followButton = document.querySelector(".follow-vehicle-button"); @@ -338,16 +379,13 @@ function MapComponent({ return; } - const location = - _.get(selectedRow.lastlocation, "location") || - _.get(selectedRow.lastlocation, "rawlocation") || - _.get(selectedRow.lastlocationResponse, "location"); + const locationObj = useResponseLocation ? selectedRow.lastlocationResponse : selectedRow.lastlocation; + const location = _.get(locationObj, "location") || _.get(locationObj, "rawlocation"); if (location?.latitude && location?.longitude) { const pos = { lat: location.latitude, lng: location.longitude }; lastValidPositionRef.current = pos; - const heading = - _.get(selectedRow.lastlocation, "heading") || _.get(selectedRow.lastlocationResponse, "heading") || 0; + const heading = _.get(locationObj, "heading") || 0; if (vehicleMarkersRef.current.background) { vehicleMarkersRef.current.background.setPosition(pos); @@ -396,7 +434,7 @@ function MapComponent({ }); } - const rawLocation = _.get(selectedRow.lastlocation, "rawlocation"); + const rawLocation = _.get(locationObj, "rawlocation"); if (rawLocation?.latitude && rawLocation?.longitude) { const rawPos = { lat: rawLocation.latitude, lng: rawLocation.longitude }; if (vehicleMarkersRef.current.rawLocation) { @@ -429,7 +467,37 @@ function MapComponent({ } else { Object.values(vehicleMarkersRef.current).forEach((marker) => marker && marker.setMap(null)); } - }, [selectedRow, isFollowingVehicle]); + }, [selectedRow, isFollowingVehicle, useResponseLocation]); + + // Update trip objects when toggle changes + useEffect(() => { + if (mapDivRef.current && mapDivRef.current.tripObjects) { + log(`Updating TripObjects useResponseLocation to ${useResponseLocation}`); + const tripObjects = mapDivRef.current.tripObjects; + tripObjects.setUseResponseLocation(useResponseLocation); + + // Redraw trips + const trips = tripLogs.getTrips(); + _.forEach(trips, (trip) => { + tripObjects.addTripVisuals(trip, minDate, maxDate); + }); + } + }, [useResponseLocation, tripLogs, minDate, maxDate]); + + // Update toggle button UI + useEffect(() => { + const container = document.querySelector(".map-toggle-container"); + if (container) { + const [reqBtn, , resBtn] = container.children; + if (reqBtn && resBtn) { + reqBtn.className = `map-toggle-button${!useResponseLocation ? " active" : ""}`; + resBtn.className = `map-toggle-button${useResponseLocation ? " active" : ""}`; + } + } + if (isFollowingVehicle && selectedRow) { + // Re-center if we are following and the toggle changed + } + }, [useResponseLocation, isFollowingVehicle, selectedRow, recenterOnVehicleWrapper]); const toggleHandlers = useMemo(() => { const map = mapRef.current; diff --git a/src/Trip.js b/src/Trip.js index 5f3ade9..5840609 100644 --- a/src/Trip.js +++ b/src/Trip.js @@ -9,6 +9,7 @@ class Trip { this.tripName = tripName; this.updateRequests = 1; this.pathCoords = []; + this.pathCoordsResponse = []; this.tripDuration = 0; this.creationTime = "Unknown"; this.firstUpdate = firstUpdate; @@ -37,11 +38,12 @@ class Trip { }; } - getPathCoords(minDate, maxDate) { + getPathCoords(minDate, maxDate, useResponse = false) { + const coords = useResponse ? this.pathCoordsResponse : this.pathCoords; if (!(minDate && maxDate)) { - return this.pathCoords; + return coords; } - return _(this.pathCoords) + return _(coords) .filter((le) => { return le.date >= minDate && le.date <= maxDate; }) @@ -59,6 +61,15 @@ class Trip { }); } + appendResponseCoords(lastLocation, timestamp) { + this.pathCoordsResponse.push({ + lat: lastLocation.location.latitude, + lng: lastLocation.location.longitude, + trip_id: this.tripName, + date: new Date(timestamp), + }); + } + setPlannedPath(plannedPath) { this.plannedPath = plannedPath.map((coords) => { return { lat: coords.latitude, lng: coords.longitude }; diff --git a/src/TripLogs.js b/src/TripLogs.js index 4f5aad9..8c2187a 100644 --- a/src/TripLogs.js +++ b/src/TripLogs.js @@ -138,6 +138,8 @@ function processRawLogs(rawLogs, solutionType) { routeSegment: null, routeSegmentTraffic: null, currentTrips: [], + responseLocation: null, + responseHeading: 0, }; for (let idx = 0; idx < sortedLogs.length; idx++) { @@ -161,38 +163,51 @@ function processRawLogs(rawLogs, solutionType) { // Get current data from the API call const currentLocation = _.get(newLog, `${vehiclePath}.lastlocation`); + const currentResponseLocation = _.get(newLog, "response.lastlocation"); const currentRouteSegment = _.get(newLog, `${vehiclePath}.currentroutesegment`); const currentRouteSegmentTraffic = _.get(newLog, `${vehiclePath}.currentroutesegmenttraffic`); - // Navigation status (fallback to response because it's typically only in the request when it changes) - newLog.navStatus = _.get(newLog, `${vehiclePath}.navstatus`) || _.get(newLog, "response.navigationstatus"); - - // Create lastlocation object using deep cloned data where available + // Location & Heading Normalization - create lastlocation objects using deep cloned data newLog.lastlocation = currentLocation ? _.cloneDeep(currentLocation) : {}; + // For calculations of server/client time deltas + newLog.lastlocationResponse = currentResponseLocation ? _.cloneDeep(currentResponseLocation) : {}; // Data Normalization within a single log, create location from rawlocation when absent if (!newLog.lastlocation.location && newLog.lastlocation.rawlocation) { - log(`processRawLogs: Falling back to rawlocation for log at ${newLog.timestamp}`); + log(`processRawLogs Request: Falling back to rawlocation for log at ${newLog.timestamp}`); newLog.lastlocation.location = _.cloneDeep(newLog.lastlocation.rawlocation); } + if (!newLog.lastlocationResponse.location && newLog.lastlocationResponse.rawlocation) { + log(`processRawLogs Response: Falling back to rawlocation for log at ${newLog.timestamp}`); + newLog.lastlocationResponse.location = _.cloneDeep(newLog.lastlocationResponse.rawlocation); + } - // Apply last known location if needed + // Apply last known locations if needed if (!newLog.lastlocation.location && lastKnownState.location) { newLog.lastlocation.location = _.cloneDeep(lastKnownState.location); newLog.lastlocation.heading = lastKnownState.heading; } + if (!newLog.lastlocationResponse.location && lastKnownState.responseLocation) { + newLog.lastlocationResponse.location = _.cloneDeep(lastKnownState.responseLocation); + newLog.lastlocationResponse.heading = lastKnownState.responseHeading; + } - // Keep the same current trips if we had an API error - if (hasApiError && lastKnownState.currentTrips.length > 0) { - log(`Preserving current trips due to API error for log at ${newLog.timestamp}`); - if (!newLog.response) { - newLog.response = {}; - } - newLog.response.currenttrips = [...lastKnownState.currentTrips]; - } else if (_.get(newLog, "response.currenttrips")) { - lastKnownState.currentTrips = [...newLog.response.currenttrips]; + // Update lastKnownState for next iterations + const locToStore = currentLocation?.location || currentLocation?.rawlocation; + if (locToStore) { + lastKnownState.location = _.cloneDeep(locToStore); + lastKnownState.heading = currentLocation.heading ?? lastKnownState.heading; + } + const respLocToStore = currentResponseLocation?.location || currentResponseLocation?.rawlocation; + if (respLocToStore) { + lastKnownState.responseLocation = _.cloneDeep(respLocToStore); + lastKnownState.responseHeading = currentResponseLocation.heading ?? lastKnownState.responseHeading; } + // Route & Traffic Logic + // Navigation status (fallback to response because it's typically only in the request when it changes) + newLog.navStatus = _.get(newLog, `${vehiclePath}.navstatus`) || _.get(newLog, "response.navigationstatus"); + // If Navigation SDK is NO_GUIDANCE, reset the lastKnownState planned route and traffic if (typeof newLog.navStatus === "string" && newLog.navStatus.endsWith("NO_GUIDANCE")) { lastKnownState.routeSegment = null; @@ -217,26 +232,21 @@ function processRawLogs(rawLogs, solutionType) { } } - // Create other synthetic fields needed for the app - // For calculations of server/client time deltas - newLog.lastlocationResponse = _.get(newLog, "response.lastlocation") - ? _.cloneDeep(_.get(newLog, "response.lastlocation")) - : null; - - newLog.error = _.get(newLog, "error.message"); + // Keep the same current trips if we had an API error + if (hasApiError && lastKnownState.currentTrips.length > 0) { + log(`Preserving current trips due to API error for log at ${newLog.timestamp}`); + if (!newLog.response) newLog.response = {}; + newLog.response.currenttrips = [...lastKnownState.currentTrips]; + } else if (_.get(newLog, "response.currenttrips")) { + lastKnownState.currentTrips = [...newLog.response.currenttrips]; + } // Sort currentTrips array since sometimes it could contain multiple trip ids in random order if (_.get(newLog, "response.currenttrips")) { newLog.response.currenttrips.sort(); } - // Update lastKnownState for next iterations - const locToStore = currentLocation?.location || currentLocation?.rawlocation; - if (locToStore) { - lastKnownState.location = _.cloneDeep(locToStore); - lastKnownState.heading = currentLocation.heading ?? lastKnownState.heading; - } - + newLog.error = _.get(newLog, "error.message"); newLogs.push(newLog); } } @@ -293,13 +303,11 @@ class TripLogs { let rawLogsChain = this.getRawLogs_(minDate, maxDate); if (logTypes && this.solutionType === "ODRD") { - log("Applying ODRD log type filters."); rawLogsChain = rawLogsChain.filter((le) => logTypes[le["@type"]]); } if (tripId && tripId.trim() !== "") { const trimmedFilter = tripId.trim(); - log(`Applying trip ID filter: "${trimmedFilter}"`); rawLogsChain = rawLogsChain.filter((le) => { // Check trip ID in trip rows const requestId = _.get(le, "request.tripid"); @@ -554,6 +562,10 @@ class TripLogs { if (lastLocation && lastLocation.location) { curTripData.appendCoords(lastLocation, le.timestamp); } + const lastLocationResponse = le.lastlocationResponse; + if (lastLocationResponse && lastLocationResponse.location) { + curTripData.appendResponseCoords(lastLocationResponse, le.timestamp); + } } const tripStatus = _.get(le, "response.tripstatus"); if (tripStatus && tripStatus !== lastTripStatus) { diff --git a/src/TripObjects.js b/src/TripObjects.js index ebf845f..50f3319 100644 --- a/src/TripObjects.js +++ b/src/TripObjects.js @@ -11,6 +11,11 @@ export class TripObjects { this.arrows = new Map(); this.setFeaturedObject = setFeaturedObject; this.setTimeRange = setTimeRange; + this.useResponseLocation = false; + } + + setUseResponseLocation(useResponse) { + this.useResponseLocation = useResponse; } createSVGMarker(type, color) { @@ -115,6 +120,7 @@ export class TripObjects { addTripVisuals(trip, minDate, maxDate) { const tripId = trip.tripName; const isNonTripSegment = tripId.startsWith("non-trip-segment-"); + const useResponse = this.useResponseLocation; log(`Processing trip visuals for ${tripId}`, { isNonTripSegment, @@ -130,7 +136,7 @@ export class TripObjects { this.clearTripObjects(tripId); // Add path polyline - const tripCoords = trip.getPathCoords(minDate, maxDate); + const tripCoords = trip.getPathCoords(minDate, maxDate, useResponse); if (tripCoords.length > 0) { const strokeColor = isNonTripSegment ? "#474647" : getColor(trip.tripIdx); @@ -168,16 +174,14 @@ export class TripObjects { return; } - const markers = []; const tripColor = getColor(trip.tripIdx); - - // Get points const pickupPoint = trip.getPickupPoint(); const actualPickupPoint = trip.getActualPickupPoint(); const dropoffPoint = trip.getDropoffPoint(); const actualDropoffPoint = trip.getActualDropoffPoint(); - // Create pickup markers + const markers = []; + const pickupMarker = this.createMarkerWithEvents(pickupPoint, "pickup", tripColor, actualPickupPoint, tripId); if (pickupMarker) markers.push(pickupMarker); diff --git a/src/global.css b/src/global.css index 2284a13..4683bfc 100644 --- a/src/global.css +++ b/src/global.css @@ -115,7 +115,7 @@ } .follow-vehicle-button { - position: absolute; + position: relative; z-index: 1000; width: 36px; height: 36px; @@ -123,6 +123,7 @@ align-items: center; justify-content: center; cursor: pointer; + margin: 0; transition: all 0.2s ease; } @@ -286,6 +287,7 @@ color: white; position: relative; } + .dataset-button-active { background-color: #4CAF50; padding: 10px 25px 10px 10px; @@ -294,7 +296,6 @@ .dataset-button-uploaded { background-color: #008CBA; padding: 10px 25px 10px 10px; - } .dataset-button-empty { @@ -310,13 +311,14 @@ display: flex; align-items: center; justify-content: center; - border-left: 1px solid rgba(255,255,255,0.3); + border-left: 1px solid rgba(255, 255, 255, 0.3); cursor: pointer; - z-index: 1; /* Ensure it's above the button text */ + z-index: 1; + /* Ensure it's above the button text */ } .dataset-button-actions:hover { - background-color: rgba(0,0,0,0.1); + background-color: rgba(0, 0, 0, 0.1); } /* Dropdown menu */ @@ -327,9 +329,10 @@ background: white; border: 1px solid #ccc; border-radius: 3px; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); z-index: 100; - min-width: 100px; /* Ensure the menu is wide enough */ + min-width: 100px; + /* Ensure the menu is wide enough */ } .dataset-button-menu-item { @@ -511,3 +514,53 @@ .filter-menu-item:hover { background-color: #cccccc; } + +/* Map Controls */ +.map-controls-bottom-left { + display: flex; + align-items: flex-end; + margin-bottom: 0px; + margin-left: 10px; +} + +.map-toggle-container { + margin-left: 10px; + margin-top: 0px; + border: 1px solid rgba(0, 0, 0, 0.15); + height: 36px; + box-sizing: border-box; + display: flex; + align-items: stretch; + background: rgba(255, 255, 255, 0.3); + border-radius: 20px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.map-toggle-button { + padding: 0 10px; + font-size: 14px; + font-weight: 700; + height: 100%; + display: flex; + align-items: center; + border: 2px solid transparent; + cursor: pointer; + background-color: transparent; + color: #666; + transition: all 0.2s ease; +} + +.map-toggle-button.active { + color: #4285F4; + background-color: rgba(209, 223, 247, 0.5); + border: 2px solid #4285F4; + border-radius: 20px; + box-shadow: 0 0 8px rgba(66, 133, 244, 0.4), inset 0 0 4px rgba(66, 133, 244, 0.2); +} + +.map-toggle-separator { + width: 1px; + height: 100%; + background-color: rgba(0, 0, 0, 0.15); +} \ No newline at end of file