diff --git a/src/App.js b/src/App.js index 7d08d6c..4a6f877 100644 --- a/src/App.js +++ b/src/App.js @@ -22,17 +22,7 @@ import "./global.css"; import { log } from "./Utils"; import { toast, ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; - -/** - * returns the default value for the button from the url - */ -function getToggleDefault(urlKey, defaultVal) { - const urlVal = getQueryStringValue(urlKey); - if (urlVal === "true") { - defaultVal = true; - } - return defaultVal; -} +import { ALL_TOGGLES, getVisibleToggles } from "./MapToggles"; class App extends React.Component { constructor(props) { @@ -41,7 +31,7 @@ class App extends React.Component { const nowDate = new Date(); let urlMinTime = getQueryStringValue("minTime"); let urlMaxTime = getQueryStringValue("maxTime"); - this.initialMinTime = urlMinTime ? parseInt(urlMinTime) : 0; // default max time to 1 year in the future + this.initialMinTime = urlMinTime ? parseInt(urlMinTime) : 0; this.initialMaxTime = urlMaxTime ? parseInt(urlMaxTime) : nowDate.setFullYear(nowDate.getFullYear() + 1); this.focusOnRowFunction = null; this.state = { @@ -50,22 +40,7 @@ class App extends React.Component { playSpeed: 1000, featuredObject: { msg: "Click a table row to select object" }, extraColumns: [], - toggleOptions: { - showGPSBubbles: getToggleDefault("showGPSBubbles", false), - showHeading: getToggleDefault("showHeading", false), - showSpeed: getToggleDefault("showSpeed", false), - showTraffic: getToggleDefault("showTraffic", false), - showTripStatus: getToggleDefault("showTripStatus", false), - showDwellLocations: getToggleDefault("showDwellLocations", false), - showNavStatus: getToggleDefault("showNavStatus", false), - showETADeltas: getToggleDefault("showETADeltas", false), - showHighVelocityJumps: getToggleDefault("showHighVelocityJumps", false), - showMissingUpdates: getToggleDefault("showMissingUpdates", false), - showTasksAsCreated: getToggleDefault("showTasksAsCreated", false), - showPlannedPaths: getToggleDefault("showPlannedPaths", false), - showLiveJS: getToggleDefault("showLiveJS", false), - showClientServerTimeDeltas: getToggleDefault("showClientServerTimeDeltas", false), - }, + toggleOptions: Object.fromEntries(ALL_TOGGLES.map((t) => [t.id, false])), uploadedDatasets: [null, null, null, null, null], activeDatasetIndex: null, activeMenuIndex: null, @@ -73,139 +48,17 @@ class App extends React.Component { selectedRowIndexPerDataset: [-1, -1, -1, -1, -1], currentLogData: this.props.logData, }; - // Realtime updates are too heavy. There must be a better/ react way this.onSliderChangeDebounced = _.debounce((timeRange) => this.onSliderChange(timeRange), 25); - - // TODO: refactor so that visualizations are registered - // rather than enumerated here? - this.toggles = _.filter( - [ - { - id: "showGPSBubbles", - name: "GPS Accuracy", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/GPSAccuracy.md", - columns: ["lastlocation.rawlocationaccuracy", "lastlocation.locationsensor"], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showHeading", - name: "Heading", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/Heading.md", - columns: ["lastlocation.heading", "lastlocation.headingaccuracy"], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showSpeed", - name: "Speed", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/Speed.md", - columns: [], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showTripStatus", - name: "Trip Status", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/TripStatus.md", - columns: [], - solutionTypes: ["ODRD"], - }, - { - id: "showNavStatus", - name: "Navigation Status", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/NavStatus.md", - columns: [], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showTasksAsCreated", - name: "Tasks", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/Tasks.md", - columns: [], - solutionTypes: ["LMFS"], - }, - { - id: "showPlannedPaths", - name: "Planned Paths", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/PlannedPaths.md", - columns: [], - solutionTypes: ["LMFS"], - }, - { - id: "showDwellLocations", - name: "Dwell Locations", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/DwellTimes.md", - columns: [], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showHighVelocityJumps", - name: "Jumps (unrealistic velocity)", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/VelocityJumps.md", - columns: [], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showMissingUpdates", - name: "Jumps (Temporal)", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/MissingUpdates.md", - columns: ["temporal_gap"], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showClientServerTimeDeltas", - name: "Client/Server Time Deltas", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/README.md", - columns: [], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showETADeltas", - name: "ETA Deltas", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/EtaDeltas.md", - columns: ["request.vehicle.etatofirstwaypoint"], - solutionTypes: ["ODRD"], - }, - { - id: "showTraffic", - name: "Live Traffic", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/README.md", - columns: [], - solutionTypes: ["ODRD", "LMFS"], - }, - { - id: "showLiveJS", - name: "Live Journey Sharing", - docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/README.md", - columns: [], - solutionTypes: ["ODRD", "LMFS"], - }, - ], - (toggle) => { - return toggle.solutionTypes.indexOf(this.state.currentLogData.solutionType) !== -1; - } - ); this.setFeaturedObject = this.setFeaturedObject.bind(this); this.setTimeRange = this.setTimeRange.bind(this); } - /* - * Update react state from data in the url. This could/should be - * cleaned up. The pure react state is actually set properly in the - * constructor ... all this does is update the map and associated - * data (once it's loaded). Given this split it's definitely possible - * that this just overwrites settings a quickfingered user already - * changed. - */ updateMapAndAssociatedData = () => { this.setTimeRange(this.state.timeRange.minTime, this.state.timeRange.maxTime); - _.map(this.toggles, (toggle) => { - const urlVal = getQueryStringValue(toggle.id); - if (urlVal === "true") { - this.updateToggleState(true, toggle.id, toggle.columns); - } - }); }; componentDidMount() { + log(`Initial device pixel ratio: ${window.devicePixelRatio}`); this.initializeData().then(() => { this.updateMapAndAssociatedData(); }); @@ -221,34 +74,27 @@ class App extends React.Component { updateToggleState(newValue, toggleName, jsonPaths) { this.setState((prevState) => { - prevState.toggleOptions[toggleName] = newValue; - setQueryStringValue(toggleName, newValue); - const extraColumns = _.clone(prevState.extraColumns); - _.forEach(jsonPaths, (path) => { - if (newValue) { - extraColumns.push(path); - } else { - _.pull(extraColumns, path); - } - }); - prevState.extraColumns = _.uniq(extraColumns); - return prevState; + const newToggleOptions = { + ...prevState.toggleOptions, + [toggleName]: newValue, + }; + + const newExtraColumns = newValue + ? _.union(prevState.extraColumns, jsonPaths) + : _.difference(prevState.extraColumns, jsonPaths); + + return { + toggleOptions: newToggleOptions, + extraColumns: newExtraColumns, + }; }); } - /* - * Updates react state associated with the slider and calls into - * the non-react map code to do the same. - */ onSliderChange(timeRange) { this.setTimeRange(timeRange.minTime, timeRange.maxTime); } - /* - * Callback to updated selected log row - */ onSelectionChange(selectedRow, rowIndex) { - // Save both the selected row and its index for the current dataset if (this.state.activeDatasetIndex !== null && rowIndex !== undefined) { this.setState((prevState) => { const newSelectedIndexes = [...prevState.selectedRowIndexPerDataset]; @@ -264,9 +110,6 @@ class App extends React.Component { } } - /* - * Set the featured object - */ setFeaturedObject(featuredObject) { this.setState({ featuredObject: featuredObject }); } @@ -281,9 +124,6 @@ class App extends React.Component { } }; - /* - * exposes editing of the timeRange state - */ setTimeRange(minTime, maxTime, callback) { setQueryStringValue("minTime", minTime); setQueryStringValue("maxTime", maxTime); @@ -818,29 +658,22 @@ class App extends React.Component { log(`Switched to dataset ${index}`); log(`New time range: ${tripLogs.minDate} - ${tripLogs.maxDate}`); - // After dataset is loaded, try to restore the previously selected row index const savedRowIndex = this.state.selectedRowIndexPerDataset[index]; log(`Attempting to restore row at index ${savedRowIndex} for dataset ${index}`); - // Wait for map and components to fully initialize setTimeout(() => { if (savedRowIndex >= 0) { - // Get current log data with the new time range const minDate = new Date(this.state.timeRange.minTime); const maxDate = new Date(this.state.timeRange.maxTime); const logs = tripLogs.getLogs_(minDate, maxDate).value(); - // Check if the saved index is valid for the current dataset if (savedRowIndex < logs.length) { log(`Restoring row at index ${savedRowIndex}`); const rowToSelect = logs[savedRowIndex]; - // First update the featured object this.setState({ featuredObject: rowToSelect }, () => { - // Then focus on the row in the table this.focusOnSelectedRow(); - // And finally center the map on the location (simulating a long press) const lat = _.get(rowToSelect, "lastlocation.rawlocation.latitude"); const lng = _.get(rowToSelect, "lastlocation.rawlocation.longitude"); @@ -856,13 +689,11 @@ class App extends React.Component { this.selectFirstRow(); } } else { - // If no saved selection or invalid index, select first row log(`No previously saved row index for dataset ${index}, selecting first row`); this.selectFirstRow(); } - }, 300); // Increased delay to ensure map is fully initialized + }, 300); - // Update map and associated data this.updateMapAndAssociatedData(); } ); @@ -875,15 +706,15 @@ class App extends React.Component { }; toggleClickHandler(id) { - const toggle = _.find(this.toggles, { id }); + const toggle = _.find(ALL_TOGGLES, { id }); const newValue = !this.state.toggleOptions[id]; this.updateToggleState(newValue, id, toggle.columns); } render() { - const selectedEventTime = this.state.featuredObject?.timestamp - ? new Date(this.state.featuredObject.timestamp).getTime() - : null; + const { featuredObject, timeRange, currentLogData, toggleOptions, extraColumns } = this.state; + const selectedEventTime = featuredObject?.timestamp ? new Date(featuredObject.timestamp).getTime() : null; + const visibleToggles = getVisibleToggles(currentLogData.solutionType); return (
@@ -893,12 +724,12 @@ class App extends React.Component {
this.onSelectionChange(row, rowIndex)} @@ -917,8 +748,8 @@ class App extends React.Component { focusSelectedRow={this.focusOnSelectedRow} /> this.toggleClickHandler(id)} />
@@ -959,10 +790,10 @@ class App extends React.Component {
this.onSelectionChange(rowData, rowIndex)} setFocusOnRowFunction={this.setFocusOnRowFunction} centerOnLocation={this.centerOnLocation} @@ -970,10 +801,7 @@ class App extends React.Component {
- this.onDataframePropClick(select)} - /> + this.onDataframePropClick(select)} />
); diff --git a/src/Map.js b/src/Map.js index 3209c53..1955863 100644 --- a/src/Map.js +++ b/src/Map.js @@ -1,483 +1,369 @@ // src/Map.js -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { APIProvider, useMapsLibrary } from "@vis.gl/react-google-maps"; import _ from "lodash"; import { getQueryStringValue, setQueryStringValue } from "./queryString"; -import Utils, { log } from "./Utils"; +import { log } from "./Utils"; import PolylineCreation from "./PolylineCreation"; import { decode } from "s2polyline-ts"; import TrafficPolyline from "./TrafficPolyline"; import { TripObjects } from "./TripObjects"; -import { getColor } from "./Trip"; +import { getToggleHandlers } from "./MapToggles.js"; + +function MapComponent({ + logData, + rangeStart, + rangeEnd, + selectedRow, + toggles, + toggleOptions, + setFeaturedObject, + setTimeRange, + focusSelectedRow, + initialMapBounds, + setCenterOnLocation, +}) { + const { tripLogs, taskLogs, jwt, projectId, mapId } = logData; + + const mapDivRef = useRef(null); + const mapRef = useRef(null); + const locationProviderRef = useRef(null); + const bubbleMapRef = useRef({}); + const trafficLayerRef = useRef(null); + const dataMakersRef = useRef([]); + const trafficPolylinesRef = useRef([]); + const panoramaRef = useRef(null); -let minDate; -let maxDate; -let map; -let apikey; -let mapId; -let dataMakers = []; -let trafficLayer; -const bubbleMap = {}; -const toggleHandlers = {}; -let panorama; -let jwt; -let projectId; -let locationProvider; -let tripLogs; -let taskLogs; -let setFeaturedObject; -let focusSelectedRow; -let setTimeRange; - -function addTripPolys(map) { - const tripObjects = new TripObjects({ - map, - setFeaturedObject, - setTimeRange, - }); - const trips = tripLogs.getTrips(); - const vehicleBounds = new window.google.maps.LatLngBounds(); - - // Add click handler to map for finding nearby events - map.addListener("click", (event) => { - const clickLocation = event.latLng; - log("Map click detected at location:", clickLocation.lat(), clickLocation.lng()); - - // Find the closest event within 250 meters - const closestEvent = findClosestEvent(clickLocation, 250); - if (closestEvent) { - log("Found closest event:", closestEvent.timestamp); - setFeaturedObject(closestEvent); - - setTimeout(() => focusSelectedRow(), 0); - } - }); + const [showPolylineUI, setShowPolylineUI] = useState(false); + const [_polylines, setPolylines] = useState([]); + const [buttonPosition, setButtonPosition] = useState({ top: 0, left: 0 }); + const [isFollowingVehicle, setIsFollowingVehicle] = useState(false); + const lastValidPositionRef = useRef(null); - _.forEach(trips, (trip) => { - tripObjects.addTripVisuals(trip, minDate, maxDate); - // Update bounds - const tripCoords = trip.getPathCoords(minDate, maxDate); - if (tripCoords.length > 0) { - tripObjects.addTripVisuals(trip, minDate, maxDate); - tripCoords.forEach((coord) => vehicleBounds.extend(coord)); - } - }); + const minDate = useMemo(() => new Date(rangeStart), [rangeStart]); + const maxDate = useMemo(() => new Date(rangeEnd), [rangeEnd]); - return vehicleBounds; -} + // Effect for map initialization (runs once on mount) + useEffect(() => { + if (!mapDivRef.current) return; + log("Map.js: Initialization useEffect triggered."); -/* - * Creates the map object using a journeySharing location - * provider. - */ -function initializeMapObject(element) { - // In a more normal implementation authTokenFetcher - // would actually be making a RPC to a backend to generate - // the jwt. For debugging use cases the jwt gets bundled into - // the extracted log data. - function authTokenFetcher(options) { - // TODO #25 - bake in actual expiration time -- and give a - // better error message for expired jwts - console.log("Ignoring options using prebuilt jwt", options); - const authToken = { - token: jwt, + const authTokenFetcher = (options) => { + log("Ignoring options; using pre-built JWT.", options); + return { token: jwt }; }; - return authToken; - } - - locationProvider = new window.google.maps.journeySharing.FleetEngineTripLocationProvider({ - projectId, - authTokenFetcher, - }); - const jsMapView = new window.google.maps.journeySharing.JourneySharingMapView({ - element: element, - locationProviders: [locationProvider], - mapOptions: { - mapId: mapId, - mapTypeControl: true, - streetViewControl: true, - }, - }); - const map = jsMapView.map; - return map; -} + locationProviderRef.current = new window.google.maps.journeySharing.FleetEngineTripLocationProvider({ + projectId, + authTokenFetcher, + }); -function MyMapComponent(props) { - const ref = useRef(); - const [showPolylineUI, setShowPolylineUI] = useState(false); - const [polylines, setPolylines] = useState([]); - const [buttonPosition, setButtonPosition] = useState({ top: 0, left: 0 }); - const [isFollowingVehicle, setIsFollowingVehicle] = useState(false); - const lastValidPositionRef = useRef(null); + const jsMapView = new window.google.maps.journeySharing.JourneySharingMapView({ + element: mapDivRef.current, + locationProviders: [locationProviderRef.current], + mapOptions: { mapId, mapTypeControl: true, streetViewControl: true }, + }); + const map = jsMapView.map; + mapRef.current = map; + + const tripObjects = new TripObjects({ map, setFeaturedObject, setTimeRange }); + + const addTripPolys = () => { + const trips = tripLogs.getTrips(); + const vehicleBounds = new window.google.maps.LatLngBounds(); + _.forEach(trips, (trip) => { + tripObjects.addTripVisuals(trip, minDate, maxDate); + const tripCoords = trip.getPathCoords(minDate, maxDate); + if (tripCoords.length > 0) { + tripCoords.forEach((coord) => vehicleBounds.extend(coord)); + } + }); + return vehicleBounds; + }; - useEffect(() => { + // Set initial view const urlZoom = getQueryStringValue("zoom"); const urlCenter = getQueryStringValue("center"); - const urlHeading = getQueryStringValue("heading"); - map = initializeMapObject(ref.current); - addTripPolys(map); - if (urlZoom && urlCenter) { - log("setting zoom & center from url", urlZoom, urlCenter); map.setZoom(parseInt(urlZoom)); map.setCenter(JSON.parse(urlCenter)); - } else if (props.initialMapBounds) { - log("Fitting map to pre-calculated dataset bounds."); - const { north, south, east, west } = props.initialMapBounds; + addTripPolys(); + } else if (initialMapBounds) { + const { north, south, east, west } = initialMapBounds; const bounds = new window.google.maps.LatLngBounds( new window.google.maps.LatLng(south, west), new window.google.maps.LatLng(north, east) ); map.fitBounds(bounds); + addTripPolys(); } else { - log("No bounds provided, defaulting to a world view."); - map.setCenter({ lat: 20, lng: 0 }); - map.setZoom(2); - } - - if (urlHeading) { - map.setHeading(parseInt(urlHeading)); + const vehicleBounds = addTripPolys(); + if (!vehicleBounds.isEmpty()) { + map.fitBounds(vehicleBounds); + } else { + map.setCenter({ lat: 20, lng: 0 }); + map.setZoom(2); + } } - map.setOptions({ maxZoom: 100 }); - - map.addListener("heading_changed", () => { - setQueryStringValue("heading", map.getHeading()); - }); - - map.addListener("dragstart", () => { - setIsFollowingVehicle(false); - log("Follow mode disabled due to map drag"); - }); - - map.addListener( - "center_changed", - _.debounce(() => { - const center = map.getCenter(); - if (center) { - console.log("center_changed event fired, updating query string."); - setQueryStringValue("center", JSON.stringify(center.toJSON())); - } - }, 100) - ); + // Add UI Controls const polylineButton = document.createElement("button"); polylineButton.textContent = "Add Polyline"; polylineButton.className = "map-button"; - polylineButton.onclick = (event) => { + log("Add Polyline button clicked."); const rect = event.target.getBoundingClientRect(); setButtonPosition({ top: rect.bottom, left: rect.left }); setShowPolylineUI((prev) => !prev); - log("Polyline button clicked"); }; - map.controls[window.google.maps.ControlPosition.TOP_LEFT].push(polylineButton); - // Create follow vehicle button with chevron icon const followButton = document.createElement("div"); followButton.className = "follow-vehicle-button"; - followButton.innerHTML = ` -
- - - - `; - + followButton.innerHTML = `
`; followButton.onclick = () => { - log("Follow vehicle button clicked"); - recenterOnVehicle(); + log("Follow vehicle button clicked."); + recenterOnVehicleWrapper(); + map.setZoom(17); }; - - // Add button to map map.controls[window.google.maps.ControlPosition.LEFT_BOTTOM].push(followButton); - updateFollowButtonAppearance(); - }, []); - - useEffect(() => { - updateFollowButtonAppearance(); - }, [isFollowingVehicle]); - - const updateFollowButtonAppearance = () => { - const followButton = document.querySelector(".follow-vehicle-button"); - if (followButton) { - if (isFollowingVehicle) { - followButton.classList.add("active"); - log("Follow vehicle button updated to active state"); - } else { - followButton.classList.remove("active"); - log("Follow vehicle button updated to inactive state"); + const clickListener = map.addListener("click", (event) => { + log("Map click listener triggered."); + const clickLocation = event.latLng; + const logs = tripLogs.getLogs_(new Date(rangeStart), new Date(rangeEnd)).value(); + let closestEvent = null; + let closestDistance = 250; + + logs.forEach((logEntry) => { + const rawLocation = _.get(logEntry, "lastlocation.rawlocation"); + if (rawLocation?.latitude && rawLocation?.longitude) { + const eventLocation = new window.google.maps.LatLng(rawLocation.latitude, rawLocation.longitude); + const distance = window.google.maps.geometry.spherical.computeDistanceBetween(clickLocation, eventLocation); + if (distance < closestDistance) { + closestEvent = logEntry; + closestDistance = distance; + } + } + }); + if (closestEvent) { + setFeaturedObject(closestEvent); + setTimeout(() => focusSelectedRow(), 0); } - } - }; + }); - const handlePolylineSubmit = (waypoints, properties) => { - const path = waypoints.map((wp) => new window.google.maps.LatLng(wp.latitude, wp.longitude)); + const centerListener = map.addListener( + "center_changed", + _.debounce(() => { + if (mapRef.current) setQueryStringValue("center", JSON.stringify(mapRef.current.getCenter().toJSON())); + }, 100) + ); + const headingListener = map.addListener("heading_changed", () => { + if (mapRef.current) setQueryStringValue("heading", mapRef.current.getHeading()); + }); - const arrowIcon = { - path: window.google.maps.SymbolPath.FORWARD_OPEN_ARROW, - scale: properties.strokeWeight / 2, - strokeColor: properties.color, + return () => { + log("Map.js: Cleanup from initialization useEffect."); + window.google.maps.event.removeListener(clickListener); + window.google.maps.event.removeListener(centerListener); + window.google.maps.event.removeListener(headingListener); + mapRef.current = null; }; + }, []); - const polyline = new window.google.maps.Polyline({ - path: path, - geodesic: true, - strokeColor: properties.color, - strokeOpacity: properties.opacity, - strokeWeight: properties.strokeWeight, - icons: [ - { - icon: arrowIcon, - offset: "0%", - }, - { - icon: arrowIcon, - offset: "100%", - }, - ], - }); + const handlePolylineSubmit = useCallback((waypoints, properties) => { + const map = mapRef.current; + if (!map) return; + log("handlePolylineSubmit called."); - polyline.setMap(map); - setPolylines([...polylines, polyline]); - log( - `Polyline ${polylines.length + 1} created with color: ${properties.color}, opacity: ${ - properties.opacity - }, stroke weight: ${properties.strokeWeight}` - ); - }; + const path = waypoints.map((wp) => new window.google.maps.LatLng(wp.latitude, wp.longitude)); + const newPolyline = new window.google.maps.Polyline({ path, geodesic: true, ...properties }); + newPolyline.setMap(map); + setPolylines((prev) => [...prev, newPolyline]); + }, []); - const recenterOnVehicle = () => { - log("Executing recenterOnVehicle function"); + const recenterOnVehicleWrapper = useCallback(() => { + const map = mapRef.current; + if (!map) return; + log("recenterOnVehicleWrapper called for follow mode."); let position = null; - - // Try to get position from current row - if (props.selectedRow && props.selectedRow.lastlocation && props.selectedRow.lastlocation.rawlocation) { - position = props.selectedRow.lastlocation.rawlocation; - log(`Found position in selected row: ${position.latitude}, ${position.longitude}`); - } - // Try to use our last cached valid position - else if (lastValidPositionRef.current) { + if (selectedRow?.lastlocation?.rawlocation) { + position = selectedRow.lastlocation.rawlocation; + } else if (lastValidPositionRef.current) { position = lastValidPositionRef.current; - log(`Using last cached valid position: ${position.lat}, ${position.lng}`); - } - if (!position) { - log("No vehicle position found"); - } - - // Center the map - if (position && typeof position.latitude !== "undefined") { - map.setCenter({ lat: position.latitude, lng: position.longitude }); - map.setZoom(17); } - setIsFollowingVehicle((prev) => { - const newState = !prev; - log(`Map follow mode ${newState ? "enabled" : "disabled"}`); - return newState; - }); - - log(`Map centered on vehicle, follow mode ${!isFollowingVehicle ? "enabled" : "disabled"}`); - }; + if (position) map.setCenter({ lat: position.latitude, lng: position.longitude }); + setIsFollowingVehicle((prev) => !prev); + }, [selectedRow]); - /* - * Handler for timewindow change. Updates global min/max date globals - * and recomputes the paths as well as all the bubble markers to respect the - * new date values. - * - * Debounced to every 100ms as a blance between performance and reactivity when - * the slider is dragged. - */ useEffect(() => { - const updateMap = () => { - minDate = new Date(props.rangeStart); - maxDate = new Date(props.rangeEnd); - addTripPolys(map); - _.forEach(toggleHandlers, (handler, name) => { - if (bubbleMap[name]) { - handler(true); - } - }); - }; - - // Create a debounced version of updateMap - const debouncedUpdateMap = _.debounce(updateMap, 200); - debouncedUpdateMap(); - - // Cleanup function to cancel any pending debounced calls when the effect re-runs or unmounts - return () => { - debouncedUpdateMap.cancel(); - }; - }, [props.rangeStart, props.rangeEnd]); + const followButton = document.querySelector(".follow-vehicle-button"); + if (followButton) { + isFollowingVehicle ? followButton.classList.add("active") : followButton.classList.remove("active"); + } + }, [isFollowingVehicle]); + // Effect to draw traffic polyline for selected row useEffect(() => { - if (!props.selectedRow) return; - - // Clear ALL route segment polylines - polylines.forEach((polyline) => { - if (polyline.isRouteSegment) { - polyline.setMap(null); - } - }); - // Update the polylines state to remove all route segments - setPolylines(polylines.filter((p) => !p.isRouteSegment)); + const map = mapRef.current; + if (!map) return; - const eventType = props.selectedRow["@type"]; - const isTripEvent = ["getTrip", "updateTrip", "createTrip"].includes(eventType); + trafficPolylinesRef.current.forEach((p) => p.setMap(null)); + trafficPolylinesRef.current = []; - if (isTripEvent) { - // Create a thin red polyline with arrows for trip events - const routeSegment = _.get(props.selectedRow, "response.currentroutesegment"); - if (routeSegment) { - try { - const decodedPoints = decode(routeSegment); - if (decodedPoints && decodedPoints.length > 0) { - const validWaypoints = decodedPoints.map((point) => ({ - lat: point.latDegrees(), - lng: point.lngDegrees(), - })); - - const trafficPolyline = new TrafficPolyline({ - path: validWaypoints, - zIndex: 3, - isTripEvent: true, - map: map, - }); - setPolylines((prev) => [...prev, ...trafficPolyline.polylines]); - } - } catch (error) { - console.error("Error processing trip event polyline:", error); - } - } - } + if (!selectedRow) return; const routeSegment = - _.get(props.selectedRow, "request.vehicle.currentroutesegment") || - _.get(props.selectedRow, "lastlocation.currentroutesegment"); - + _.get(selectedRow, "request.vehicle.currentroutesegment") || + _.get(selectedRow, "lastlocation.currentroutesegment"); if (routeSegment) { try { const decodedPoints = decode(routeSegment); - - if (decodedPoints && decodedPoints.length > 0) { - const validWaypoints = decodedPoints.map((point) => ({ - lat: point.latDegrees(), - lng: point.lngDegrees(), - })); - + if (decodedPoints?.length > 0) { + const validWaypoints = decodedPoints.map((p) => ({ lat: p.latDegrees(), lng: p.lngDegrees() })); const trafficRendering = - _.get(props.selectedRow, "request.vehicle.currentroutesegmenttraffic.trafficrendering") || - _.get(props.selectedRow, "lastlocation.currentroutesegmenttraffic.trafficrendering"); - - const location = _.get(props.selectedRow.lastlocation, "location"); + _.get(selectedRow, "request.vehicle.currentroutesegmenttraffic.trafficrendering") || + _.get(selectedRow, "lastlocation.currentroutesegmenttraffic.trafficrendering"); + const location = _.get(selectedRow.lastlocation, "location"); const trafficPolyline = new TrafficPolyline({ path: validWaypoints, + map, zIndex: 2, trafficRendering: structuredClone(trafficRendering), currentLatLng: location, - map: map, }); - setPolylines((prev) => [...prev, ...trafficPolyline.polylines]); + + trafficPolylinesRef.current = trafficPolyline.polylines; } } catch (error) { console.error("Error processing route segment polyline:", error); } } - }, [props.selectedRow]); + }, [selectedRow]); + // Effect for updating selected row vehicle marker useEffect(() => { - // Vehicle chevron location maker - const data = props.selectedRow; - if (!data) return; - _.forEach(dataMakers, (m) => m.setMap(null)); - dataMakers = []; + const map = mapRef.current; + dataMakersRef.current.forEach((m) => m.setMap(null)); + dataMakersRef.current = []; - const markerSymbols = { - background: { - path: window.google.maps.SymbolPath.CIRCLE, - fillColor: "#FFFFFF", - fillOpacity: 0.7, - scale: 18, - strokeColor: "#FFFFFF", - strokeWeight: 2, - strokeOpacity: 0.3, - }, - chevron: { - path: "M -1,1 L 0,-1 L 1,1 L 0,0.5 z", - fillColor: "#4285F4", - fillOpacity: 1, - scale: 10, - strokeColor: "#4285F4", - strokeWeight: 1, - rotation: 0, - }, - rawLocation: { - path: window.google.maps.SymbolPath.CIRCLE, - fillColor: "#FF0000", - fillOpacity: 1, - scale: 2, - strokeColor: "#FF0000", - strokeWeight: 1, - }, - }; + if (!map || !selectedRow) return; - const location = _.get(data.lastlocation, "location") || _.get(data.lastlocationResponse, "location"); - if (location) { - lastValidPositionRef.current = { lat: location.latitude, lng: location.longitude }; + const location = _.get(selectedRow.lastlocation, "location") || _.get(selectedRow.lastlocationResponse, "location"); - const heading = _.get(data.lastlocation, "heading") || _.get(data.lastlocationResponse, "heading") || 0; - markerSymbols.chevron.rotation = heading; + 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 backgroundMarker = new window.google.maps.Marker({ - position: { lat: location.latitude, lng: location.longitude }, - map: map, - icon: markerSymbols.background, - clickable: false, + position: pos, + map, + icon: { + path: window.google.maps.SymbolPath.CIRCLE, + fillColor: "#FFFFFF", + fillOpacity: 0.7, + scale: 18, + strokeColor: "#FFFFFF", + strokeWeight: 2, + strokeOpacity: 0.3, + }, zIndex: 9, }); - const chevronMarker = new window.google.maps.Marker({ - position: { lat: location.latitude, lng: location.longitude }, - map: map, - icon: markerSymbols.chevron, + position: pos, + map, + icon: { + path: "M -1,1 L 0,-1 L 1,1 L 0,0.5 z", + fillColor: "#4285F4", + fillOpacity: 1, + scale: 10, + strokeColor: "#4285F4", + strokeWeight: 1, + rotation: heading, + }, zIndex: 10, }); + dataMakersRef.current.push(backgroundMarker, chevronMarker); - dataMakers.push(backgroundMarker, chevronMarker); - - const rawLocation = _.get(data.lastlocation, "rawlocation"); - if (rawLocation) { + const rawLocation = _.get(selectedRow.lastlocation, "rawlocation"); + if (rawLocation?.latitude && rawLocation?.longitude) { + const rawPos = { lat: rawLocation.latitude, lng: rawLocation.longitude }; const rawLocationMarker = new window.google.maps.Marker({ - position: { lat: rawLocation.latitude, lng: rawLocation.longitude }, - map: map, - icon: markerSymbols.rawLocation, + position: rawPos, + map, + icon: { + path: window.google.maps.SymbolPath.CIRCLE, + fillColor: "#FF0000", + fillOpacity: 1, + scale: 2, + strokeColor: "#FF0000", + strokeWeight: 1, + }, zIndex: 8, - clickable: false, }); - - dataMakers.push(rawLocationMarker); + dataMakersRef.current.push(rawLocationMarker); } if (isFollowingVehicle) { - map.setCenter({ lat: location.latitude, lng: location.longitude }); + map.setCenter(pos); + } + } + }, [selectedRow, isFollowingVehicle]); + + const toggleHandlers = useMemo(() => { + const map = mapRef.current; + if (!map) { + return {}; + } + return getToggleHandlers({ + map, + tripLogs, + taskLogs, + minDate, + maxDate, + setFeaturedObject, + setTimeRange, + bubbleMapRef, + panoramaRef, + mapDivRef, + trafficLayerRef, + locationProviderRef, + jwt, + }); + }, [mapRef.current, tripLogs, taskLogs, minDate, maxDate, jwt, setFeaturedObject, setTimeRange]); + + useEffect(() => { + if (_.isEmpty(toggleHandlers)) { + log("Map.js: Toggles effect skipped because handlers are not ready."); + return; + } + for (const toggle of toggles) { + if (toggleHandlers[toggle.id]) { + toggleHandlers[toggle.id](toggleOptions[toggle.id]); } } - }, [props.selectedRow, isFollowingVehicle]); + }, [toggleOptions, toggles, toggleHandlers, minDate, maxDate]); - for (const toggle of props.toggles) { - const id = toggle.id; - const enabled = props.toggleOptions[id]; - useEffect(() => { - toggleHandlers[id](enabled); - }, [props.toggleOptions[id]]); - } + useEffect(() => { + const centerOnLocationImpl = (lat, lng) => { + const map = mapRef.current; + if (map) { + log(`Centering map on ${lat}, ${lng} with zoom 13.`); + map.setCenter({ lat, lng }); + map.setZoom(13); + } + }; + setCenterOnLocation(centerOnLocationImpl); + }, [setCenterOnLocation]); return ( <> -
+
{showPolylineUI && ( Loading Maps...; - } - - log("MapContent: Google Maps libraries loaded, rendering MyMapComponent."); - return ; + if (!journeySharingLib || !geometryLib) return

Loading Maps...

; + return ; } -function Map(props) { - tripLogs = props.logData.tripLogs; - taskLogs = props.logData.taskLogs; - minDate = props.rangeStart; - maxDate = props.rangeEnd; - const urlParams = new URLSearchParams(window.location.search); - apikey = urlParams.get("apikey") || props.logData.apikey; - mapId = urlParams.get("mapId") || props.logData.mapId; - jwt = props.logData.jwt; - projectId = props.logData.projectId; - setFeaturedObject = props.setFeaturedObject; - focusSelectedRow = props.focusSelectedRow; - setTimeRange = props.setTimeRange; - - function centerOnLocation(lat, lng) { - if (map) { - const newCenter = new window.google.maps.LatLng(lat, lng); - map.setCenter(newCenter); - map.setZoom(13); - console.log(`Map centered on: ${lat}, ${lng}`); - } else { - console.error("Map not initialized"); - } - } - - props.setCenterOnLocation(centerOnLocation); - +export default function Map(props) { + const { apikey } = props.logData; + const stableSetCenterOnLocation = useCallback(props.setCenterOnLocation, []); return ( - + ); } - -// Add a new function to find the closest event to a clicked location -function findClosestEvent(clickLocation, maxDistance) { - const logs = tripLogs.getLogs_(minDate, maxDate).value(); - let closestEvent = null; - let closestDistance = maxDistance; - - logs.forEach((event) => { - const rawLocation = _.get(event, "lastlocation.rawlocation"); - if (rawLocation && rawLocation.latitude && rawLocation.longitude) { - const eventLocation = new window.google.maps.LatLng(rawLocation.latitude, rawLocation.longitude); - const distance = window.google.maps.geometry.spherical.computeDistanceBetween(clickLocation, eventLocation); - - if (distance < closestDistance) { - closestEvent = event; - closestDistance = distance; - } - } - }); - - if (closestEvent) { - log("Found closest event at distance:", closestDistance, "meters"); - } else { - log("No events found within", maxDistance, "meters"); - } - - return closestEvent; -} - -/* - * GenerateBubbles() -- helper function for generating map features based - * on per-log entry data. - * - * Handles the gunk of iterating over log entries and clearing/setting the map - */ -function GenerateBubbles(bubbleName, cb) { - return (showBubble) => { - _.forEach(bubbleMap[bubbleName], (bubble) => bubble.setMap(null)); - delete bubbleMap[bubbleName]; - if (showBubble) { - bubbleMap[bubbleName] = tripLogs - .getLogs_(minDate, maxDate) - .map((le) => { - const lastLocation = le.lastlocation; - let rawlocation; - let bubble = undefined; - if (lastLocation && (rawlocation = lastLocation.rawlocation)) { - bubble = cb( - new window.google.maps.LatLng({ - lat: rawlocation.latitude, - lng: rawlocation.longitude, - }), - lastLocation, - le - ); - } - return bubble; - }) - .compact() - .value(); - } - }; -} - -/* - * Draws circles on map with a radius equal to the - * GPS accuracy. - */ -toggleHandlers["showGPSBubbles"] = GenerateBubbles("showGPSBubbles", (rawLocationLatLng, lastLocation) => { - let color; - switch (lastLocation.locationsensor) { - case "GPS": - color = "#11FF11"; - break; - case "NETWORK": - color = "#FF1111"; - break; - case "PASSIVE": - color = "#FF0000"; - break; - case "ROAD_SNAPPED_LOCATION_PROVIDER": - color = "#00FF00"; - break; - case "FUSED_LOCATION_PROVIDER": - color = "#11FF11"; - break; - case "LOG_UNSPECIFIED": - default: - color = "#000000"; - } - const accuracy = lastLocation.rawlocationaccuracy; - if (accuracy) { - let circ = new window.google.maps.Circle({ - strokeColor: color, - strokeOpacity: 0.6, - strokeWeight: 2, - fillColor: color, - fillOpacity: 0.2, - map, - center: rawLocationLatLng, - radius: accuracy, // units is this actually meters? - }); - window.google.maps.event.addListener(circ, "mouseover", () => { - setFeaturedObject({ - rawlocationaccuracy: lastLocation.rawlocationaccuracy, - locationsensor: lastLocation.locationsensor, - }); - }); - return circ; - } -}); - -/* - * Draws circles on map with a radius equal to the - * time delta (1 meter radius = 1 second of delta) - */ -toggleHandlers["showClientServerTimeDeltas"] = GenerateBubbles( - "showClientServerTimeDeltas", - (rawLocationLatLng, lastLocation, logEntry) => { - const clientTimeStr = _.get(logEntry.lastlocationResponse, "rawlocationtime"); - const serverTimeStr = _.get(logEntry.lastlocationResponse, "servertime"); - if (clientTimeStr && serverTimeStr) { - const clientDate = new Date(clientTimeStr); - const serverDate = new Date(serverTimeStr); - const timeDeltaSeconds = Math.abs(clientDate.getTime() - serverDate.getTime()) / 1000; - let color; - if (clientDate > serverDate) { - color = "#0000F0"; - } else { - color = "#0F0000"; - } - - let circ = new window.google.maps.Circle({ - strokeColor: color, - strokeOpacity: 0.6, - strokeWeight: 2, - fillColor: color, - fillOpacity: 0.2, - map, - center: rawLocationLatLng, - radius: timeDeltaSeconds, - }); - window.google.maps.event.addListener(circ, "mouseover", () => { - setFeaturedObject({ - timeDeltaSeconds: timeDeltaSeconds, - serverDate: serverDate, - clientDate: clientDate, - }); - }); - return circ; - } - } -); - -/* - * Draws arrows on map showing the measured heading - * of the vehicle (ie which direction vehicle was traveling - */ -toggleHandlers["showHeading"] = GenerateBubbles("showHeading", (rawLocationLatLng, lastLocation, logEntry) => { - // Note: Heading & accuracy are only on the _request_ not the - // response. - const heading = _.get(logEntry.lastlocation, "heading"); - const accuracy = _.get(logEntry.lastlocation, "headingaccuracy"); - - // Not currently using accuracy. How to show it? Maybe opacity of the arrorw? - const arrowLength = 20; // meters?? - if (!(heading && accuracy)) { - return; - } - const headingLine = new window.google.maps.Polyline({ - strokeColor: "#0000F0", - strokeOpacity: 0.6, - strokeWeight: 2, - icons: [ - { - icon: { - path: window.google.maps.SymbolPath.FORWARD_CLOSED_ARROW, - strokeColor: "#0000FF", - strokeWeight: 4, - }, - offset: "100%", - }, - ], - map, - path: [ - rawLocationLatLng, - window.google.maps.geometry.spherical.computeOffset(rawLocationLatLng, arrowLength, heading), - ], - }); - window.google.maps.event.addListener(headingLine, "click", () => { - // TODO: allow updating panorama based on forward/back - // stepper buttons (ie at each updatevehicle log we have a heading) - panorama = new window.google.maps.StreetViewPanorama(document.getElementById("map"), { - position: rawLocationLatLng, - pov: { heading: heading, pitch: 10 }, - addressControlOptions: { - position: window.google.maps.ControlPosition.BOTTOM_CENTER, - }, - linksControl: false, - panControl: false, - enableCloseButton: true, - }); - console.log("loaded panorama", panorama); - }); - return headingLine; -}); - -/* - * Draws circles on the map. Color indicates vehicle speed at that - * location. - */ -toggleHandlers["showSpeed"] = GenerateBubbles("showSpeed", (rawLocationLatLng, lastLocation) => { - const speed = lastLocation.speed; - if (lastLocation.speed === undefined) { - return; - } - const color = speed < 0 ? "#FF0000" : "#00FF00"; - return new window.google.maps.Circle({ - strokeColor: color, - strokeOpacity: 0.5, - fillColor: color, - fillOpacity: 0.5, - map, - center: rawLocationLatLng, - radius: Math.abs(speed), - }); -}); - -/* - * Draws circles on the map. Color indicates trip status - * at that location. Note that trip status isn't actually - * in the update vehicle logs, so current trip status will actually - * just be the trip status at the time of the vehicle update -- - * which is a bit wrong and wonky on the boundaries. - */ -toggleHandlers["showTripStatus"] = GenerateBubbles("showTripStatus", (rawLocationLatLng, lastLocation, le) => { - let color, - radius = 5; - const tripStatus = tripLogs.getTripStatusAtDate(le.date); - switch (tripStatus) { - case "NEW": - color = "#002200"; - radius = 30; - break; - case "ENROUTE_TO_PICKUP": - color = "#FFFF00"; - break; - case "ARRIVED_AT_PICKUP": - color = "#FFFF10"; - radius = 10; - break; - case "ARRIVED_AT_INTERMEDIATE_DESTINATION": - color = "#10FFFF"; - radius = 20; - break; - case "ENROUTE_TO_DROPOFF": - color = "#00FFFF"; - break; - case "COMPLETE": - radius = 30; - color = "#00FF00"; - break; - case "CANCELED": - radius = 30; - color = "#FF0000"; - break; - case "UNKNOWN_TRIP_STATUS": - default: - color = "#000000"; - } - - const statusCirc = new window.google.maps.Circle({ - strokeColor: color, - strokeOpacity: 0.5, - fillColor: color, - fillOpacity: 0.5, - map, - center: rawLocationLatLng, - radius: radius, // set based on trip status? - }); - window.google.maps.event.addListener(statusCirc, "mouseover", () => { - setFeaturedObject({ - tripStatus: tripStatus, - }); - }); - return statusCirc; -}); - -/* - * Enable/disables live traffic layer - */ -toggleHandlers["showTraffic"] = function (enabled) { - if (!trafficLayer) { - trafficLayer = new window.google.maps.TrafficLayer(); - } - if (enabled) { - trafficLayer.setMap(map); - } else { - trafficLayer.setMap(null); - } -}; - -/* - * Draws circles on the map. Size indicates dwell time at that - * location. - */ -toggleHandlers["showDwellLocations"] = function (enabled) { - const bubbleName = "showDwellLocations"; - const dwellLocations = tripLogs.getDwellLocations(minDate, maxDate); - _.forEach(bubbleMap[bubbleName], (bubble) => bubble.setMap(null)); - delete bubbleMap[bubbleName]; - if (enabled) { - bubbleMap[bubbleName] = _.map(dwellLocations, (dl) => { - const circ = new window.google.maps.Circle({ - strokeColor: "#000000", - strokeOpacity: 0.25, - fillColor: "#FFFF00", - fillOpacity: 0.25, - map, - center: dl.leaderCoords, - radius: dl.updates * 3, // make dwell times more obvious - }); - window.google.maps.event.addListener(circ, "mouseover", () => { - setFeaturedObject({ - startDate: dl.startDate, - duration: Utils.formatDuration(dl.endDate - dl.startDate), - endDate: dl.endDate, - }); - }); - return circ; - }); - } -}; - -/* - * Draws markers on the map for all tasks. - */ -toggleHandlers["showTasksAsCreated"] = function (enabled) { - const bubbleName = "showTasksAsCreated"; - const tasks = taskLogs.getTasks(maxDate).value(); - _.forEach(bubbleMap[bubbleName], (bubble) => bubble.setMap(null)); - delete bubbleMap[bubbleName]; - function getIcon(task) { - const outcome = task.taskoutcome || "unknown"; - const urlBase = "http://maps.google.com/mapfiles/kml/shapes/"; - const icon = { - url: urlBase, - scaledSize: new window.google.maps.Size(35, 35), - }; - if (outcome.match("SUCCEEDED")) { - icon.url += "flag.png"; - } else if (outcome.match("FAIL")) { - icon.url += "caution.png"; - } else { - icon.url += "shaded_dot.png"; - } - return icon; - } - if (enabled) { - bubbleMap[bubbleName] = _(tasks) - .map((task) => { - const marker = new window.google.maps.Marker({ - position: { - lat: task.plannedlocation.point.latitude, - lng: task.plannedlocation.point.longitude, - }, - map: map, - icon: getIcon(task), - title: `${task.state}: ${task.taskid} - ${task.trackingid}`, - }); - window.google.maps.event.addListener(marker, "click", () => { - setFeaturedObject(task); - }); - const ret = [marker]; - const arrowColor = task.plannedVsActualDeltaMeters > 50 ? "#FF1111" : "#11FF11"; - if (task.taskoutcomelocation) { - const offSetPath = new window.google.maps.Polyline({ - path: [ - { - lat: task.plannedlocation.point.latitude, - lng: task.plannedlocation.point.longitude, - }, - { - lat: task.taskoutcomelocation.point.latitude, - lng: task.taskoutcomelocation.point.longitude, - }, - ], - geodesic: true, - strokeColor: arrowColor, - strokeOpacity: 0.6, - strokeWeight: 4, - map: map, - icons: [ - { - icon: { - path: window.google.maps.SymbolPath.FORWARD_CLOSED_ARROW, - strokeColor: arrowColor, - strokeWeight: 4, - }, - offset: "100%", - }, - ], - }); - ret.push(offSetPath); - } - return ret; - }) - .flatten() - .value(); - } -}; - -/* - * Draws planned paths on the map. - */ -toggleHandlers["showPlannedPaths"] = function (enabled) { - const bubbleName = "showPlannedPaths"; - _.forEach(bubbleMap[bubbleName], (bubble) => bubble.setMap(null)); - delete bubbleMap[bubbleName]; - - if (enabled) { - const trips = tripLogs.getTrips(); - bubbleMap[bubbleName] = trips - .filter((trip) => { - return trip.firstUpdate <= maxDate && trip.lastUpdate >= minDate && trip.getPlannedPath().length > 0; - }) - .map((trip) => { - const plannedPath = trip.getPlannedPath(); - const path = new window.google.maps.Polyline({ - path: plannedPath, - geodesic: true, - strokeColor: getColor(trip.tripIdx), - strokeOpacity: 0.3, - strokeWeight: 6, - }); - path.setMap(map); - return path; - }); - } -}; - -/* - * Draws circles on the map. Size indicates dwell time at that - * location. - */ -toggleHandlers["showNavStatus"] = GenerateBubbles("showNavStatus", (rawLocationLatLng, lastLocation, le) => { - const navStatus = le.navStatus; - if (navStatus === undefined) { - return; - } - let color, - radius = 5; - switch (navStatus) { - case "UNKNOWN_NAVIGATION_STATUS": - color = "#222222"; - break; - case "NO_GUIDANCE": - color = "#090909"; - break; - case "ENROUTE_TO_DESTINATION": - color = "#00FF00"; - break; - case "OFF_ROUTE": - color = "#FF0000"; - radius = 30; - break; - case "ARRIVED_AT_DESTINATION": - color = "0000FF"; - radius = 10; - break; - default: - color = "#000000"; - } - const statusCirc = new window.google.maps.Circle({ - strokeColor: color, - strokeOpacity: 0.5, - fillColor: color, - fillOpacity: 0.5, - map, - center: rawLocationLatLng, - radius: radius, // set based on trip status? - }); - window.google.maps.event.addListener(statusCirc, "mouseover", () => { - setFeaturedObject({ - navStatus: navStatus, - vehicleState: _.get(le, "response.vehiclestate"), - tripStatus: "??", - }); - }); - return statusCirc; -}); - -/* - * Draws circles on the map. Size indicates delta in seconds at that - * location. - */ -toggleHandlers["showETADeltas"] = function (enabled) { - const bubbleName = "showETADeltas"; - _.forEach(bubbleMap[bubbleName], (bubble) => bubble.setMap(null)); - delete bubbleMap[bubbleName]; - const etaDeltas = tripLogs.getETADeltas(minDate, maxDate); - if (enabled) { - bubbleMap[bubbleName] = _.map(etaDeltas, (etaDelta) => { - const circ = new window.google.maps.Circle({ - strokeColor: "#000000", - strokeOpacity: 0.25, - fillColor: "FF0000", - fillOpacity: 0.25, - map, - center: etaDelta.coords, - // cap radius to 300 meters to avoid coloring the whole - // screen when there is a very large delta. Definitely - // needs tuning ... and likely better to consider adjusting - // color as well. - radius: _.min([etaDelta.deltaInSeconds, 300]), - }); - window.google.maps.event.addListener(circ, "mouseover", () => { - setFeaturedObject({ - etaDeltaInSeconds: etaDelta.deltaInSeconds, - }); - }); - return circ; - }); - } -}; - -/* - * Draws arrows on the map showing where a vehicle jumped - * from one location to another at an unrealistic velocity. - */ -toggleHandlers["showHighVelocityJumps"] = function (enabled) { - const bubbleName = "showHighVelocityJumps"; - - tripLogs.debouncedGetHighVelocityJumps(minDate, maxDate, (jumps) => { - _.forEach(bubbleMap[bubbleName], (bubble) => bubble.setMap(null)); - delete bubbleMap[bubbleName]; - - if (enabled) { - bubbleMap[bubbleName] = _(jumps) - .map((jump) => { - function getStrokeWeight(velocity) { - if (velocity <= 100) { - return 2; - } else if (velocity < 1000) { - return 6; - } else if (velocity < 2000) { - return 10; - } else { - return 14; - } - } - const path = new window.google.maps.Polyline({ - path: [jump.startLoc, jump.endLoc], - geodesic: true, - strokeColor: getColor(jump.jumpIdx), - strokeOpacity: 0.8, - strokeWeight: getStrokeWeight(jump.velocity), - map: map, - icons: [ - { - icon: { - path: window.google.maps.SymbolPath.FORWARD_CLOSED_ARROW, - strokeColor: getColor(jump.jumpIdx), - strokeWeight: getStrokeWeight(jump.velocity), - }, - offset: "100%", - }, - ], - }); - window.google.maps.event.addListener(path, "mouseover", () => { - setFeaturedObject(jump.getFeaturedData()); - }); - window.google.maps.event.addListener(path, "click", () => { - setFeaturedObject(jump.getFeaturedData()); - // show a minute +/- on each side of a jump - setTimeRange(jump.startDate.getTime() - 60 * 1000, jump.endDate.getTime() + 60 * 1000); - }); - return [path]; - }) - .flatten() - .value(); - } else { - console.log("Disabling high velocity jumps"); - setTimeRange(tripLogs.minDate.getTime(), tripLogs.maxDate.getTime()); - } - }); -}; - -/* - * Marks locations on the map where we did not get the expected - * updateVehicle requests - */ -toggleHandlers["showMissingUpdates"] = function (enabled) { - const bubbleName = "showMissingUpdates"; - const missingUpdates = tripLogs.getMissingUpdates(minDate, maxDate); - _.forEach(bubbleMap[bubbleName], (bubble) => bubble.setMap(null)); - delete bubbleMap[bubbleName]; - if (enabled) { - bubbleMap[bubbleName] = _(missingUpdates) - .map((update) => { - function getStrokeWeight(interval) { - if (interval <= 60 * 1000) { - return 2; - } else if (interval < 60 * 10 * 1000) { - return 6; - } else if (interval < 60 * 60 * 10 * 1000) { - return 10; - } else { - return 14; - } - } - const heading = window.google.maps.geometry.spherical.computeHeading(update.startLoc, update.endLoc); - const offsetHeading = ((heading + 360 + 90) % 360) - 180; - const points = [ - update.startLoc, - window.google.maps.geometry.spherical.computeOffset( - update.startLoc, - 1000, //TODO compute based on viewport? - offsetHeading - ), - window.google.maps.geometry.spherical.computeOffset( - update.startLoc, - 900, //TODO compute based on viewport? - offsetHeading - ), - window.google.maps.geometry.spherical.computeOffset( - update.endLoc, - 900, //TODO compute based on viewport? - offsetHeading - ), - window.google.maps.geometry.spherical.computeOffset( - update.endLoc, - 1000, //TODO compute based on viewport? - offsetHeading - ), - update.endLoc, - ]; - const path = new window.google.maps.Polyline({ - path: points, - geodesic: true, - strokeColor: "#008B8B", - strokeOpacity: 0.5, - strokeWeight: getStrokeWeight(update.interval), - map: map, - icons: [ - { - icon: { - path: window.google.maps.SymbolPath.FORWARD_CLOSED_ARROW, - strokeColor: "#008B8B", - strokeWeight: getStrokeWeight(update.interval), - scale: 6, - }, - offset: "50%", - }, - { - icon: { - path: window.google.maps.SymbolPath.CIRCLE, - scale: 6, - strokeColor: "#000000", - strokeWeight: 1, - strokeOpacity: 0.5, - }, - offset: "0%", - }, - { - icon: { - path: window.google.maps.SymbolPath.CIRCLE, - scale: 6, - strokeColor: "#000000", - strokeWeight: 1, - strokeOpacity: 0.5, - }, - offset: "100%", - }, - ], - }); - window.google.maps.event.addListener(path, "mouseover", () => { - setFeaturedObject(update.getFeaturedData()); - path.setOptions({ - strokeOpacity: 1, - strokeWeight: 1.5 * getStrokeWeight(update.interval), - }); - }); - window.google.maps.event.addListener(path, "mouseout", () => { - path.setOptions({ - strokeOpacity: 0.5, - strokeWeight: getStrokeWeight(update.interval), - }); - }); - window.google.maps.event.addListener(path, "click", () => { - setFeaturedObject(update.getFeaturedData()); - // show a minute +/- on each side of a update - setTimeRange(update.startDate.getTime() - 60 * 1000, update.endDate.getTime() + 60 * 1000); - }); - return [path]; - }) - .flatten() - .value(); - } else { - // TODO: ideally reset to timerange that was selected before enabling - // jump view - setTimeRange(tripLogs.minDate.getTime(), tripLogs.maxDate.getTime()); - } -}; - -/* - * Enable/disables live journey sharing view - */ -toggleHandlers["showLiveJS"] = function (enabled) { - if (!jwt) { - console.log("Issue #25 -- no/invalid jwt"); - return; - } - // call into js to set the trip - if (enabled) { - locationProvider.tripId = _.last(tripLogs.getTripIDs()); - } else { - locationProvider.tripId = ""; - } -}; - -export { Map as default }; diff --git a/src/MapToggles.js b/src/MapToggles.js new file mode 100644 index 0000000..ae99d0a --- /dev/null +++ b/src/MapToggles.js @@ -0,0 +1,590 @@ +// src/MapToggles.js +import _ from "lodash"; +import { getColor } from "./Trip"; +import Utils, { log } from "./Utils"; + +export const ALL_TOGGLES = [ + { + id: "showGPSBubbles", + name: "GPS Accuracy", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/GPSAccuracy.md", + columns: ["lastlocation.rawlocationaccuracy", "lastlocation.locationsensor"], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showHeading", + name: "Heading", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/Heading.md", + columns: ["lastlocation.heading", "lastlocation.headingaccuracy"], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showSpeed", + name: "Speed", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/Speed.md", + columns: [], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showTripStatus", + name: "Trip Status", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/TripStatus.md", + columns: [], + solutionTypes: ["ODRD"], + }, + { + id: "showNavStatus", + name: "Navigation Status", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/NavStatus.md", + columns: [], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showTasksAsCreated", + name: "Tasks", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/Tasks.md", + columns: [], + solutionTypes: ["LMFS"], + }, + { + id: "showPlannedPaths", + name: "Planned Paths", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/PlannedPaths.md", + columns: [], + solutionTypes: ["LMFS"], + }, + { + id: "showDwellLocations", + name: "Dwell Locations", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/DwellTimes.md", + columns: [], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showHighVelocityJumps", + name: "Jumps (unrealistic velocity)", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/VelocityJumps.md", + columns: [], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showMissingUpdates", + name: "Jumps (Temporal)", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/MissingUpdates.md", + columns: ["temporal_gap"], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showClientServerTimeDeltas", + name: "Client/Server Time Deltas", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/README.md", + columns: [], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showETADeltas", + name: "ETA Deltas", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/docs/EtaDeltas.md", + columns: ["request.vehicle.etatofirstwaypoint"], + solutionTypes: ["ODRD"], + }, + { + id: "showTraffic", + name: "Live Traffic", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/README.md", + columns: [], + solutionTypes: ["ODRD", "LMFS"], + }, + { + id: "showLiveJS", + name: "Live Journey Sharing", + docLink: "https://github.com/googlemaps/fleet-debugger/blob/main/README.md", + columns: [], + solutionTypes: ["ODRD", "LMFS"], + }, +]; + +export function getVisibleToggles(solutionType) { + if (!solutionType) return []; + log(`Filtering toggles for solution type: ${solutionType}`); + return _.filter(ALL_TOGGLES, (toggle) => toggle.solutionTypes.indexOf(solutionType) !== -1); +} + +export function getToggleHandlers({ + map, + tripLogs, + taskLogs, + minDate, + maxDate, + setFeaturedObject, + setTimeRange, + bubbleMapRef, + panoramaRef, + mapDivRef, + trafficLayerRef, + locationProviderRef, + jwt, +}) { + const GenerateBubbles = (bubbleName, cb) => (showBubble) => { + _.forEach(bubbleMapRef.current[bubbleName], (bubble) => bubble.setMap(null)); + delete bubbleMapRef.current[bubbleName]; + if (showBubble) { + log(`Enabling ${bubbleName}`); + bubbleMapRef.current[bubbleName] = tripLogs + .getLogs_(minDate, maxDate) + .map((le) => { + const rawloc = le.lastlocation?.rawlocation; + if (rawloc && typeof rawloc.latitude === "number" && typeof rawloc.longitude === "number") { + const latLng = new window.google.maps.LatLng(rawloc.latitude, rawloc.longitude); + return cb(latLng, le.lastlocation, le); + } + return null; + }) + .compact() + .value(); + } + }; + + return { + showGPSBubbles: GenerateBubbles("showGPSBubbles", (rawLocationLatLng, lastLocation) => { + let color; + switch (lastLocation.locationsensor) { + case "GPS": + color = "#11FF11"; + break; + case "NETWORK": + color = "#FF1111"; + break; + case "PASSIVE": + color = "#FF0000"; + break; + case "ROAD_SNAPPED_LOCATION_PROVIDER": + color = "#00FF00"; + break; + case "FUSED_LOCATION_PROVIDER": + color = "#11FF11"; + break; + case "LOG_UNSPECIFIED": + default: + color = "#000000"; + } + const accuracy = lastLocation.rawlocationaccuracy; + if (accuracy) { + const circ = new window.google.maps.Circle({ + strokeColor: color, + strokeOpacity: 0.6, + strokeWeight: 2, + fillColor: color, + fillOpacity: 0.2, + map, + center: rawLocationLatLng, + radius: accuracy, + }); + window.google.maps.event.addListener(circ, "mouseover", () => { + setFeaturedObject({ + rawlocationaccuracy: lastLocation.rawlocationaccuracy, + locationsensor: lastLocation.locationsensor, + }); + }); + return circ; + } + }), + showClientServerTimeDeltas: GenerateBubbles( + "showClientServerTimeDeltas", + (rawLocationLatLng, lastLocation, logEntry) => { + const clientTimeStr = _.get(logEntry.lastlocationResponse, "rawlocationtime"); + const serverTimeStr = _.get(logEntry.lastlocationResponse, "servertime"); + if (clientTimeStr && serverTimeStr) { + const clientDate = new Date(clientTimeStr); + const serverDate = new Date(serverTimeStr); + const timeDeltaSeconds = Math.abs(clientDate.getTime() - serverDate.getTime()) / 1000; + let color = clientDate > serverDate ? "#0000F0" : "#0F0000"; + + const circ = new window.google.maps.Circle({ + strokeColor: color, + strokeOpacity: 0.6, + strokeWeight: 2, + fillColor: color, + fillOpacity: 0.2, + map, + center: rawLocationLatLng, + radius: timeDeltaSeconds, + }); + window.google.maps.event.addListener(circ, "mouseover", () => { + setFeaturedObject({ + timeDeltaSeconds: timeDeltaSeconds, + serverDate: serverDate, + clientDate: clientDate, + }); + }); + return circ; + } + } + ), + showHeading: GenerateBubbles("showHeading", (rawLocationLatLng, lastLocation, logEntry) => { + const heading = _.get(logEntry.lastlocation, "heading"); + const accuracy = _.get(logEntry.lastlocation, "headingaccuracy"); + if (heading === undefined || accuracy === undefined) { + return; + } + const headingLine = new window.google.maps.Polyline({ + strokeColor: "#0000F0", + strokeOpacity: 0.6, + strokeWeight: 2, + icons: [ + { + icon: { + path: window.google.maps.SymbolPath.FORWARD_CLOSED_ARROW, + strokeColor: "#0000FF", + strokeWeight: 4, + }, + offset: "100%", + }, + ], + map, + path: [rawLocationLatLng, window.google.maps.geometry.spherical.computeOffset(rawLocationLatLng, 20, heading)], + }); + headingLine.addListener("click", () => { + log("Heading line clicked, showing StreetView."); + panoramaRef.current = new window.google.maps.StreetViewPanorama(mapDivRef.current, { + position: rawLocationLatLng, + pov: { heading, pitch: 10 }, + }); + }); + return headingLine; + }), + showSpeed: GenerateBubbles("showSpeed", (rawLocationLatLng, lastLocation) => { + const speed = lastLocation.speed; + if (speed === undefined) { + return; + } + const color = speed < 0 ? "#FF0000" : "#00FF00"; + return new window.google.maps.Circle({ + strokeColor: color, + strokeOpacity: 0.5, + fillColor: color, + fillOpacity: 0.5, + map, + center: rawLocationLatLng, + radius: Math.abs(speed), + }); + }), + showTripStatus: GenerateBubbles("showTripStatus", (rawLocationLatLng, lastLocation, le) => { + let color, + radius = 5; + const tripStatus = tripLogs.getTripStatusAtDate(le.date); + switch (tripStatus) { + case "NEW": + color = "#002200"; + radius = 30; + break; + case "ENROUTE_TO_PICKUP": + color = "#FFFF00"; + break; + case "ARRIVED_AT_PICKUP": + color = "#FFFF10"; + radius = 10; + break; + case "ARRIVED_AT_INTERMEDIATE_DESTINATION": + color = "#10FFFF"; + radius = 20; + break; + case "ENROUTE_TO_DROPOFF": + color = "#00FFFF"; + break; + case "COMPLETE": + radius = 30; + color = "#00FF00"; + break; + case "CANCELED": + radius = 30; + color = "#FF0000"; + break; + case "UNKNOWN_TRIP_STATUS": + default: + color = "#000000"; + } + + const statusCirc = new window.google.maps.Circle({ + strokeColor: color, + strokeOpacity: 0.5, + fillColor: color, + fillOpacity: 0.5, + map, + center: rawLocationLatLng, + radius: radius, + }); + window.google.maps.event.addListener(statusCirc, "mouseover", () => { + setFeaturedObject({ + tripStatus: tripStatus, + }); + }); + return statusCirc; + }), + showNavStatus: GenerateBubbles("showNavStatus", (rawLocationLatLng, lastLocation, le) => { + const navStatus = le.navStatus; + if (navStatus === undefined) { + return; + } + let color, + radius = 5; + switch (navStatus) { + case "UNKNOWN_NAVIGATION_STATUS": + color = "#222222"; + break; + case "NO_GUIDANCE": + color = "#090909"; + break; + case "ENROUTE_TO_DESTINATION": + color = "#00FF00"; + break; + case "OFF_ROUTE": + color = "#FF0000"; + radius = 30; + break; + case "ARRIVED_AT_DESTINATION": + color = "#0000FF"; + radius = 10; + break; + default: + color = "#000000"; + } + const statusCirc = new window.google.maps.Circle({ + strokeColor: color, + strokeOpacity: 0.5, + fillColor: color, + fillOpacity: 0.5, + map, + center: rawLocationLatLng, + radius: radius, + }); + window.google.maps.event.addListener(statusCirc, "mouseover", () => { + setFeaturedObject({ + navStatus: navStatus, + }); + }); + return statusCirc; + }), + showTraffic: (enabled) => { + if (!trafficLayerRef.current) { + trafficLayerRef.current = new window.google.maps.TrafficLayer(); + } + if (enabled) { + log("Enabling Traffic"); + trafficLayerRef.current.setMap(map); + } else { + log("Disabling Traffic"); + trafficLayerRef.current.setMap(null); + } + }, + showLiveJS: (enabled) => { + if (!jwt) { + log("Cannot enable LiveJS: JWT is missing."); + return; + } + if (enabled) { + log("Enabling LiveJS"); + locationProviderRef.current.tripId = _.last(tripLogs.getTripIDs()); + } else { + log("Disabling LiveJS"); + locationProviderRef.current.tripId = ""; + } + }, + showDwellLocations: (enabled) => { + const bubbleName = "showDwellLocations"; + _.forEach(bubbleMapRef.current[bubbleName], (bubble) => bubble.setMap(null)); + delete bubbleMapRef.current[bubbleName]; + + if (enabled) { + log(`Enabling ${bubbleName}`); + bubbleMapRef.current[bubbleName] = _.map(tripLogs.getDwellLocations(minDate, maxDate), (dl) => { + const circ = new window.google.maps.Circle({ + strokeColor: "#000000", + strokeOpacity: 0.25, + fillColor: "#FFFF00", + fillOpacity: 0.25, + map, + center: dl.leaderCoords, + radius: dl.updates * 3, + }); + window.google.maps.event.addListener(circ, "mouseover", () => { + setFeaturedObject({ + startDate: dl.startDate, + duration: Utils.formatDuration(dl.endDate - dl.startDate), + endDate: dl.endDate, + }); + }); + return circ; + }); + } + }, + showTasksAsCreated: (enabled) => { + const bubbleName = "showTasksAsCreated"; + _.forEach(bubbleMapRef.current[bubbleName], (b) => b.setMap(null)); + delete bubbleMapRef.current[bubbleName]; + if (enabled) { + log(`Enabling ${bubbleName}`); + bubbleMapRef.current[bubbleName] = _.map( + taskLogs.getTasks(maxDate).value(), + (t) => new window.google.maps.Marker({ map, position: t.plannedlocation.point }) + ); + } + }, + showPlannedPaths: (enabled) => { + const bubbleName = "showPlannedPaths"; + _.forEach(bubbleMapRef.current[bubbleName], (b) => b.setMap(null)); + delete bubbleMapRef.current[bubbleName]; + if (enabled) { + log(`Enabling ${bubbleName}`); + bubbleMapRef.current[bubbleName] = tripLogs + .getTrips() + .filter((t) => t.getPlannedPath().length > 0) + .map( + (t) => new window.google.maps.Polyline({ map, path: t.getPlannedPath(), strokeColor: getColor(t.tripIdx) }) + ); + } + }, + showETADeltas: (enabled) => { + const bubbleName = "showETADeltas"; + _.forEach(bubbleMapRef.current[bubbleName], (b) => b.setMap(null)); + delete bubbleMapRef.current[bubbleName]; + if (enabled) { + log(`Enabling ${bubbleName}`); + bubbleMapRef.current[bubbleName] = _.map(tripLogs.getETADeltas(minDate, maxDate), (d) => { + const circ = new window.google.maps.Circle({ + map, + center: d.coords, + radius: _.min([d.deltaInSeconds, 300]), + strokeColor: "#000000", + strokeOpacity: 0.25, + fillColor: "#FF0000", + fillOpacity: 0.25, + }); + window.google.maps.event.addListener(circ, "mouseover", () => { + setFeaturedObject({ + etaDeltaInSeconds: d.deltaInSeconds, + }); + }); + return circ; + }); + } + }, + showHighVelocityJumps: (enabled) => { + const bubbleName = "showHighVelocityJumps"; + tripLogs.debouncedGetHighVelocityJumps(minDate, maxDate, (jumps) => { + _.forEach(bubbleMapRef.current[bubbleName], (b) => b.setMap(null)); + delete bubbleMapRef.current[bubbleName]; + if (enabled) { + log(`Enabling ${bubbleName}`); + bubbleMapRef.current[bubbleName] = _(jumps) + .map((jump) => { + const getStrokeWeight = (velocity) => + velocity <= 100 ? 2 : velocity < 1000 ? 6 : velocity < 2000 ? 10 : 14; + const path = new window.google.maps.Polyline({ + path: [jump.startLoc, jump.endLoc], + geodesic: true, + strokeColor: getColor(jump.jumpIdx), + strokeOpacity: 0.8, + strokeWeight: getStrokeWeight(jump.velocity), + map: map, + icons: [ + { + icon: { + path: window.google.maps.SymbolPath.FORWARD_CLOSED_ARROW, + strokeColor: getColor(jump.jumpIdx), + strokeWeight: getStrokeWeight(jump.velocity), + }, + offset: "100%", + }, + ], + }); + path.addListener("mouseover", () => setFeaturedObject(jump.getFeaturedData())); + path.addListener("click", () => { + setFeaturedObject(jump.getFeaturedData()); + setTimeRange(jump.startDate.getTime() - 60 * 1000, jump.endDate.getTime() + 60 * 1000); + }); + return [path]; + }) + .flatten() + .value(); + } + }); + }, + showMissingUpdates: (enabled) => { + const bubbleName = "showMissingUpdates"; + _.forEach(bubbleMapRef.current[bubbleName], (b) => b.setMap(null)); + delete bubbleMapRef.current[bubbleName]; + if (enabled) { + log(`Enabling ${bubbleName}`); + bubbleMapRef.current[bubbleName] = _(tripLogs.getMissingUpdates(minDate, maxDate)) + .map((update) => { + const getStrokeWeight = (interval) => + interval <= 60000 ? 2 : interval < 600000 ? 6 : interval < 36000000 ? 10 : 14; + const heading = window.google.maps.geometry.spherical.computeHeading(update.startLoc, update.endLoc); + const offsetHeading = ((heading + 360 + 90) % 360) - 180; + const points = [ + update.startLoc, + window.google.maps.geometry.spherical.computeOffset(update.startLoc, 1000, offsetHeading), + window.google.maps.geometry.spherical.computeOffset(update.startLoc, 900, offsetHeading), + window.google.maps.geometry.spherical.computeOffset(update.endLoc, 900, offsetHeading), + window.google.maps.geometry.spherical.computeOffset(update.endLoc, 1000, offsetHeading), + update.endLoc, + ]; + const path = new window.google.maps.Polyline({ + path: points, + geodesic: true, + strokeColor: "#008B8B", + strokeOpacity: 0.5, + strokeWeight: getStrokeWeight(update.interval), + map: map, + icons: [ + { + icon: { + path: window.google.maps.SymbolPath.FORWARD_CLOSED_ARROW, + strokeColor: "#008B8B", + strokeWeight: getStrokeWeight(update.interval), + scale: 6, + }, + offset: "50%", + }, + { + icon: { + path: window.google.maps.SymbolPath.CIRCLE, + scale: 6, + strokeColor: "#000000", + strokeWeight: 1, + strokeOpacity: 0.5, + }, + offset: "0%", + }, + { + icon: { + path: window.google.maps.SymbolPath.CIRCLE, + scale: 6, + strokeColor: "#000000", + strokeWeight: 1, + strokeOpacity: 0.5, + }, + offset: "100%", + }, + ], + }); + path.addListener("mouseover", () => { + setFeaturedObject(update.getFeaturedData()); + path.setOptions({ strokeOpacity: 1, strokeWeight: 1.5 * getStrokeWeight(update.interval) }); + }); + path.addListener("mouseout", () => + path.setOptions({ strokeOpacity: 0.5, strokeWeight: getStrokeWeight(update.interval) }) + ); + path.addListener("click", () => { + setFeaturedObject(update.getFeaturedData()); + setTimeRange(update.startDate.getTime() - 60 * 1000, update.endDate.getTime() + 60 * 1000); + }); + return [path]; + }) + .flatten() + .value(); + } + }, + }; +} diff --git a/src/MapToggles.test.js b/src/MapToggles.test.js new file mode 100644 index 0000000..1567dc3 --- /dev/null +++ b/src/MapToggles.test.js @@ -0,0 +1,51 @@ +// src/MapToggles.test.js +import { getVisibleToggles, ALL_TOGGLES } from "./MapToggles"; + +describe("getVisibleToggles", () => { + test('should return ODRD and common toggles for "ODRD" solution type', () => { + const solutionType = "ODRD"; + const visibleToggles = getVisibleToggles(solutionType); + + // Manually check for a toggle that is ODRD-only + expect(visibleToggles.some((t) => t.id === "showTripStatus")).toBe(true); + // Manually check for a toggle that is NOT for ODRD + expect(visibleToggles.some((t) => t.id === "showTasksAsCreated")).toBe(false); + + // Compare against the master list for accuracy + const expectedToggles = ALL_TOGGLES.filter((t) => t.solutionTypes.includes("ODRD")); + expect(visibleToggles).toHaveLength(expectedToggles.length); + + const visibleToggleIds = visibleToggles.map((t) => t.id); + const expectedToggleIds = expectedToggles.map((t) => t.id); + expect(visibleToggleIds).toEqual(expect.arrayContaining(expectedToggleIds)); + }); + + test('should return LMFS and common toggles for "LMFS" solution type', () => { + const solutionType = "LMFS"; + const visibleToggles = getVisibleToggles(solutionType); + + // Manually check for a toggle that is LMFS-only + expect(visibleToggles.some((t) => t.id === "showTasksAsCreated")).toBe(true); + // Manually check for a toggle that is NOT for LMFS + expect(visibleToggles.some((t) => t.id === "showTripStatus")).toBe(false); + + const expectedToggles = ALL_TOGGLES.filter((t) => t.solutionTypes.includes("LMFS")); + expect(visibleToggles).toHaveLength(expectedToggles.length); + + const visibleToggleIds = visibleToggles.map((t) => t.id); + const expectedToggleIds = expectedToggles.map((t) => t.id); + expect(visibleToggleIds).toEqual(expect.arrayContaining(expectedToggleIds)); + }); + + test("should return an empty array for an unknown solution type", () => { + const solutionType = "UNKNOWN_TYPE"; + const visibleToggles = getVisibleToggles(solutionType); + expect(visibleToggles).toEqual([]); + expect(visibleToggles).toHaveLength(0); + }); + + test("should return an empty array for null or undefined solution type", () => { + expect(getVisibleToggles(null)).toEqual([]); + expect(getVisibleToggles(undefined)).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/TripObjects.js b/src/TripObjects.js index e7f67ae..86348d7 100644 --- a/src/TripObjects.js +++ b/src/TripObjects.js @@ -138,19 +138,19 @@ export class TripObjects { path: tripCoords, geodesic: true, strokeColor: strokeColor, - strokeOpacity: 0.3, - strokeWeight: 6, + strokeOpacity: 0.6, + strokeWeight: 5, clickable: true, zIndex: 1, map: this.map, }); google.maps.event.addListener(path, "mouseover", () => { - path.setOptions({ strokeOpacity: 0.7, strokeWeight: 8, zIndex: 100 }); + path.setOptions({ strokeOpacity: 0.8, strokeWeight: 6, zIndex: 100 }); }); google.maps.event.addListener(path, "mouseout", () => { - path.setOptions({ strokeOpacity: 0.3, strokeWeight: 6, zIndex: 1 }); + path.setOptions({ strokeOpacity: 0.6, strokeWeight: 5, zIndex: 1 }); }); // Handle click on polyline but pass the event through to the map