diff --git a/.eslint/typescript.eslintrc.cjs b/.eslint/typescript.eslintrc.cjs index d67200fc6..ef9c125a8 100644 --- a/.eslint/typescript.eslintrc.cjs +++ b/.eslint/typescript.eslintrc.cjs @@ -337,9 +337,7 @@ module.exports = { '@typescript-eslint/no-require-imports': 'warn', '@typescript-eslint/no-type-alias': 'off', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'warn', - // Disabled because of incorrect typings from libraries and - // checks after type assertions without `undefined` in the type - '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unnecessary-condition': 'warn', '@typescript-eslint/no-unnecessary-qualifier': 'warn', '@typescript-eslint/no-unnecessary-type-arguments': 'warn', '@typescript-eslint/no-unnecessary-type-constraint': 'warn', @@ -399,6 +397,6 @@ module.exports = { ], '@typescript-eslint/no-misused-new': 'warn', '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/no-unnecessary-type-assertion': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'warn', }, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index cbee04139..23830d589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project does **not** adhere to [Semantic Versioning](https://semver.org ## [Unreleased] +## [0.7.0] - 2023-05-30 + +### Added + +- Statistics can now also be restricted to simulated regions. +- Add functionality to create logs for statistics. +- Add a button that allows trainers to move the map to any coordinates of their choice. +- Log entries are now displayed on the statistics page and can be filtered. +- Log entries are being generated for the following actions: + - Publishing, accepting and marking radiograms as done. + - Accepting or denying resource request radiograms. + - Vehicle and Patient addition and deletion + - AlarmGroup sent + - Addition of elements to transfer + - Edit or pause of transfer + - All configuration of the simulation + - Addition of elements to simulated regions + - Connection and disconnection of transfer points and hospitals +- Log entries are being generated for the following occurrences: + - Completion of transfer + - Treatment status changes. + - Completion of the transfer of patients of one category + - Either in one region or in all regions managed by one behavior + - Vehicle transfer, loading, unloading and occupation changes. + - Visible status changes of patients. + - Treating personnel changes for patients. +- By clicking on a log entry or a chart, a marker will be shown in the chart at that time. The log entry list scrolls to that time. + +### Fixed + +- Errors in reduction of the tick actions no longer crash the backend. + ## [0.6.0] - 2023-05-17 ### Added @@ -230,7 +262,8 @@ and this project does **not** adhere to [Semantic Versioning](https://semver.org ### Initial unstable release of Digitale FüSim MANV -[Unreleased]: https://github.com/hpi-sam/digital-fuesim-manv/compare/v0.6.0...HEAD +[Unreleased]: https://github.com/hpi-sam/digital-fuesim-manv/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/hpi-sam/digital-fuesim-manv/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/hpi-sam/digital-fuesim-manv/compare/v0.5.1...v0.6.0 [0.5.1]: https://github.com/hpi-sam/digital-fuesim-manv/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/hpi-sam/digital-fuesim-manv/compare/v0.4.0...v0.5.0 diff --git a/backend/package-lock.json b/backend/package-lock.json index 057b2ef0e..9a3de0a7d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "digital-fuesim-manv-backend", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digital-fuesim-manv-backend", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -48,7 +48,7 @@ }, "../shared": { "name": "digital-fuesim-manv-shared", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@noble/hashes": "^1.2.0", "class-transformer": "^0.5.1", @@ -6738,9 +6738,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz", - "integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz", + "integrity": "sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ==", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" diff --git a/backend/package.json b/backend/package.json index 10367f7b2..a515f5f69 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "digital-fuesim-manv-backend", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "scripts": { "start:once:linux-macos": "NODE_ENV=production node --experimental-specifier-resolution=node dist/src/index.js", diff --git a/backend/src/exercise/action-wrapper.ts b/backend/src/exercise/action-wrapper.ts index e5bf31d83..d22705d8e 100644 --- a/backend/src/exercise/action-wrapper.ts +++ b/backend/src/exercise/action-wrapper.ts @@ -90,7 +90,7 @@ export class ActionWrapper extends NormalType< ); } - public readonly index!: number; + public readonly index: number; /** * @param emitterId `null` iff the emitter was the server, the client id otherwise diff --git a/backend/src/exercise/exercise-wrapper.ts b/backend/src/exercise/exercise-wrapper.ts index 73a50ea2d..5bb2b1c6c 100644 --- a/backend/src/exercise/exercise-wrapper.ts +++ b/backend/src/exercise/exercise-wrapper.ts @@ -207,24 +207,41 @@ export class ExerciseWrapper extends NormalType< * All periodic actions of the exercise (e.g. status changes for patients) should happen here. */ private readonly tick = async () => { - const patientUpdates = patientTick( - this.getStateSnapshot(), - this.tickInterval - ); - const updateAction: ExerciseAction = { - type: '[Exercise] Tick', - patientUpdates, - /** - * Refresh every {@link refreshTreatmentInterval} * {@link tickInterval} ms seconds - */ - // TODO: Refactor this: do this in the reducer instead of sending it in the action - refreshTreatments: - this.tickCounter % this.refreshTreatmentInterval === 0, - tickInterval: this.tickInterval, - }; - this.applyAction(updateAction, this.emitterId); - this.tickCounter++; - this.markAsModified(); + try { + const patientUpdates = patientTick( + this.getStateSnapshot(), + this.tickInterval + ); + const updateAction: ExerciseAction = { + type: '[Exercise] Tick', + patientUpdates, + /** + * Refresh every {@link refreshTreatmentInterval} * {@link tickInterval} ms seconds + */ + // TODO: Refactor this: do this in the reducer instead of sending it in the action + refreshTreatments: + this.tickCounter % this.refreshTreatmentInterval === 0, + tickInterval: this.tickInterval, + }; + this.applyAction(updateAction, this.emitterId); + this.tickCounter++; + this.markAsModified(); + } catch (e: unknown) { + // Something went wrong in tick, probably some corrupted simulation state. + console.error(e); + try { + this.applyAction( + { + type: '[Exercise] Pause', + }, + this.emitterId + ); + this.markAsModified(); + } catch { + // Alright, this is enough. Something is fundamentally broken. + this.pause(); + } + } }; // Call the tick every 1000 ms diff --git a/benchmark/package-lock.json b/benchmark/package-lock.json index 594ac2d04..6bbfe73df 100644 --- a/benchmark/package-lock.json +++ b/benchmark/package-lock.json @@ -1,12 +1,12 @@ { "name": "digital-fuesim-manv-benchmark", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digital-fuesim-manv-benchmark", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "digital-fuesim-manv-shared": "file:../shared", "immer": "^9.0.17", @@ -32,7 +32,7 @@ }, "../shared": { "name": "digital-fuesim-manv-shared", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@noble/hashes": "^1.2.0", "class-transformer": "^0.5.1", diff --git a/benchmark/package.json b/benchmark/package.json index cf4f1b8fc..c5d5fe2bf 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -1,6 +1,6 @@ { "name": "digital-fuesim-manv-benchmark", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "scripts": { "lint": "eslint --max-warnings 0 --ignore-path .gitignore \"./**/*.{ts,js,yml,html}\"", diff --git a/docs/swagger.yml b/docs/swagger.yml index 256c4ce57..565003dd0 100644 --- a/docs/swagger.yml +++ b/docs/swagger.yml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Digital Fuesim MANV HTTP API description: HTTP API of the digital-fuesim-manv project - version: 0.6.0 + version: 0.7.0 paths: /api/health: get: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 928734ed5..e4a494c77 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "digital-fuesim-manv-frontend", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digital-fuesim-manv-frontend", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@angular/animations": "~15.1.0", "@angular/common": "~15.1.0", @@ -66,7 +66,7 @@ }, "../shared": { "name": "digital-fuesim-manv-shared", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@noble/hashes": "^1.2.0", "class-transformer": "^0.5.1", @@ -15933,8 +15933,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.1", - "license": "MIT", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz", + "integrity": "sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ==", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" diff --git a/frontend/package.json b/frontend/package.json index ce69e9a10..f34153456 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "digital-fuesim-manv-frontend", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "scripts": { "cy:open": "cypress open", diff --git a/frontend/src/app/core/time-travel.service.ts b/frontend/src/app/core/time-travel.service.ts index 82635cdd1..56ae4894c 100644 --- a/frontend/src/app/core/time-travel.service.ts +++ b/frontend/src/app/core/time-travel.service.ts @@ -60,6 +60,8 @@ export class TimeTravelService { }); // Freeze to prevent accidental modification freeze(exerciseTimeLine, true); + // False positive, could be changed by the `catch` + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!this.activatingTimeTravel) { // The timeTravel has been stopped during the retrieval of the timeline return; diff --git a/frontend/src/app/feature/messages/custom-timer-progress-bar/custom-timer-progress-bar.component.ts b/frontend/src/app/feature/messages/custom-timer-progress-bar/custom-timer-progress-bar.component.ts index 448821e5b..d343fe591 100644 --- a/frontend/src/app/feature/messages/custom-timer-progress-bar/custom-timer-progress-bar.component.ts +++ b/frontend/src/app/feature/messages/custom-timer-progress-bar/custom-timer-progress-bar.component.ts @@ -116,6 +116,7 @@ export class CustomTimerProgressBarComponent implements OnChanges, OnDestroy { } ngOnChanges(changes: SimpleChangesGeneric) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (changes.timer) { this.animationParams$.next(undefined); this.timer$.next(this.timer); diff --git a/frontend/src/app/pages/exercises/exercise/exercise.module.ts b/frontend/src/app/pages/exercises/exercise/exercise.module.ts index f7fb22d45..3294c2574 100644 --- a/frontend/src/app/pages/exercises/exercise/exercise.module.ts +++ b/frontend/src/app/pages/exercises/exercise/exercise.module.ts @@ -20,6 +20,7 @@ import { TimeTravelComponent } from './shared/time-travel/time-travel.component' import { TrainerMapEditorComponent } from './shared/trainer-map-editor/trainer-map-editor.component'; import { TrainerToolbarComponent } from './shared/trainer-toolbar/trainer-toolbar.component'; import { TransferOverviewModule } from './shared/transfer-overview/transfer-overview.module'; +import { CoordinatePickerModule } from './shared/coordinate-picker/coordinate-picker.module'; @NgModule({ declarations: [ @@ -46,6 +47,7 @@ import { TransferOverviewModule } from './shared/transfer-overview/transfer-over AlarmGroupOverviewModule, HospitalEditorModule, EmergencyOperationsCenterModule, + CoordinatePickerModule, ], exports: [ExerciseComponent], }) diff --git a/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.ts b/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.ts index bfdbc7e2d..0c5cd32bf 100644 --- a/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.ts +++ b/frontend/src/app/pages/exercises/exercise/exercise/exercise.component.ts @@ -55,6 +55,8 @@ export class ExerciseComponent implements OnDestroy { this.store ); const url = `${location.origin}/exercises/${id}`; + // Could be unavailable in insecure contexts + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (navigator.share) { navigator.share({ url }).catch((error) => { if (error.name === 'AbortError') { diff --git a/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker-modal/coordinate-picker-modal.component.html b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker-modal/coordinate-picker-modal.component.html new file mode 100644 index 000000000..b22daa8e4 --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker-modal/coordinate-picker-modal.component.html @@ -0,0 +1,44 @@ + + diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-name/simulated-region-name.component.scss b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker-modal/coordinate-picker-modal.component.scss similarity index 100% rename from frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-name/simulated-region-name.component.scss rename to frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker-modal/coordinate-picker-modal.component.scss diff --git a/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker-modal/coordinate-picker-modal.component.ts b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker-modal/coordinate-picker-modal.component.ts new file mode 100644 index 000000000..74482a31b --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker-modal/coordinate-picker-modal.component.ts @@ -0,0 +1,42 @@ +import type { OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { toLonLat } from 'ol/proj'; +import { OlMapManager } from '../../exercise-map/utility/ol-map-manager'; + +@Component({ + selector: 'app-coordinate-picker-modal', + templateUrl: './coordinate-picker-modal.component.html', + styleUrls: ['./coordinate-picker-modal.component.scss'], +}) +export class CoordinatePickerModalComponent implements OnInit { + @Input() + public olMapManager!: OlMapManager; + + public latitude = ''; + public longitude = ''; + + constructor(public activeModal: NgbActiveModal) {} + + ngOnInit() { + const center = this.olMapManager.getCoordinates(); + + if (!center) return; + + const latLonCoordinates = toLonLat(center) + .reverse() + .map((coordinate) => coordinate.toFixed(6)); + + this.latitude = latLonCoordinates[0]!; + this.longitude = latLonCoordinates[1]!; + } + + public goToCoordinates() { + this.olMapManager.tryGoToCoordinates(+this.latitude, +this.longitude); + this.activeModal.close(); + } + + public close() { + this.activeModal.close(); + } +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker.module.ts b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker.module.ts new file mode 100644 index 000000000..4a984e333 --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/coordinate-picker.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { SharedModule } from 'src/app/shared/shared.module'; +import { CoordinatePickerModalComponent } from './coordinate-picker-modal/coordinate-picker-modal.component'; + +@NgModule({ + declarations: [CoordinatePickerModalComponent], + imports: [CommonModule, FormsModule, SharedModule], +}) +export class CoordinatePickerModule {} diff --git a/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/open-coordinate-picker-modal.ts b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/open-coordinate-picker-modal.ts new file mode 100644 index 000000000..8554a1b33 --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/coordinate-picker/open-coordinate-picker-modal.ts @@ -0,0 +1,16 @@ +import type { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import type { OlMapManager } from '../exercise-map/utility/ol-map-manager'; +import { CoordinatePickerModalComponent } from './coordinate-picker-modal/coordinate-picker-modal.component'; + +export function openCoordinatePickerModal( + ngbModalService: NgbModal, + olMapManager: OlMapManager +) { + const modalRef = ngbModalService.open(CoordinatePickerModalComponent, { + size: 'sm', + }); + + ( + modalRef.componentInstance as CoordinatePickerModalComponent + ).olMapManager = olMapManager; +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/core/drag-element.service.ts b/frontend/src/app/pages/exercises/exercise/shared/core/drag-element.service.ts index ab5b2a373..166c7cba7 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/core/drag-element.service.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/core/drag-element.service.ts @@ -333,16 +333,15 @@ export class DragElementService { } this.olMap.forEachFeatureAtPixel(pixel, (droppedOnFeature, layer) => { // Skip layer when unset + // OpenLayers type definitions are incorrect, layer may be `null` + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (layer === null || !this.layerFeatureManagerDictionary) { return; } // We stop propagating the event as soon as the onFeatureDropped function returns true return this.layerFeatureManagerDictionary .get(layer as VectorLayer)! - .onFeatureDrop( - createdElement as Element, - droppedOnFeature as Feature - ); + .onFeatureDrop(createdElement, droppedOnFeature as Feature); }); } diff --git a/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics-entry.ts b/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics-entry.ts index fcedf6287..35661d533 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics-entry.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics-entry.ts @@ -13,5 +13,9 @@ export interface StatisticsEntry { readonly [viewportId: string]: AreaStatistics; }; + readonly simulatedRegions: { + readonly [simulatedRegionId: string]: AreaStatistics; + }; + readonly exercise: AreaStatistics; } diff --git a/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics.service.ts b/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics.service.ts index dc54695d2..da3100e63 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics.service.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics.service.ts @@ -8,6 +8,8 @@ import { uuid, Viewport, isNotInTransfer, + isInSpecificSimulatedRegion, + cloneDeepMutable, } from 'digital-fuesim-manv-shared'; import type { Personnel, @@ -15,6 +17,9 @@ import type { ExerciseState, Patient, Vehicle, + WithPosition, + UUID, + LogEntry, } from 'digital-fuesim-manv-shared'; import { countBy } from 'lodash-es'; import { ReplaySubject } from 'rxjs'; @@ -40,6 +45,8 @@ export class StatisticsService { 1 ); + public readonly logEntries$ = new ReplaySubject(1); + // TODO: Already calculated statistics could be cached // TODO: Maybe calculate this in a webworker to not block the main thread // a short test showed that the calculating in the webworker (excluding communication, structuredClone etc.) @@ -52,8 +59,11 @@ export class StatisticsService { selectCurrentTime, this.store ); - const { initialState, actionsWrappers } = - await this.apiService.exerciseHistory(); + const { initialState, actionsWrappers } = cloneDeepMutable( + await this.apiService.exerciseHistory() + ); + + initialState.logEntries = []; const minimumExerciseTime = initialState.currentTime; @@ -89,6 +99,7 @@ export class StatisticsService { } ); this.statistics$.next(statistics); + this.logEntries$.next(initialState.logEntries); this.updatingStatistics = false; return statistics; } @@ -103,48 +114,57 @@ export class StatisticsService { Object.values(draftState.personnel) ); - const viewportStatistics = Object.fromEntries( - Object.entries(draftState.viewports).map(([id, viewport]) => [ - id, - this.generateAreaStatistics( - Object.values(draftState.clients).filter( - (client) => client.viewRestrictedToViewportId === id - ), - Object.values(draftState.patients).filter( - (patient) => - isOnMap(patient) && - Viewport.isInViewport( - viewport, - currentCoordinatesOf(patient) - ) - ), - Object.values(draftState.vehicles).filter( - (vehicle) => - isOnMap(vehicle) && - Viewport.isInViewport( - viewport, - currentCoordinatesOf(vehicle) - ) - ), - Object.values(draftState.personnel).filter( - (personnel) => - isOnMap(personnel) && - Viewport.isInViewport( - viewport, - currentCoordinatesOf(personnel) - ) - ) - ), - ]) + const viewportStatistics = this.generateFilteredAreaStatistics( + draftState, + draftState.viewports, + (viewport, element) => + isOnMap(element) && + Viewport.isInViewport(viewport, currentCoordinatesOf(element)), + true + ); + const simulatedRegionsStatistics = this.generateFilteredAreaStatistics( + draftState, + draftState.simulatedRegions, + (simulatedRegion, element) => + isInSpecificSimulatedRegion(element, simulatedRegion.id) ); return { id: uuid(), exercise: exerciseStatistics, viewports: viewportStatistics, + simulatedRegions: simulatedRegionsStatistics, exerciseTime: draftState.currentTime, }; } + private generateFilteredAreaStatistics( + draftState: ExerciseState, + areas: { readonly [key: UUID]: T }, + isInArea: (area: T, element: WithPosition) => boolean, + clients = false + ) { + return Object.fromEntries( + Object.entries(areas).map(([id, area]) => { + const isInThisArea = (element: WithPosition) => + isInArea(area, element); + return [ + id, + this.generateAreaStatistics( + clients + ? Object.values(draftState.clients).filter( + (client) => + client.viewRestrictedToViewportId === id + ) + : [], + Object.values(draftState.patients).filter(isInThisArea), + Object.values(draftState.vehicles).filter(isInThisArea), + Object.values(draftState.personnel).filter(isInThisArea) + ), + ]; + }) + ); + } + private generateAreaStatistics( clients: Client[], patients: Patient[], diff --git a/frontend/src/app/pages/exercises/exercise/shared/editor-panel/image-template-form/image-template-form.component.ts b/frontend/src/app/pages/exercises/exercise/shared/editor-panel/image-template-form/image-template-form.component.ts index 4f8206f91..4c99835b4 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/editor-panel/image-template-form/image-template-form.component.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/editor-panel/image-template-form/image-template-form.component.ts @@ -25,6 +25,7 @@ export class ImageTemplateFormComponent implements OnChanges { constructor(private readonly messageService: MessageService) {} ngOnChanges(changes: SimpleChangesGeneric): void { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!changes.initialValues) { return; } diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-map/exercise-map.component.html b/frontend/src/app/pages/exercises/exercise/shared/exercise-map/exercise-map.component.html index 6bcb81e66..8754d61bb 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-map/exercise-map.component.html +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-map/exercise-map.component.html @@ -36,6 +36,17 @@ > + - - -
-
-

Patienten

- -
-

Fahrzeuge

- -
-

- Einsatzkräfte - (weder im Transfer noch im Fahrzeug) -

- - - - - Krankenhäuser - -

Krankenhäuser

- -
-
- -
+
+
+

Patienten

+ +
+

Fahrzeuge

+ +
+

+ Einsatzkräfte + (weder im Transfer noch im Fahrzeug) +

+ + + + + Krankenhäuser + +

Krankenhäuser

+ +
+
+ +
+ +
+
+ +
+ diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics-modal/exercise-statistics-modal.component.scss b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics-modal/exercise-statistics-modal.component.scss index e69de29bb..23ab858df 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics-modal/exercise-statistics-modal.component.scss +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics-modal/exercise-statistics-modal.component.scss @@ -0,0 +1,9 @@ +.statistics-nav-outlet > div[role='tabpanel'] { + max-height: 100%; + min-height: 0; +} + +.modal-xxl { + max-width: 90%; + width: 90%; +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics-modal/exercise-statistics-modal.component.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics-modal/exercise-statistics-modal.component.ts index 6b2268095..3b6d4c99f 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics-modal/exercise-statistics-modal.component.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics-modal/exercise-statistics-modal.component.ts @@ -1,7 +1,13 @@ -import { Component } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { Component, ViewEncapsulation } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { Store } from '@ngrx/store'; -import type { PatientStatus, PersonnelType } from 'digital-fuesim-manv-shared'; +import type { + PatientStatus, + PersonnelType, + UUID, + LogEntry, +} from 'digital-fuesim-manv-shared'; import { statusNames } from 'digital-fuesim-manv-shared'; import type { Observable } from 'rxjs'; import { map } from 'rxjs'; @@ -10,9 +16,11 @@ import { getRgbaColor, rgbColorPalette, } from 'src/app/shared/functions/colors'; -import { formatDuration } from 'src/app/shared/functions/format-duration'; import type { AppState } from 'src/app/state/app.state'; -import { selectViewports } from 'src/app/state/application/selectors/exercise.selectors'; +import { + selectSimulatedRegions, + selectViewports, +} from 'src/app/state/application/selectors/exercise.selectors'; import { StatisticsService } from '../../core/statistics/statistics.service'; import { AreaStatisticsService } from '../area-statistics.service'; import type { StackedBarChartStatistics } from '../stacked-bar-chart/stacked-bar-chart.component'; @@ -22,9 +30,11 @@ import { StackedBarChart } from '../stacked-bar-chart/time-line-area-chart'; selector: 'app-exercise-statistics-modal', templateUrl: './exercise-statistics-modal.component.html', styleUrls: ['./exercise-statistics-modal.component.scss'], + encapsulation: ViewEncapsulation.None, }) -export class ExerciseStatisticsModalComponent { - public viewports$ = this.store.select(selectViewports); +export class ExerciseStatisticsModalComponent implements OnInit { + public viewportIds$!: Observable; + public simulatedRegionIds$!: Observable; constructor( public activeModal: NgbActiveModal, @@ -34,6 +44,14 @@ export class ExerciseStatisticsModalComponent { ) { this.statisticsService.updateStatistics(); } + ngOnInit(): void { + this.viewportIds$ = this.store + .select(selectViewports) + .pipe(map(Object.keys)); + this.simulatedRegionIds$ = this.store + .select(selectSimulatedRegions) + .pipe(map(Object.keys)); + } public close() { this.activeModal.close(); @@ -68,9 +86,7 @@ export class ExerciseStatisticsModalComponent { backgroundColor, }) ), - labels: statistics.map(({ exerciseTime }) => - formatDuration(exerciseTime) - ), + labels: statistics.map(({ exerciseTime }) => exerciseTime), })) ); @@ -112,9 +128,7 @@ export class ExerciseStatisticsModalComponent { ), backgroundColor: this.getColor(index), })), - labels: statistics.map(({ exerciseTime }) => - formatDuration(exerciseTime) - ), + labels: statistics.map(({ exerciseTime }) => exerciseTime), }; }) ); @@ -162,9 +176,10 @@ export class ExerciseStatisticsModalComponent { backgroundColor: color, }) ), - labels: statistics.map(({ exerciseTime }) => - formatDuration(exerciseTime) - ), + labels: statistics.map(({ exerciseTime }) => exerciseTime), })) ); + + public logEntries$: Observable = + this.statisticsService.logEntries$; } diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics.module.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics.module.ts index 6c3b0e2c0..1f7d1a042 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics.module.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/exercise-statistics.module.ts @@ -1,17 +1,27 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { MatSortModule } from '@angular/material/sort'; -import { NgbDropdownModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { + NgbDropdownModule, + NgbNavModule, + NgbPopoverModule, +} from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from 'src/app/shared/shared.module'; import { ExerciseStatisticsModalComponent } from './exercise-statistics-modal/exercise-statistics-modal.component'; import { HospitalPatientsTableComponent } from './hospital-patients-table/hospital-patients-table.component'; import { StackedBarChartComponent } from './stacked-bar-chart/stacked-bar-chart.component'; +import { LogEntryComponent } from './log-entry/log-entry.component'; +import { TagComponent } from './tag/tag.component'; +import { LogTableComponent } from './log-table/log-table.component'; @NgModule({ declarations: [ ExerciseStatisticsModalComponent, StackedBarChartComponent, HospitalPatientsTableComponent, + LogEntryComponent, + TagComponent, + LogTableComponent, ], imports: [ CommonModule, @@ -19,6 +29,7 @@ import { StackedBarChartComponent } from './stacked-bar-chart/stacked-bar-chart. SharedModule, MatSortModule, NgbNavModule, + NgbPopoverModule, ], }) export class ExerciseStatisticsModule {} diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-entry/log-entry.component.html b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-entry/log-entry.component.html new file mode 100644 index 000000000..cc83598d6 --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-entry/log-entry.component.html @@ -0,0 +1,12 @@ +
+
+ {{ logEntry.description }} + +
+
+ {{ logEntry.timestamp | formatDuration }} +
+
diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-entry/log-entry.component.scss b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-entry/log-entry.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-entry/log-entry.component.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-entry/log-entry.component.ts new file mode 100644 index 000000000..8c7907ff2 --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-entry/log-entry.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import { LogEntry } from 'digital-fuesim-manv-shared'; +import { StatisticsTimeSelectionService } from '../statistics-time-selection.service'; + +@Component({ + selector: 'app-log-entry', + templateUrl: './log-entry.component.html', + styleUrls: ['./log-entry.component.scss'], +}) +export class LogEntryComponent { + @Input() logEntry!: LogEntry; + + constructor( + private readonly statisticsTimeSelectionService: StatisticsTimeSelectionService + ) {} + + selectTime() { + this.statisticsTimeSelectionService.selectTime( + this.logEntry.timestamp, + 'log' + ); + } +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.html b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.html new file mode 100644 index 000000000..a26b495ac --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.html @@ -0,0 +1,145 @@ +
+

Log

+ +
+
+ Filter + + + +
+ + + + + +
    +
  • +
    + {{ filter.category }} + + hat Wert + + + ist gesetzt + + + + {{ specifier.name }} + + + + oder + +
    +
    + + +
    + + + + +
  • +
+ + + Keine Filter vorhanden. Sie können einen neuen Filter über den + Button hinzufügen. + + +
+ +
+ + Alle Einträge + + + Gefilterte Einträge + + + ({{ filteredLogEntries.length }}) +
+ +
    +
  • + +
  • +
+
diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.scss b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.scss new file mode 100644 index 000000000..ae62aa0fa --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.scss @@ -0,0 +1,3 @@ +ul.list-group-striped li:nth-of-type(even) { + background: #f2f2f2; +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.ts new file mode 100644 index 000000000..1fb1c3471 --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/log-table/log-table.component.ts @@ -0,0 +1,209 @@ +import type { + OnChanges, + OnDestroy, + SimpleChanges, + AfterViewInit, +} from '@angular/core'; +import { Component, Input } from '@angular/core'; +import type { LogEntry, Tag } from 'digital-fuesim-manv-shared'; +import { StrictObject } from 'digital-fuesim-manv-shared'; +import { difference } from 'lodash-es'; +import { Subject, takeUntil } from 'rxjs'; +import { StatisticsTimeSelectionService } from '../statistics-time-selection.service'; + +type KnownSpecifier = Omit; + +interface Filter { + category: string; + specifiers: KnownSpecifier[]; +} + +@Component({ + selector: 'app-log-table', + templateUrl: './log-table.component.html', + styleUrls: ['./log-table.component.scss'], +}) +export class LogTableComponent implements OnChanges, OnDestroy, AfterViewInit { + @Input() public logEntries!: readonly LogEntry[]; + + public knownCategories: { + [category: string]: { [specifier: string]: KnownSpecifier }; + } = {}; + + public filters: Filter[] = []; + private readonly destroy$ = new Subject(); + + constructor( + private readonly statisticsTimeSelectionService: StatisticsTimeSelectionService + ) {} + + public get availableCategories() { + const knownCategoryNames = Object.keys(this.knownCategories); + const categoriesInUse = this.filters.map((filter) => filter.category); + + return difference(knownCategoryNames, categoriesInUse) + .sort((a, b) => a.localeCompare(b)) + .map((categoryName) => ({ + name: categoryName, + identifier: categoryName, + })); + } + + public get availableSpecifiersPerCategory() { + return StrictObject.fromEntries( + StrictObject.entries(this.knownCategories).map( + ([knownCategory, knownSpecifiers]) => [ + knownCategory, + Object.values(knownSpecifiers) + .filter( + (knownSpecifier) => + this.filters + .find( + (filter) => + filter.category === knownCategory + ) + ?.specifiers.every( + (specifierInUse) => + specifierInUse.specifier !== + knownSpecifier.specifier + ) ?? true + ) + .map((availableSpecifier) => ({ + name: availableSpecifier.name, + identifier: availableSpecifier.specifier, + color: availableSpecifier.color, + backgroundColor: availableSpecifier.backgroundColor, + })), + ] + ) + ); + } + + public get filteredLogEntries() { + const predicate = (logEntry: LogEntry) => + this.filters.every((filter) => { + if (filter.specifiers.length === 0) { + return logEntry.tags.some( + (tag) => tag.category === filter.category + ); + } + + return filter.specifiers.some((specifier) => + logEntry.tags.some( + (tag) => + tag.category === filter.category && + tag.specifier === specifier.specifier + ) + ); + }); + + return this.logEntries.filter(predicate); + } + + ngAfterViewInit(): void { + this.statisticsTimeSelectionService.selectedTime$ + .pipe(takeUntil(this.destroy$)) + .subscribe((update) => { + if (update !== undefined) { + const { time, cause } = update; + if (cause === 'log') return; + const index = this.filteredLogEntries.findIndex( + (entry) => entry.timestamp >= time + ); + document + .querySelector( + `#log-entry-${ + index === -1 + ? this.filteredLogEntries.length - 1 + : index + }` + ) + ?.scrollIntoView({ behavior: 'smooth' }); + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!('logEntries' in changes)) return; + + this.knownCategories = {}; + + // Process all tags in reverse order to use the latest available display name and color + [...this.logEntries].reverse().forEach((logEntry) => { + logEntry.tags.forEach((tag) => { + if (!(tag.category in this.knownCategories)) { + this.knownCategories[tag.category] = {}; + } + + const knownCategory = this.knownCategories[tag.category]!; + + if (!(tag.specifier in knownCategory)) { + knownCategory[tag.specifier] = { + specifier: tag.specifier, + name: tag.name, + color: tag.color, + backgroundColor: tag.backgroundColor, + }; + } + }); + }); + } + + addCategory(category: string) { + if (!this.filters.some((filter) => filter.category === category)) { + this.filters.push({ category, specifiers: [] }); + } + } + + removeCategory(category: string) { + const categoryIndex = this.filters.findIndex( + (filter) => filter.category === category + ); + + if (categoryIndex !== -1) { + this.filters.splice(categoryIndex, 1); + } + } + + clearFilters() { + this.filters = []; + } + + addSpecifierToCategory(specifier: string, category: string) { + const categoryFilter = this.filters.find( + (filter) => filter.category === category + ); + + if (!categoryFilter) return; + + const specifierPresent = categoryFilter.specifiers.some( + (filter) => filter.specifier === specifier + ); + + if (!specifierPresent) { + categoryFilter.specifiers.push( + this.knownCategories[category]![specifier]! + ); + } + } + + removeSpecifierFromCategory(specifier: string, category: string) { + const categoryFilter = this.filters.find( + (filter) => filter.category === category + ); + + if (!categoryFilter) return; + + const specifierIndex = categoryFilter.specifiers.findIndex( + (filter) => filter.specifier === specifier + ); + + if (specifierIndex !== -1) { + categoryFilter.specifiers.splice(specifierIndex, 1); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + } +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/open-exercise-statistics-modal.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/open-exercise-statistics-modal.ts index 45dc39b6e..b2a08e58d 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/open-exercise-statistics-modal.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/open-exercise-statistics-modal.ts @@ -3,6 +3,6 @@ import { ExerciseStatisticsModalComponent } from './exercise-statistics-modal/ex export function openExerciseStatisticsModal(ngbModalService: NgbModal) { ngbModalService.open(ExerciseStatisticsModalComponent, { - size: 'xl', + size: 'xxl', }); } diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/stacked-bar-chart/stacked-bar-chart.component.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/stacked-bar-chart/stacked-bar-chart.component.ts index 641422bf6..4fddbcee5 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/stacked-bar-chart/stacked-bar-chart.component.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/stacked-bar-chart/stacked-bar-chart.component.ts @@ -1,6 +1,8 @@ import type { AfterViewInit, OnChanges, OnDestroy } from '@angular/core'; import { Component, ElementRef, Input, NgZone, ViewChild } from '@angular/core'; import type { SimpleChangesGeneric } from 'src/app/shared/types/simple-changes-generic'; +import { Subject, takeUntil } from 'rxjs'; +import { StatisticsTimeSelectionService } from '../statistics-time-selection.service'; import type { StackedBarChartDatasets } from './time-line-area-chart'; import { StackedBarChart } from './time-line-area-chart'; @@ -17,19 +19,33 @@ export class StackedBarChartComponent @ViewChild('chart', { static: true }) chartCanvas!: ElementRef; - constructor(private readonly ngZone: NgZone) {} + private readonly destroy$ = new Subject(); + + constructor( + private readonly ngZone: NgZone, + private readonly timeSelectionService: StatisticsTimeSelectionService + ) {} private chart?: StackedBarChart; ngAfterViewInit() { // Run outside angular zone for improved performance this.ngZone.runOutsideAngular(() => { - this.chart = new StackedBarChart(this.chartCanvas.nativeElement); + this.chart = new StackedBarChart( + this.chartCanvas.nativeElement, + (time) => this.timeSelectionService.selectTime(time, 'chart') + ); + this.timeSelectionService.selectedTime$ + .pipe(takeUntil(this.destroy$)) + .subscribe((update) => + this.chart?.setHighlightTime(update?.time) + ); }); this.updateChartData(); } ngOnChanges(changes: SimpleChangesGeneric) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (changes.statistics) { this.updateChartData(); } @@ -45,11 +61,12 @@ export class StackedBarChartComponent } ngOnDestroy() { + this.destroy$.next(); this.chart?.destroy(); } } export interface StackedBarChartStatistics { - labels: string[]; + labels: number[]; datasets: StackedBarChartDatasets; } diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/stacked-bar-chart/time-line-area-chart.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/stacked-bar-chart/time-line-area-chart.ts index b989bcfd1..76c89f328 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/stacked-bar-chart/time-line-area-chart.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/stacked-bar-chart/time-line-area-chart.ts @@ -8,6 +8,7 @@ import { LinearScale, Tooltip, } from 'chart.js'; +import { formatDuration } from 'digital-fuesim-manv-shared'; import { rgbColorPalette } from 'src/app/shared/functions/colors'; Chart.register( @@ -25,17 +26,37 @@ export type StackedBarChartDatasets = ChartDataset<'bar', Data[]>[]; export class StackedBarChart { public static readonly backgroundAlpha = 0.8; - public readonly chart: Chart<'bar', Data[], string>; + public readonly chart: Chart<'bar', Data[], number>; + markedTime: number | undefined; + lineMarkerIndex: number | undefined; - constructor(canvas: HTMLCanvasElement) { - this.chart = new Chart<'bar', Data[], string>( - canvas, - this.canvasConfig - ); + constructor( + canvas: HTMLCanvasElement, + readonly clickCallback?: (time: number) => void + ) { + this.chart = new Chart(canvas, this.canvasConfig); + } + + public setHighlightTime(time: number | undefined) { + this.markedTime = time; + this.updateMarkerIndex(); + this.chart.update(); + } + + private updateMarkerIndex() { + if (this.markedTime === undefined) { + this.lineMarkerIndex = undefined; + } else { + const index = + this.chart.data.labels?.findIndex( + (label) => this.markedTime! <= (label as number) + ) ?? -1; + this.lineMarkerIndex = index < 0 ? undefined : index; + } } public setChartData( - newLabels: string[], + newLabels: number[], newDatasets: StackedBarChartDatasets ) { this.chart.data.labels = newLabels; @@ -59,7 +80,7 @@ export class StackedBarChart { // Add the properties from the new dataset Object.assign(oldDataset, newDataset); }); - + this.updateMarkerIndex(); this.chart.update(); } @@ -68,26 +89,14 @@ export class StackedBarChart { } // It causes problems if this object is shared between multiple charts. - private readonly canvasConfig: ChartConfiguration<'bar', Data[], string> = { + private readonly canvasConfig: ChartConfiguration<'bar', Data[], number> = { type: 'bar', data: { labels: [], datasets: [], }, options: { - transitions: { - // Disable the clunky animations for showing and hiding datasets - hide: { - animation: { - duration: 0, - }, - }, - show: { - animation: { - duration: 0, - }, - }, - }, + animation: false, plugins: { tooltip: { position: 'nearest', @@ -95,6 +104,11 @@ export class StackedBarChart { callbacks: { label: (tooltipItem) => `${tooltipItem.dataset.label}: ${tooltipItem.formattedValue}`, + title(tooltipItems) { + return formatDuration( + Number(tooltipItems[0]!.label) + ); + }, }, }, legend: { @@ -114,8 +128,16 @@ export class StackedBarChart { scales: { x: { stacked: true, + type: 'category', ticks: { maxTicksLimit: 10, + callback(tickValue) { + return formatDuration( + this.chart.data.labels?.[ + tickValue as number + ] as number + ); + }, }, }, y: { @@ -137,6 +159,40 @@ export class StackedBarChart { normalized: false, }, }, + onClick: (event, elements, chart) => { + const { + scales: { x }, + data: { labels }, + } = chart; + if (event.x === null || !this.clickCallback) return; + const index = x?.getValueForPixel(event.x); + if (index !== undefined && labels?.[index] !== undefined) { + this.clickCallback(labels[index] as number); + } + }, }, + plugins: [ + { + id: 'timeMarker', + afterDraw: (chart) => { + if (this.lineMarkerIndex === undefined) return; + const { + ctx, + chartArea: { top, bottom }, + scales: { x }, + } = chart; + const xCoord = x!.getPixelForValue(this.lineMarkerIndex); + + ctx.save(); + ctx.beginPath(); + ctx.strokeStyle = 'blue'; + ctx.lineWidth = 3; + ctx.moveTo(xCoord, top); + ctx.lineTo(xCoord, bottom); + ctx.stroke(); + ctx.restore(); + }, + }, + ], }; } diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/statistics-time-selection.service.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/statistics-time-selection.service.ts new file mode 100644 index 000000000..52234c7ca --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/statistics-time-selection.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class StatisticsTimeSelectionService { + public readonly selectedTime$ = new BehaviorSubject< + { time: number; cause: 'chart' | 'log' } | undefined + >(undefined); + + public selectTime(time: number, cause: 'chart' | 'log') { + this.selectedTime$.next({ time, cause }); + } + + public clearSelection() { + this.selectedTime$.next(undefined); + } +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/tag/tag.component.html b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/tag/tag.component.html new file mode 100644 index 000000000..325bcf020 --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/tag/tag.component.html @@ -0,0 +1,7 @@ + + {{ tag.category }}: {{ tag.name }} + diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/tag/tag.component.scss b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/tag/tag.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/tag/tag.component.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/tag/tag.component.ts new file mode 100644 index 000000000..f309dd5ab --- /dev/null +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-statistics/tag/tag.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core'; +import { Tag } from 'digital-fuesim-manv-shared'; + +@Component({ + selector: 'app-tag', + templateUrl: './tag.component.html', + styleUrls: ['./tag.component.scss'], +}) +export class TagComponent { + @Input() tag!: Tag; +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-overview.module.ts b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-overview.module.ts index 0d4996cc7..01def3515 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-overview.module.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-overview.module.ts @@ -23,7 +23,6 @@ import { SimulatedRegionOverviewBehaviorTreatPatientsPatientDetailsComponent } f import { WithDollarPipe } from './tabs/general-tab/utils/with-dollar'; import { PersonnelTypeToGermanAbbreviationPipe } from './tabs/behavior-tab/utils/personnel-type-to-german-abbreviation.pipe'; import { SimulatedRegionsModalComponent } from './simulated-regions-modal/simulated-regions-modal.component'; -import { SimulatedRegionNameComponent } from './simulated-region-name/simulated-region-name.component'; import { SimulatedRegionOverviewPatientsTabComponent } from './tabs/patients-tab/simulated-region-overview-patients-tab/simulated-region-overview-patients-tab.component'; import { SelectPatientService } from './select-patient.service'; import { RadiogramListComponent } from './radiogram-list/radiogram-list.component'; @@ -64,7 +63,6 @@ import { ManagePatientTransportToHospitalSettingsEditorComponent } from './tabs/ @NgModule({ declarations: [ - SimulatedRegionNameComponent, SimulatedRegionOverviewGeneralComponent, SimulatedRegionOverviewBehaviorTabComponent, SimulatedRegionOverviewGeneralTabComponent, diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/report/simulated-region-overview-behavior-report.component.ts b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/report/simulated-region-overview-behavior-report.component.ts index d0bf2d629..360f7abc0 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/report/simulated-region-overview-behavior-report.component.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/report/simulated-region-overview-behavior-report.component.ts @@ -6,7 +6,11 @@ import type { ReportableInformation, ReportBehaviorState, } from 'digital-fuesim-manv-shared'; -import { reportableInformations, UUID } from 'digital-fuesim-manv-shared'; +import { + reportableInformationTypeToGermanNameDictionary, + reportableInformations, + UUID, +} from 'digital-fuesim-manv-shared'; import type { Observable } from 'rxjs'; import { combineLatest, map } from 'rxjs'; import { ExerciseService } from 'src/app/core/exercise.service'; @@ -35,19 +39,8 @@ export class SimulatedRegionOverviewBehaviorReportComponent implements OnInit { currentTime$!: Observable; reportableInformations = reportableInformations; - reportableInformationTranslationMap: { - [key in ReportableInformation]: string; - } = { - patientCount: 'Anzahl an Patienten', - vehicleCount: 'Anzahl an Fahrzeugen', - personnelCount: 'Anzahl an Rettungskräften', - materialCount: 'Anzahl an Material', - treatmentStatus: 'Behandlungsstatus', - singleRegionTransferCounts: - 'Anzahl aus diesem Bereich in Krankenhäuser abtransportierter Patienten', - transportManagementTransferCounts: - 'Anzahl unter dieser Transportorganisation in Krankenhäuser abtransportierter Patienten', - }; + reportableInformationTranslationMap = + reportableInformationTypeToGermanNameDictionary; createReportCollapsed = true; repeatingReport = false; diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/request-vehicles/simulated-region-overview-behavior-request-vehicles.component.ts b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/request-vehicles/simulated-region-overview-behavior-request-vehicles.component.ts index c58203c9b..9f77b054d 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/request-vehicles/simulated-region-overview-behavior-request-vehicles.component.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/request-vehicles/simulated-region-overview-behavior-request-vehicles.component.ts @@ -105,7 +105,7 @@ export class RequestVehiclesComponent implements OnChanges { return undefined; const recurringEventActivityState = activities[ requestBehaviorState.recurringEventActivityId - ] as RecurringEventActivityState; + ] as RecurringEventActivityState | undefined; if (!recurringEventActivityState) return undefined; return Math.max( diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/treat-patients/patient-details/simulated-region-overview-behavior-treat-patients-patient-details.component.ts b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/treat-patients/patient-details/simulated-region-overview-behavior-treat-patients-patient-details.component.ts index ffd3e92eb..917978978 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/treat-patients/patient-details/simulated-region-overview-behavior-treat-patients-patient-details.component.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/treat-patients/patient-details/simulated-region-overview-behavior-treat-patients-patient-details.component.ts @@ -49,7 +49,7 @@ export class SimulatedRegionOverviewBehaviorTreatPatientsPatientDetailsComponent selectPersonnel, patientSelector, (personnel, patient) => - Object.keys(patient?.assignedPersonnelIds ?? {}) + Object.keys(patient.assignedPersonnelIds) .map((personnelId) => personnel[personnelId]) .filter((person) => person !== undefined) .map((person) => ({ @@ -82,16 +82,12 @@ export class SimulatedRegionOverviewBehaviorTreatPatientsPatientDetailsComponent createSelector( patientSelector, selectConfiguration, - (patient, configuration) => { - if (patient === undefined) { - return 'white'; - } - return Patient.getVisibleStatus( + (patient, configuration) => + Patient.getVisibleStatus( patient, configuration.pretriageEnabled, configuration.bluePatientsEnabled - ); - } + ) ) ); } diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/unload-arriving-vehicles/simulated-region-overview-behavior-unload-arriving-vehicles.component.ts b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/unload-arriving-vehicles/simulated-region-overview-behavior-unload-arriving-vehicles.component.ts index 61feaabb0..56454974d 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/unload-arriving-vehicles/simulated-region-overview-behavior-unload-arriving-vehicles.component.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/behaviors/unload-arriving-vehicles/simulated-region-overview-behavior-unload-arriving-vehicles.component.ts @@ -50,7 +50,7 @@ export class SimulatedRegionOverviewBehaviorUnloadArrivingVehiclesComponent ); this.unloadDuration$ = this.store .select(selectBehavior) - .pipe(map((state) => state?.unloadDelay ?? 0)); + .pipe(map((state) => state.unloadDelay)); const unloadingSelector = createSelector( createSelectActivityStates(this.simulatedRegionId), diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/utils/behavior-to-german-name.pipe.ts b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/utils/behavior-to-german-name.pipe.ts index 04c5173da..d4b24012d 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/utils/behavior-to-german-name.pipe.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/utils/behavior-to-german-name.pipe.ts @@ -1,22 +1,8 @@ import type { PipeTransform } from '@angular/core'; import { Pipe } from '@angular/core'; import type { ExerciseSimulationBehaviorType } from 'digital-fuesim-manv-shared'; +import { behaviorTypeToGermanNameDictionary } from 'digital-fuesim-manv-shared'; -const behaviorTypeToGermanNameDictionary: { - [Key in ExerciseSimulationBehaviorType]: string; -} = { - assignLeaderBehavior: 'Führung zuweisen', - treatPatientsBehavior: 'Patienten behandeln', - unloadArrivingVehiclesBehavior: 'Fahrzeuge entladen', - reportBehavior: 'Berichte erstellen', - providePersonnelBehavior: 'Personal nachfordern', - answerRequestsBehavior: 'Fahrzeuganfragen beantworten', - automaticallyDistributeVehiclesBehavior: 'Fahrzeuge verteilen', - requestBehavior: 'Fahrzeuge anfordern', - transferBehavior: 'Fahrzeuge versenden', - transferToHospitalBehavior: 'Patienten abtransportieren', - managePatientTransportToHospitalBehavior: 'Transportorganisation', -}; @Pipe({ name: 'behaviorTypeToGermanName', }) diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/utils/treatment-progress-to-german-name.pipe.ts b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/utils/treatment-progress-to-german-name.pipe.ts index f528ca7ff..83c253d53 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/utils/treatment-progress-to-german-name.pipe.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/behavior-tab/utils/treatment-progress-to-german-name.pipe.ts @@ -1,15 +1,7 @@ import type { PipeTransform } from '@angular/core'; import { Pipe } from '@angular/core'; import type { TreatmentProgress } from 'digital-fuesim-manv-shared'; -const treatmentProgressToGermanNameDictionary: { - [Key in TreatmentProgress]: string; -} = { - counted: 'Vorsichten', - noTreatment: 'Keine Behandlung', - secured: 'Erstversorgung sichergestellt', - triaged: 'Behandeln, Personal fehlt', - unknown: 'Erkunden', -}; +import { treatmentProgressToGermanNameDictionary } from 'digital-fuesim-manv-shared'; @Pipe({ name: 'treatmentProgressToGermanName', diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/compare-patients.ts b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/compare-patients.ts index 5ddbd4cd6..28a01a5a9 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/compare-patients.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/tabs/compare-patients.ts @@ -27,27 +27,16 @@ export function comparePatientsByVisibleStatus( patientB: Patient, configuration: ExerciseConfiguration ): number { - let statusA!: PatientStatus; - let statusB!: PatientStatus; - - if (patientA === undefined) { - statusA = 'white'; - } else { - statusA = Patient.getVisibleStatus( - patientA, - configuration.pretriageEnabled, - configuration.bluePatientsEnabled - ); - } - if (patientB === undefined) { - statusB = 'white'; - } else { - statusB = Patient.getVisibleStatus( - patientB, - configuration.pretriageEnabled, - configuration.bluePatientsEnabled - ); - } + const statusA = Patient.getVisibleStatus( + patientA, + configuration.pretriageEnabled, + configuration.bluePatientsEnabled + ); + const statusB = Patient.getVisibleStatus( + patientB, + configuration.pretriageEnabled, + configuration.bluePatientsEnabled + ); const valueA = patientCategoryOrderDictionary[statusA]; const valueB = patientCategoryOrderDictionary[statusB]; diff --git a/frontend/src/app/pages/exercises/exercise/shared/time-travel/time-travel.component.ts b/frontend/src/app/pages/exercises/exercise/shared/time-travel/time-travel.component.ts index 9cf43e58f..7ce81f0b0 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/time-travel/time-travel.component.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/time-travel/time-travel.component.ts @@ -106,7 +106,7 @@ export class TimeTravelComponent implements OnDestroy { selectTimeConstraints, this.store )!; - this.timeTravelService.jumpToTime(timeConstraints!.start); + this.timeTravelService.jumpToTime(timeConstraints.start); this.startReplay(); } diff --git a/frontend/src/app/pages/exercises/exercise/shared/transfer-overview/start-point-name/start-point-name.component.html b/frontend/src/app/pages/exercises/exercise/shared/transfer-overview/start-point-name/start-point-name.component.html index fee500aee..da0702759 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/transfer-overview/start-point-name/start-point-name.component.html +++ b/frontend/src/app/pages/exercises/exercise/shared/transfer-overview/start-point-name/start-point-name.component.html @@ -1,5 +1,13 @@ - {{ startPoint.alarmGroupName }} + + {{ alarmGroupName }} + + Gelöschte Alarmgruppe | undefined; + + constructor(private readonly store: Store) {} + + ngOnChanges(): void { + if (this.startPoint.type === 'alarmGroupStartPoint') { + const alarmGroupNameSelector = createSelector( + createSelectAlarmGroup(this.startPoint.alarmGroupId), + (alarmGroup) => alarmGroup.name + ); + this.alarmGroupName$ = this.store.select(alarmGroupNameSelector); + } + } } diff --git a/frontend/src/app/shared/components/searchable-dropdown/searchable-dropdown.component.html b/frontend/src/app/shared/components/searchable-dropdown/searchable-dropdown.component.html new file mode 100644 index 000000000..a420b6da0 --- /dev/null +++ b/frontend/src/app/shared/components/searchable-dropdown/searchable-dropdown.component.html @@ -0,0 +1,32 @@ + +
+ + +
diff --git a/frontend/src/app/shared/components/searchable-dropdown/searchable-dropdown.component.scss b/frontend/src/app/shared/components/searchable-dropdown/searchable-dropdown.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/shared/components/searchable-dropdown/searchable-dropdown.component.ts b/frontend/src/app/shared/components/searchable-dropdown/searchable-dropdown.component.ts new file mode 100644 index 000000000..47af855d2 --- /dev/null +++ b/frontend/src/app/shared/components/searchable-dropdown/searchable-dropdown.component.ts @@ -0,0 +1,73 @@ +import type { AfterViewInit } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, +} from '@angular/core'; + +interface Option { + name: string; + identifier: string; + color?: string; + backgroundColor?: string; +} + +@Component({ + selector: 'app-searchable-dropdown', + templateUrl: './searchable-dropdown.component.html', + styleUrls: ['./searchable-dropdown.component.scss'], +}) +export class SearchableDropdownComponent implements AfterViewInit { + @Input() + public options: Option[] = []; + + public filter = ''; + public selectedIndex = -1; + + public get filteredOptions() { + return this.options.filter((option) => + option.name.toLowerCase().includes(this.filter.toLowerCase()) + ); + } + + @Output() + public readonly selected: EventEmitter = new EventEmitter(); + + @ViewChild('searchInput') + private readonly searchInput!: ElementRef; + + ngAfterViewInit() { + this.searchInput.nativeElement.focus(); + } + + increaseSelectedIndex() { + if (this.selectedIndex + 1 < this.filteredOptions.length) + this.selectedIndex++; + } + + decreaseSelectedIndex() { + if (this.selectedIndex - 1 >= -1) this.selectedIndex--; + } + + resetSelectedIndex() { + if (this.filteredOptions.length === 1) { + this.selectedIndex = 0; + } else { + this.selectedIndex = -1; + } + } + + confirmSelection() { + if ( + this.selectedIndex > -1 && + this.selectedIndex < this.filteredOptions.length + ) { + this.selected.emit( + this.filteredOptions[this.selectedIndex]!.identifier + ); + } + } +} diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-name/simulated-region-name.component.html b/frontend/src/app/shared/components/simulated-region-name/simulated-region-name.component.html similarity index 100% rename from frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-name/simulated-region-name.component.html rename to frontend/src/app/shared/components/simulated-region-name/simulated-region-name.component.html diff --git a/frontend/src/app/shared/components/simulated-region-name/simulated-region-name.component.scss b/frontend/src/app/shared/components/simulated-region-name/simulated-region-name.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-name/simulated-region-name.component.ts b/frontend/src/app/shared/components/simulated-region-name/simulated-region-name.component.ts similarity index 100% rename from frontend/src/app/pages/exercises/exercise/shared/simulated-region-overview/simulated-region-name/simulated-region-name.component.ts rename to frontend/src/app/shared/components/simulated-region-name/simulated-region-name.component.ts diff --git a/frontend/src/app/shared/components/vehicle-occupation-editor/vehicle-occupation-editor.component.html b/frontend/src/app/shared/components/vehicle-occupation-editor/vehicle-occupation-editor.component.html index df187bfb1..bcf503169 100644 --- a/frontend/src/app/shared/components/vehicle-occupation-editor/vehicle-occupation-editor.component.html +++ b/frontend/src/app/shared/components/vehicle-occupation-editor/vehicle-occupation-editor.component.html @@ -2,32 +2,7 @@ class="d-flex justify-content-between align-items-center" *appLet="occupation$ | async as occupation" > - - - Das Fahrzeug wird nicht genutzt. - - - Das Fahrzeug wird gerade übergeben. - - - Das Fahrzeug wird gerade ausgeladen. - - - Das Fahrzeug wird gerade beladen. - - - Das Fahrzeug wartet auf den Transfer. - - - - Das Fahrzeug ist für den Transport von Patienten ins Krankenhaus - reserviert. - - - - Die Tätigkeit ist unbekannt. - - + {{ occupationToGermanDictionary[occupation.type] }}.