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;
+ }
+}