diff --git a/application/frontend/src/app/core/actions/index.ts b/application/frontend/src/app/core/actions/index.ts index 74c059ab..573240cd 100644 --- a/application/frontend/src/app/core/actions/index.ts +++ b/application/frontend/src/app/core/actions/index.ts @@ -38,6 +38,7 @@ import * as ShipmentModelActions from './shipment-model.actions'; import * as ShipmentRouteActions from './shipment-route.actions'; import * as ShipmentsMetadataActions from './shipments-metadata.actions'; import * as StorageApiActions from './storage-api.actions'; +import * as TravelSimulatorActions from '../actions/travel-simulator.actions'; import * as UIActions from './ui.actions'; import * as UndoRedoActions from './undo-redo.actions'; import * as UploadActions from './upload.actions'; @@ -71,6 +72,7 @@ export { ShipmentModelActions, ShipmentRouteActions, StorageApiActions, + TravelSimulatorActions, UIActions, UndoRedoActions, UploadActions, diff --git a/application/frontend/src/app/core/actions/travel-simulator.actions.ts b/application/frontend/src/app/core/actions/travel-simulator.actions.ts new file mode 100644 index 00000000..2b145835 --- /dev/null +++ b/application/frontend/src/app/core/actions/travel-simulator.actions.ts @@ -0,0 +1,24 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { createAction, props } from '@ngrx/store'; + +export const setTime = createAction('[Travel Simulator] Set time', props<{ time: number }>()); + +export const setActive = createAction( + '[Travel Simulator] Set active', + props<{ active: boolean }>() +); diff --git a/application/frontend/src/app/core/containers/app/app.component.ts b/application/frontend/src/app/core/containers/app/app.component.ts index 2031d5d2..00142f10 100644 --- a/application/frontend/src/app/core/containers/app/app.component.ts +++ b/application/frontend/src/app/core/containers/app/app.component.ts @@ -79,7 +79,9 @@ export class AppComponent { 'navigate_before', 'navigate_next', 'open_in_new', + 'pause', 'pdf', + 'play', 'pickup', 'route', 'satellite', diff --git a/application/frontend/src/app/core/containers/map/map.component.html b/application/frontend/src/app/core/containers/map/map.component.html index ec25226a..98870deb 100644 --- a/application/frontend/src/app/core/containers/map/map.component.html +++ b/application/frontend/src/app/core/containers/map/map.component.html @@ -20,3 +20,4 @@ (activeChange)="onToggleSelectMapItems($event)"> + diff --git a/application/frontend/src/app/core/containers/map/map.component.spec.ts b/application/frontend/src/app/core/containers/map/map.component.spec.ts index 987bc206..a3af4aaf 100644 --- a/application/frontend/src/app/core/containers/map/map.component.spec.ts +++ b/application/frontend/src/app/core/containers/map/map.component.spec.ts @@ -22,6 +22,7 @@ import { MockInfoWindowService, MockLayerService } from 'src/test/service-mocks' import * as fromConfig from '../../selectors/config.selectors'; import * as fromMap from '../../selectors/map.selectors'; import * as fromUI from '../../selectors/ui.selectors'; +import TravelSimulatorSelectors from '../../selectors/travel-simulator.selectors'; import { DepotLayer, MapService, @@ -81,6 +82,12 @@ class MockPostSolveMapLegendComponent { @Output() setLayerVisibility = new EventEmitter<{ layerId: MapLayerId; visible: boolean }>(); } +@Component({ + selector: 'app-travel-simulator', + template: '', +}) +class MockTravelSimulatorComponent {} + describe('MapComponent', () => { let component: MapComponent; let fixture: ComponentFixture; @@ -99,6 +106,7 @@ describe('MapComponent', () => { MockMapWrapperComponent, MockZoomHomeButtonComponent, MockPostSolveMapLegendComponent, + MockTravelSimulatorComponent, MapComponent, ], providers: [ @@ -119,6 +127,7 @@ describe('MapComponent', () => { { selector: fromUI.selectPage, value: null }, { selector: fromUI.selectHasMap, value: false }, { selector: fromConfig.selectTimezoneOffset, value: 0 }, + { selector: TravelSimulatorSelectors.selectTravelSimulatorVisible, value: false }, ], }), ], diff --git a/application/frontend/src/app/core/containers/map/map.component.ts b/application/frontend/src/app/core/containers/map/map.component.ts index 0d019adf..85eaced1 100644 --- a/application/frontend/src/app/core/containers/map/map.component.ts +++ b/application/frontend/src/app/core/containers/map/map.component.ts @@ -45,6 +45,7 @@ import * as fromPostSolve from '../../selectors/post-solve.selectors'; import * as fromPreSolve from '../../selectors/pre-solve.selectors'; import * as fromUI from '../../selectors/ui.selectors'; import { selectPage } from '../../selectors/ui.selectors'; +import TravelSimulatorSelectors from '../../selectors/travel-simulator.selectors'; import { MATERIAL_COLORS, VehicleInfoWindowService, @@ -71,6 +72,7 @@ export class MapComponent implements OnInit, OnDestroy { selectionFilterActive$: Observable; timezoneOffset$: Observable; layers$: Observable<{ [id in MapLayerId]?: MapLayer }>; + travelSimulatorVisible$: Observable; get bounds(): google.maps.LatLngBounds { return this.mapService.bounds; @@ -100,6 +102,9 @@ export class MapComponent implements OnInit, OnDestroy { this.mapSelectionToolsVisible$ = this.store.pipe(select(selectMapSelectionToolsVisible)); this.selectionFilterActive$ = this.store.pipe(select(selectSelectionFilterActive)); this.layers$ = this.store.pipe(select(fromMap.selectPostSolveMapLayers)); + this.travelSimulatorVisible$ = this.store.pipe( + select(TravelSimulatorSelectors.selectTravelSimulatorVisible) + ); this.subscriptions.push( this.store diff --git a/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.html b/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.html new file mode 100644 index 00000000..a0be133b --- /dev/null +++ b/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.html @@ -0,0 +1,30 @@ +
+ + Travel simulator + +
+ + + +
+
Speed:
+ + 0.5x + 1x + 3x + 5x + +
+
+
diff --git a/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.scss b/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.scss new file mode 100644 index 00000000..55bf1c5b --- /dev/null +++ b/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.scss @@ -0,0 +1,42 @@ +@import '../../../../styles/variables.scss'; + +:host { + position: absolute; + bottom: 10px; + left: 10px; + padding: 10px; +} + +.simulator-toggle { + font-size: 16px; + line-height: 24px; +} + +.simulator-contents { + margin-left: 20px; + gap: 20px; +} + +.divider { + height: 24px; +} + +.play-button { + box-shadow: none; + height: 24px; + width: 24px; + + .mat-button-wrapper { + position: relative; + top: -4px; + } +} + +.speed-container { + gap: 10px; + + mat-radio-group { + display: flex; + gap: 10px; + } +} diff --git a/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.spec.ts b/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.spec.ts new file mode 100644 index 00000000..96b9fd4a --- /dev/null +++ b/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.spec.ts @@ -0,0 +1,51 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TravelSimulatorComponent } from './travel-simulator.component'; +import { provideMockStore } from '@ngrx/store/testing'; +import ShipmentModelSelectors from '../../selectors/shipment-model.selectors'; +import TravelSimulatorSelectors from '../../selectors/travel-simulator.selectors'; + +describe('TravelSimulatorComponent', () => { + let component: TravelSimulatorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TravelSimulatorComponent], + providers: [ + provideMockStore({ + selectors: [ + { selector: ShipmentModelSelectors.selectGlobalStartTime, value: 0 }, + { selector: ShipmentModelSelectors.selectGlobalEndTime, value: 0 }, + { selector: TravelSimulatorSelectors.selectTime, value: 0 }, + { selector: TravelSimulatorSelectors.selectActive, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TravelSimulatorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.ts b/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.ts new file mode 100644 index 00000000..5c15370b --- /dev/null +++ b/application/frontend/src/app/core/containers/travel-simulator/travel-simulator.component.ts @@ -0,0 +1,109 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + HostBinding, + OnDestroy, + OnInit, +} from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import Long from 'long'; +import { Observable, Subscription, interval } from 'rxjs'; +import ShipmentModelSelectors from '../../selectors/shipment-model.selectors'; +import { map } from 'rxjs/operators'; +import { setActive, setTime } from '../../actions/travel-simulator.actions'; +import TravelSimulatorSelectors from '../../selectors/travel-simulator.selectors'; +import { MatSlideToggleChange } from '@angular/material/slide-toggle'; +import { formatSecondsDate } from 'src/app/util/time-translation'; + +@Component({ + selector: 'app-travel-simulator', + templateUrl: './travel-simulator.component.html', + styleUrls: ['./travel-simulator.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TravelSimulatorComponent implements OnInit, OnDestroy { + @HostBinding('class.app-map-panel') readonly mapPanel = true; + + readonly animationRateMs: number = 33; + readonly animationStepSize: number = 2; + + get animating(): boolean { + return this.animationTimer$?.closed === false; + } + + active$: Observable; + animationTimer$: Subscription; + animationSpeedMultiple = 1; + end$: Subscription; + end: number; + time$: Subscription; + time: number; + + formatSecondsDate = formatSecondsDate; + + constructor(private store: Store, private detectorRef: ChangeDetectorRef) {} + + ngOnInit(): void { + this.end$ = this.store + .select(ShipmentModelSelectors.selectGlobalEndTime) + .pipe(map((value) => Long.fromValue(value).toNumber())) + .subscribe((end) => { + this.end = end; + this.detectorRef.markForCheck(); + }); + + this.time$ = this.store.select(TravelSimulatorSelectors.selectTime).subscribe((time) => { + this.time = time; + this.detectorRef.markForCheck(); + }); + + this.active$ = this.store.pipe(select(TravelSimulatorSelectors.selectActive)); + } + + ngOnDestroy(): void { + this.onEndAnimate(); + this.time$.unsubscribe(); + this.end$.unsubscribe(); + } + + onBeginAnimate(): void { + this.animationTimer$ = interval(this.animationRateMs).subscribe((_value) => { + const newTime = this.time + this.animationStepSize * this.animationSpeedMultiple; + if (newTime >= this.end) { + this.onEndAnimate(); + return; + } + this.store.dispatch(setTime({ time: newTime })); + }); + } + + onEndAnimate(): void { + this.animationTimer$?.unsubscribe(); + this.detectorRef.markForCheck(); + } + + onToggleActive(event: MatSlideToggleChange): void { + this.store.dispatch(setActive({ active: event.checked })); + + if (!event.checked) { + this.onEndAnimate(); + } + } +} diff --git a/application/frontend/src/app/core/core.module.ts b/application/frontend/src/app/core/core.module.ts index 7dd7a160..0eadd85f 100644 --- a/application/frontend/src/app/core/core.module.ts +++ b/application/frontend/src/app/core/core.module.ts @@ -98,6 +98,7 @@ import { TransitionAttributesDialogComponent } from './components/transition-att import { PrecedenceRulesDialogComponent } from './components/precedence-rules-dialog/precedence-rules-dialog.component'; import { ShipmentModelSettingsComponent } from './containers/shipment-model-settings/shipment-model-settings.component'; import { PostSolveMapLegendComponent } from './components/post-solve-map-legend/post-solve-map-legend.component'; +import { TravelSimulatorComponent } from './containers/travel-simulator/travel-simulator.component'; export const COMPONENTS = [ BaseDocumentationDialogComponent, @@ -155,6 +156,7 @@ export const CONTAINERS = [ ShipmentsControlBarComponent, ShipmentsKpisComponent, StorageApiSaveLoadDialogComponent, + TravelSimulatorComponent, UploadDialogComponent, ValidationResultDialogComponent, VehicleInfoWindowComponent, diff --git a/application/frontend/src/app/core/reducers/travel-simulator.reducer.ts b/application/frontend/src/app/core/reducers/travel-simulator.reducer.ts new file mode 100644 index 00000000..c9a70d33 --- /dev/null +++ b/application/frontend/src/app/core/reducers/travel-simulator.reducer.ts @@ -0,0 +1,66 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { createReducer, on } from '@ngrx/store'; +import Long from 'long'; +import { ShipmentModelActions, TravelSimulatorActions } from '../actions'; + +export const travelSimulatorKey = 'travelSimulator'; + +export interface State { + active: boolean; + time: number; +} + +export const initialState: State = { + active: false, + time: 0, +}; + +export const reducer = createReducer( + initialState, + on(TravelSimulatorActions.setActive, (state, { active }) => ({ ...state, active })), + on(TravelSimulatorActions.setTime, (state, { time }) => ({ ...state, time })), + // Keep time selection within the global range + on(ShipmentModelActions.setGlobalStartTime, (state, { globalStartTime }) => ({ + ...state, + time: Math.max(Long.fromValue(globalStartTime).toNumber(), state.time), + })), + on(ShipmentModelActions.setGlobalEndTime, (state, { globalEndTime }) => ({ + ...state, + time: Math.min(Long.fromValue(globalEndTime).toNumber(), state.time), + })), + on(ShipmentModelActions.setShipmentModel, (state, newState) => { + let newTime = state.time; + + if (newState.globalStartTime) { + newTime = Long.fromValue(newState.globalStartTime).toNumber(); + } + + if (newState.globalEndTime) { + newTime = Math.min(Long.fromValue(newState.globalEndTime).toNumber(), newTime); + } + + return { + ...state, + time: newTime, + }; + }) +); + +export const selectTime = (state: State): number => state.time; + +export const selectActive = (state: State): boolean => state.active; diff --git a/application/frontend/src/app/core/selectors/map.selectors.ts b/application/frontend/src/app/core/selectors/map.selectors.ts index 99e5f686..b6b1f598 100644 --- a/application/frontend/src/app/core/selectors/map.selectors.ts +++ b/application/frontend/src/app/core/selectors/map.selectors.ts @@ -19,6 +19,8 @@ import buffer from '@turf/buffer'; import lineIntersect from '@turf/line-intersect'; import { bufferBounds, + computeHeadingAlongPath, + durationSeconds, findNearestCandidatePointToTargetAlongPath, findPathHeadingAtPointOptimized, fromDispatcherLatLng, @@ -28,7 +30,7 @@ import { toTurfLineString, toTurfPoint, } from 'src/app/util'; -import { ILatLng, Page, TravelMode, VisitRequest } from '../models'; +import { ILatLng, Page, TravelMode, ShipmentRoute, VisitRequest } from '../models'; import * as fromDepot from './depot.selectors'; import PreSolveShipmentSelectors from './pre-solve-shipment.selectors'; import PreSolveVehicleSelectors from './pre-solve-vehicle.selectors'; @@ -40,6 +42,9 @@ import VisitSelectors from './visit.selectors'; import VisitRequestSelectors from './visit-request.selectors'; import * as fromMap from '../reducers/map.reducer'; import { MapLayer, MapLayerId } from '../models/map'; +import TravelSimulatorSelectors from './travel-simulator.selectors'; +import * as fromShipmentRoute from './shipment-route.selectors'; +import Long from 'long'; type MapLatLng = google.maps.LatLng; @@ -150,41 +155,100 @@ export const selectBounds = createSelector( } ); -const getVehicleStartLocationsOnRoute = ( +const getVehicleStartLocationsOnRouteWithHeadings = ( paths: { [id: number]: MapLatLng[] }, boundsRadius: number -): { [id: number]: MapLatLng } => { +): { [id: number]: { location: MapLatLng; heading: number } } => { const occupiedStartLocations: MapLatLng[] = []; - const lookup: { [id: number]: MapLatLng } = {}; + const lookup: { [id: number]: { location: MapLatLng; heading: number } } = {}; Object.entries(paths).forEach(([key, vehiclePath]) => { const startLocation = vehiclePath?.length ? getVehicleStartingLocation(vehiclePath, boundsRadius / 4, occupiedStartLocations) : null; occupiedStartLocations.push(startLocation); - lookup[+key] = startLocation; + lookup[+key] = { + location: startLocation, + heading: vehiclePath ? findPathHeadingAtPointOptimized(vehiclePath, startLocation) : null, + }; }); return lookup; }; -export const selectVehicleStartLocationsOnRoute = createSelector( +const getSimulatedVehicleLocationsOnRouteWithHeadings = ( + routes: ShipmentRoute[], + paths: { [routeId: number]: google.maps.LatLng[] }, + simulationTime: number +): { [id: number]: { location: MapLatLng; heading: number } } => { + const lookup: { [id: number]: { location: MapLatLng; heading: number } } = {}; + routes.forEach((route) => { + if (!route.transitions) { + return; + } + + const path = paths[route.id]; + let interpolationDistance = 0; + let totalDistance = 0; + + if (durationSeconds(route.vehicleStartTime).greaterThan(simulationTime)) { + lookup[route.id] = { + location: path[0], + heading: computeHeadingAlongPath(path[0], path.length > 1 ? path[1] : path[0]), + }; + } else if (durationSeconds(route.vehicleEndTime).lessThan(simulationTime)) { + lookup[route.id] = { + location: path[path.length - 1], + heading: computeHeadingAlongPath( + path[path.length - 1], + path.length > 1 ? path[path.length - 2] : path[path.length - 1] + ), + }; + } else { + route.transitions.forEach((transition) => { + const start = durationSeconds(transition.startTime, Long.ZERO); + const duration = durationSeconds(transition.travelDuration, Long.ZERO); + const end = start.add(duration); + + if (start.lessThanOrEqual(simulationTime)) { + const transitionPercent = Math.min( + 1, + Math.max(0, (simulationTime - start.toNumber()) / end.subtract(start).toNumber()) + ); + interpolationDistance = + totalDistance + transition.travelDistanceMeters * transitionPercent; + } + totalDistance += transition.travelDistanceMeters; + }); + const point = getPointAlongPathByDistance(path, interpolationDistance); + const prevPoint = getPointAlongPathByDistance( + path, + interpolationDistance > 1 ? interpolationDistance - 1 : 0 + ); + lookup[route.id] = { location: point, heading: computeHeadingAlongPath(prevPoint, point) }; + } + }); + return lookup; +}; + +export const selectVehicleStartLocationsOnRouteWithHeadings = createSelector( ShipmentRouteSelectors.selectOverviewPolylinePaths, selectScenarioBoundsRadius, - (paths, boundsRadius) => getVehicleStartLocationsOnRoute(paths, boundsRadius) + (paths, boundsRadius) => getVehicleStartLocationsOnRouteWithHeadings(paths, boundsRadius) ); -export const selectVehicleHeadings = createSelector( - selectVehicleStartLocationsOnRoute, +export const selectSimulatedVehicleLocationsOnRouteWithHeadings = createSelector( + fromShipmentRoute.selectAll, ShipmentRouteSelectors.selectOverviewPolylinePaths, - (vehicleLocations, paths) => { - const vehicleHeadings: { [id: number]: number } = {}; - Object.entries(paths).forEach(([key, path]) => { - const id = +key; - vehicleHeadings[id] = path - ? findPathHeadingAtPointOptimized(path, vehicleLocations[id]) - : null; - }); - return vehicleHeadings; - } + TravelSimulatorSelectors.selectTime, + (routes, paths, simulationTime) => + getSimulatedVehicleLocationsOnRouteWithHeadings(routes, paths, simulationTime) +); + +export const selectVehicleLocationsOnRouteWithHeadings = createSelector( + TravelSimulatorSelectors.selectActive, + selectSimulatedVehicleLocationsOnRouteWithHeadings, + selectVehicleStartLocationsOnRouteWithHeadings, + (useSimulatedLocations, simulatedLocations, locations) => + useSimulatedLocations ? simulatedLocations : locations ); export const selectVehicleInitialHeadings = createSelector( @@ -219,13 +283,13 @@ export const selectPreSolveEditShipmentFormBounds = createSelector( export const selectInfoWindowVehicle = createSelector( fromVehicle.selectClickedVehicle, - selectVehicleStartLocationsOnRoute, + selectVehicleLocationsOnRouteWithHeadings, (vehicle, startLocations) => { return ( vehicle && { id: vehicle.id, - position: startLocations[vehicle.id] - ? startLocations[vehicle.id] + position: startLocations[vehicle.id].location + ? startLocations[vehicle.id].location : fromDispatcherLatLng(vehicle.startWaypoint?.location?.latLng), } ); diff --git a/application/frontend/src/app/core/selectors/post-solve-vehicle-layer.selectors.ts b/application/frontend/src/app/core/selectors/post-solve-vehicle-layer.selectors.ts index b11ce14b..4f2ad910 100644 --- a/application/frontend/src/app/core/selectors/post-solve-vehicle-layer.selectors.ts +++ b/application/frontend/src/app/core/selectors/post-solve-vehicle-layer.selectors.ts @@ -17,8 +17,7 @@ limitations under the License. import { createSelector } from '@ngrx/store'; import { selectPostSolveMapLayers, - selectVehicleHeadings, - selectVehicleStartLocationsOnRoute, + selectVehicleLocationsOnRouteWithHeadings, } from './map.selectors'; import { vehicleToDeckGL } from './pre-solve-vehicle-layer.selectors'; import RoutesChartSelectors from './routes-chart.selectors'; @@ -30,17 +29,15 @@ import * as fromUi from './ui.selectors'; export const selectFilteredVehicles = createSelector( fromVehicle.selectEntities, - selectVehicleStartLocationsOnRoute, - selectVehicleHeadings, fromUi.selectPage, + selectVehicleLocationsOnRouteWithHeadings, RoutesChartSelectors.selectFilteredRoutesWithTransitionsLookup, RoutesMetadataSelectors.selectFilteredRouteLookup, selectPostSolveMapLayers, ( vehicles, - startLocations, - headings, currentPage, + locations, chartFilteredRoutesLookup, metadataSelectedRoutesLookup, mapLayers @@ -58,25 +55,23 @@ export const selectFilteredVehicles = createSelector( ) : []; return filteredVehicles.map((vehicle) => - vehicleToDeckGL(vehicle, startLocations[vehicle.id], headings[vehicle.id]) + vehicleToDeckGL(vehicle, locations[vehicle.id].location, locations[vehicle.id].heading) ); } ); export const selectFilteredVehiclesSelected = createSelector( fromVehicle.selectEntities, - selectVehicleStartLocationsOnRoute, - selectVehicleHeadings, fromUi.selectPage, + selectVehicleLocationsOnRouteWithHeadings, RoutesChartSelectors.selectFilteredRoutesSelectedWithTransitionsLookup, RoutesMetadataSelectors.selectFilteredRoutesSelectedLookup, RoutesChartSelectors.selectSelectedRoutesColors, selectPostSolveMapLayers, ( vehicles, - startLocations, - headings, currentPage, + locations, chartSelectedRoutesLookup, metadataSelectedRoutesLookup, colors, @@ -92,7 +87,7 @@ export const selectFilteredVehiclesSelected = createSelector( : mapLayers[MapLayerId.PostSolveWalking].visible) ); return selectedVehicles.map((vehicle) => ({ - ...vehicleToDeckGL(vehicle, startLocations[vehicle.id], headings[vehicle.id]), + ...vehicleToDeckGL(vehicle, locations[vehicle.id].location, locations[vehicle.id].heading), color: colors[vehicle.id], })); } diff --git a/application/frontend/src/app/core/selectors/travel-simulator.selectors.ts b/application/frontend/src/app/core/selectors/travel-simulator.selectors.ts new file mode 100644 index 00000000..09c36c0c --- /dev/null +++ b/application/frontend/src/app/core/selectors/travel-simulator.selectors.ts @@ -0,0 +1,42 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import * as fromTravelSimulator from '../reducers/travel-simulator.reducer'; +import * as fromUI from './ui.selectors'; +import { Page } from '../models'; + +export const selectTravelSimulatorState = createFeatureSelector( + fromTravelSimulator.travelSimulatorKey +); + +const selectTravelSimulatorVisible = createSelector( + fromUI.selectPage, + (page) => + page === Page.RoutesChart || page === Page.RoutesMetadata || page === Page.ShipmentsMetadata +); + +const selectTime = createSelector(selectTravelSimulatorState, fromTravelSimulator.selectTime); + +const selectActive = createSelector(selectTravelSimulatorState, fromTravelSimulator.selectActive); + +export const TravelSimulatorSelectors = { + selectActive, + selectTravelSimulatorVisible, + selectTime, +}; + +export default TravelSimulatorSelectors; diff --git a/application/frontend/src/app/reducers/index.ts b/application/frontend/src/app/reducers/index.ts index 3de6fdf8..3e1ad305 100644 --- a/application/frontend/src/app/reducers/index.ts +++ b/application/frontend/src/app/reducers/index.ts @@ -42,6 +42,7 @@ import * as fromShipment from '../core/reducers/shipment.reducer'; import * as fromShipmentModel from '../core/reducers/shipment-model.reducer'; import * as fromShipmentRoute from '../core/reducers/shipment-route.reducer'; import * as fromShipmentsMetadata from '../core/reducers/shipments-metadata.reducer'; +import * as fromTravelSimulator from '../core/reducers/travel-simulator.reducer'; import * as fromUI from '../core/reducers/ui.reducer'; import * as fromUndoRedo from '../core/reducers/undo-redo.reducer'; import * as fromVehicle from '../core/reducers/vehicle.reducer'; @@ -71,6 +72,7 @@ export interface State { [fromShipmentModel.shipmentModelFeatureKey]: fromShipmentModel.State; [fromUndoRedo.undoRedoFeatureKey]: fromUndoRedo.State; [fromMap.mapFeatureKey]: fromMap.State; + [fromTravelSimulator.travelSimulatorKey]: fromTravelSimulator.State; router: fromRouter.RouterReducerState; } @@ -99,6 +101,7 @@ export const ROOT_REDUCERS = new InjectionToken> [fromVisit.visitsFeatureKey]: fromVisit.reducer, [fromVisitRequest.visitRequestsFeatureKey]: fromVisitRequest.reducer, [fromMap.mapFeatureKey]: fromMap.reducer, + [fromTravelSimulator.travelSimulatorKey]: fromTravelSimulator.reducer, router: fromRouter.routerReducer, }), } diff --git a/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.html b/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.html index 4aa03e77..73c3a052 100644 --- a/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.html +++ b/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.html @@ -5,7 +5,10 @@ [unitStep]="unitStep" [columnLabelFormatter]="columnLabelFormatter" [trackBy]="trackRouteBy" - [timezoneOffset]="timezoneOffset"> + [timezoneOffset]="timezoneOffset" + [travelSimulatorActive]="travelSimulatorActive" + [travelSimulatorValue]="travelSimulatorRelativeValue" + (dragSimulatorHandle)="onDragSimulatorHandle($event)"> diff --git a/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.scss b/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.scss index 918df4bd..6eac923c 100644 --- a/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.scss +++ b/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.scss @@ -21,6 +21,8 @@ app-base-routes-chart { .column-header-container, .marker-handle-container, .marker-line-container, + .simulator-handle-container, + .simulator-line-container, .rule-column-header-container, .rule-container { /** padding consistent with the timeline/POI offset */ @@ -92,10 +94,12 @@ app-base-routes-chart { border-radius: 50%; } + .simulator-line, .marker-line { width: 2px; } + .simulator-line, .marker-line, .marker-handle { background-color: $red-light; diff --git a/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.ts b/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.ts index fb8d32a4..c212df92 100644 --- a/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.ts +++ b/application/frontend/src/app/routes-chart/components/base-routes-chart/base-routes-chart.component.ts @@ -48,10 +48,14 @@ export class BaseRoutesChartComponent implements OnChanges, OnInit, OnDestroy { @Input() nextRangeOffset: number; @Input() previousRangeOffset: number; @Input() timezoneOffset: number; + @Input() travelSimulatorActive: boolean; + @Input() travelSimulatorValue: number; @Output() selectPreviousRangeOffset = new EventEmitter(); @Output() selectNextRangeOffset = new EventEmitter(); + @Output() setTravelSimulatorValue = new EventEmitter(); marker: number; + travelSimulatorRelativeValue: number; private readonly range$ = new BehaviorSubject(this.range); private readonly duration$ = new BehaviorSubject<[Long, Long]>(this.duration); @@ -66,6 +70,9 @@ export class BaseRoutesChartComponent implements OnChanges, OnInit, OnDestroy { if (changes.range) { this.range$.next(changes.range.currentValue); } + if (changes.travelSimulatorActive || changes.travelSimulatorValue) { + this.updateTravelSimulatorHandle(); + } } ngOnInit(): void { @@ -77,6 +84,7 @@ export class BaseRoutesChartComponent implements OnChanges, OnInit, OnDestroy { .pipe(auditTime(25)) .subscribe(() => { this.updateMarker(); + this.updateTravelSimulatorHandle(); this.changeDetector.markForCheck(); }) ); @@ -90,6 +98,10 @@ export class BaseRoutesChartComponent implements OnChanges, OnInit, OnDestroy { return route.id; } + onDragSimulatorHandle(value: number): void { + this.setTravelSimulatorValue.emit(this.duration[0].toNumber() + value); + } + private updateMarker(): void { if (!this.duration || !this.range) { this.marker = null; @@ -102,4 +114,18 @@ export class BaseRoutesChartComponent implements OnChanges, OnInit, OnDestroy { } this.marker = now - this.duration[0].toNumber(); } + + private updateTravelSimulatorHandle(): void { + if ( + !this.duration || + !this.range || + !this.travelSimulatorActive || + this.duration[0].greaterThan(this.travelSimulatorValue) || + this.duration[1].lessThan(this.travelSimulatorValue) + ) { + this.travelSimulatorRelativeValue = null; + return; + } + this.travelSimulatorRelativeValue = this.travelSimulatorValue - this.duration[0].toNumber(); + } } diff --git a/application/frontend/src/app/routes-chart/containers/routes-chart/routes-chart.component.html b/application/frontend/src/app/routes-chart/containers/routes-chart/routes-chart.component.html index 6b9c2439..7bbfc7ee 100644 --- a/application/frontend/src/app/routes-chart/containers/routes-chart/routes-chart.component.html +++ b/application/frontend/src/app/routes-chart/containers/routes-chart/routes-chart.component.html @@ -7,8 +7,11 @@ [previousRangeOffset]="previousRangeOffset$ | async" [nextRangeOffset]="nextRangeOffset$ | async" [timezoneOffset]="timezoneOffset$ | async" + [travelSimulatorActive]="travelSimulatorActive$ | async" + [travelSimulatorValue]="travelSimulatorValue$ | async" (selectPreviousRangeOffset)="onSelectPreviousRangeOffset($event)" (selectNextRangeOffset)="onSelectNextRangeOffset($event)" + (setTravelSimulatorValue)="onSetTravelSimulatorValue($event)" #routesChart> +
+
+
+
+
+
+
+
+
+
+
diff --git a/application/frontend/src/app/shared/components/gantt-chart/gantt-chart.component.scss b/application/frontend/src/app/shared/components/gantt-chart/gantt-chart.component.scss index 7042f3b6..3a4643b1 100644 --- a/application/frontend/src/app/shared/components/gantt-chart/gantt-chart.component.scss +++ b/application/frontend/src/app/shared/components/gantt-chart/gantt-chart.component.scss @@ -1,3 +1,5 @@ +@import '../../../../styles/variables.scss'; + app-gantt-chart { display: block; height: 100%; @@ -38,6 +40,7 @@ app-gantt-chart { } .marker-line-container, + .simulator-line-container, .rule-container { grid-column: 2; grid-row: 2 / -1; @@ -49,6 +52,13 @@ app-gantt-chart { z-index: 1000; } + .simulator-line-container { + position: relative; + pointer-events: none; + z-index: 1000; + } + + .simulator-line, .marker-line { position: absolute; top: 0; @@ -105,6 +115,7 @@ app-gantt-chart { grid-column: 1; } + .simulator-handle-container, .column-header-container, .marker-handle-container { position: -webkit-sticky; @@ -119,6 +130,35 @@ app-gantt-chart { z-index: 1001; } + .simulator-handle-container { + z-index: 1002; + pointer-events: none; + } + + .simulator-handle-line { + height: 100%; + width: 2px; + } + + .simulator-handle, + .simulator-handle-line { + position: absolute; + background-color: $red-light; + } + + .simulator-handle { + border-radius: 40%; + top: -12px; + height: 24px; + width: 22px; + pointer-events: all; + cursor: grab; + + &.simulator-handle-drag { + cursor: grabbing; + } + } + .marker-handle-container { pointer-events: none; z-index: 1002; diff --git a/application/frontend/src/app/shared/components/gantt-chart/gantt-chart.component.ts b/application/frontend/src/app/shared/components/gantt-chart/gantt-chart.component.ts index 8fb6dfb3..01dd70bd 100644 --- a/application/frontend/src/app/shared/components/gantt-chart/gantt-chart.component.ts +++ b/application/frontend/src/app/shared/components/gantt-chart/gantt-chart.component.ts @@ -23,10 +23,15 @@ import { Component, ContentChild, ElementRef, + EventEmitter, + HostListener, + Inject, Input, NgZone, OnChanges, OnDestroy, + OnInit, + Output, SimpleChanges, TemplateRef, ViewChild, @@ -41,6 +46,7 @@ import { GanttRowDirective, } from '../../directives'; import { ChartColumnLabelFormatter, day, UnitStep } from '../../models'; +import { DOCUMENT } from '@angular/common'; interface GanttColumn { label: string; @@ -54,13 +60,23 @@ interface GanttColumn { encapsulation: ViewEncapsulation.None, }) export class GanttChartComponent - implements OnChanges, AfterViewInit, AfterViewChecked, OnDestroy + implements OnInit, OnChanges, AfterViewInit, AfterViewChecked, OnDestroy { + @ViewChild('headerOverlay') headerOverlay: ElementRef; + @ViewChild('rowColumnHeader') rowColumnHeader: ElementRef; + @ViewChild(CdkVirtualScrollViewport, { static: true }) viewPort: CdkVirtualScrollViewport; + @ViewChild('simulatorHandleContainer') simulatorHandleContainer: ElementRef; + @Input() value: T[]; @Input() range: number = day.ranges[day.defaultRangeIndex].value; @Input() marker?: number; @Input() unitStep: UnitStep = day.ranges[day.defaultRangeIndex].unitStep; @Input() timezoneOffset: number; + @Input() travelSimulatorActive: boolean; + @Input() travelSimulatorValue: number; + @Input() columnLabelFormatter: ChartColumnLabelFormatter = day.columnLabelFormatter; + @Input() trackBy: (index: number, item: T) => any = (index) => index; + @Output() dragSimulatorHandle = new EventEmitter(); @ContentChild(GanttColumnHeaderDirective, { static: true }) columnHeaderDir: GanttColumnHeaderDirective; @@ -91,20 +107,23 @@ export class GanttChartComponent hasMarker = false; markerPercent = 0; + + hasTravelSimulator = false; + travelSimulatorPercent = 0; + isDraggingSimulator = false; + dragPosition: [number, number]; + columns: GanttColumn[] = []; - @ViewChild(CdkVirtualScrollViewport, { static: true }) viewPort: CdkVirtualScrollViewport; viewPortWidth = 0; - @ViewChild('headerOverlay') headerOverlay: ElementRef; - @ViewChild('rowColumnHeader') rowColumnHeader: ElementRef; get inverseTranslation(): string { return this.viewPort ? -this.viewPort.getOffsetToRenderedContentStart() + 'px' : '0'; } - @Input() columnLabelFormatter: ChartColumnLabelFormatter = day.columnLabelFormatter; - @Input() trackBy: (index: number, item: T) => any = (index) => index; + private onMouseMoveFn = this.onMouseMove.bind(this) as (event: MouseEvent) => void; constructor( + @Inject(DOCUMENT) private document: Document, private domSanitizer: DomSanitizer, private zone: NgZone, private changeDetector: ChangeDetectorRef, @@ -116,11 +135,33 @@ export class GanttChartComponent return this.domSanitizer.bypassSecurityTrustStyle(left); } + getSimulatorHandleLeft(el: HTMLElement): SafeStyle { + const left = 'calc(' + this.travelSimulatorPercent + '% - ' + el.clientWidth / 2 + 'px)'; + return this.domSanitizer.bypassSecurityTrustStyle(left); + } + + ngOnInit(): void { + this.zone.runOutsideAngular(() => { + this.document.addEventListener('mousemove', this.onMouseMoveFn); + }); + } + ngOnChanges(changes: SimpleChanges): void { if (changes.marker || changes.range) { this.hasMarker = this.marker != null && this.marker >= 0 && this.marker <= this.range; this.markerPercent = this.hasMarker ? (100 * this.marker) / this.range : 0; } + if (changes.travelSimulatorActive || changes.travelSimulatorValue || changes.range) { + this.hasTravelSimulator = + this.travelSimulatorActive && + this.travelSimulatorValue != null && + this.travelSimulatorValue >= 0 && + this.travelSimulatorValue <= this.range; + this.travelSimulatorPercent = this.hasTravelSimulator + ? (100 * this.travelSimulatorValue) / this.range + : 0; + } + if ((changes.range || changes.unitStep || changes.columnLabelFormatter) && this.initialized) { this.columns = this.getColumns(); } @@ -146,6 +187,39 @@ export class GanttChartComponent ngOnDestroy(): void { this.resizeObserver?.disconnect(); + this.document.removeEventListener('mousemove', this.onMouseMoveFn); + } + + onSimulatorMouseDown(event: MouseEvent): void { + if (event.button !== 0) { + return; + } + this.isDraggingSimulator = true; + this.dragPosition = [event.x, event.y]; + event.preventDefault(); + event.stopPropagation(); + } + + @HostListener('document:mouseup', ['$event']) + onMouseUp(_event: MouseEvent): void { + this.isDraggingSimulator = false; + } + + onMouseMove(event: MouseEvent): void { + if (!this.isDraggingSimulator) { + return; + } + + const bounds: DOMRect = this.simulatorHandleContainer.nativeElement.getBoundingClientRect(); + const relativeX = event.x - bounds.left; + + if (relativeX < 0 || relativeX > bounds.width) { + return; + } + + this.zone.run(() => { + this.dragSimulatorHandle.emit((relativeX / bounds.width) * this.range); + }); } private calculateDimensions(): void { diff --git a/application/frontend/src/assets/images/pause.svg b/application/frontend/src/assets/images/pause.svg new file mode 100644 index 00000000..d477130b --- /dev/null +++ b/application/frontend/src/assets/images/pause.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/application/frontend/src/assets/images/play.svg b/application/frontend/src/assets/images/play.svg new file mode 100644 index 00000000..5a9aa6ad --- /dev/null +++ b/application/frontend/src/assets/images/play.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/application/frontend/src/styles/main.scss b/application/frontend/src/styles/main.scss index 75a7f012..2ad1d5d9 100644 --- a/application/frontend/src/styles/main.scss +++ b/application/frontend/src/styles/main.scss @@ -985,3 +985,12 @@ app-post-solve-map-legend .legend-panel mat-checkbox label .mat-checkbox-label { font-size: 16px; line-height: 24px; } + +.play-button .mat-button-wrapper { + position: relative; + top: -8px; + + mat-icon svg { + height: 22px; + } +}