Skip to content
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
2ced797
feat: add measure datamodel
Jogius Mar 27, 2026
40ca1b9
feat: add measure selectors and bar
Jogius Mar 27, 2026
19fcfdc
feat: add measure execution
Jogius Mar 29, 2026
de6efe3
feat: redesign measure toolbar
Jogius Mar 31, 2026
5639f55
feat: add alarm and eoc log measure properties
Jogius Apr 1, 2026
4cccd5d
feat: add measures to trainer view
Jogius Apr 1, 2026
0c3d24e
feat: add measure template creation form and modal
Jogius Apr 1, 2026
02a53a4
feat: add form validation with zod
Jogius Apr 2, 2026
1a36e66
feat: drag and drop reordering measure templates
Jogius Apr 6, 2026
e0e91e5
fix: apply style and linting
Jogius Apr 6, 2026
921d90d
fix: allow drag scrolling with normal modal
Jogius Apr 6, 2026
12ab0f0
feat: add scroll buttons to measures
Jogius Apr 6, 2026
d76c715
fix: bump state version and change property deps
Jogius Apr 6, 2026
b2de849
feat: add edit measure template modal
Jogius Apr 6, 2026
7651e15
feat: alarm group and transferpoint display
Jogius Apr 6, 2026
3f79fa5
feat: add drawing properties
Jogius Apr 7, 2026
1ca40dc
feat: add measure categories
Jogius Apr 16, 2026
78b2000
update: improve hint text UI
Jogius Apr 16, 2026
bfd6b33
fix: measure execution
Jogius Apr 16, 2026
13f9c7f
feat: allow replacement of measures
Jogius Apr 16, 2026
6095e73
feat: add abort/complete buttons
Jogius Apr 16, 2026
89d4cef
feat: add delay abort
Jogius Apr 16, 2026
4c575de
fix: use signal from for alarm modal
Jogius Apr 22, 2026
885a4da
fix: use signal form for measure template
Jogius Apr 22, 2026
2fde377
chore: reformatting with new prettier
Jogius Apr 22, 2026
b85214f
fix: address review comments
Jogius Apr 22, 2026
8bbcc0d
fix: remove templates and minor fixes
Jogius Apr 22, 2026
0948144
fix: minor review changes
Jogius Apr 22, 2026
c23446d
Fix some design aspects of global measures
hansegucker Apr 22, 2026
9fd232f
fix: improve measure template form
Jogius Apr 22, 2026
231e830
fix: bump state version
Jogius Apr 22, 2026
56bb7fa
feat: remove dnd, add arrow buttons
Jogius Apr 22, 2026
35d8b9e
Merge branch 'dev-next' into feature/measures
fekoch Apr 22, 2026
c13db1a
fix: bump state version to 52
Jogius Apr 23, 2026
bf43859
fix: honor exercise pause in delay
Jogius Apr 23, 2026
f17ecff
fix: add type to measure related types
Jogius Apr 23, 2026
456dc28
feat: add measures to log
Jogius Apr 23, 2026
e727b30
feat: make drawing strokes wider
Jogius Apr 23, 2026
c4b48cd
Merge branch 'dev-next' into feature/measures
Jogius Apr 23, 2026
6f678a0
fix: change log text
Jogius Apr 23, 2026
72db18e
fix: address some review comments
Jogius Apr 23, 2026
3698d91
feat: migrate actions to zod
Jogius Apr 24, 2026
bedbd62
chore: rename drawing type to polygon
Jogius Apr 24, 2026
65ca0bc
chore: clean up measures data model
Jogius Apr 24, 2026
86cc780
Merge branch 'dev-next' into feature/measures
Jogius Apr 27, 2026
82c0b68
chore: code improvements
Jogius Apr 29, 2026
ed22bbe
Merge branch 'dev-next' into feature/measures
Jogius Apr 29, 2026
cfc8296
fix: measures in fullscreen and animation
Jogius May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions frontend/src/app/core/drawing-interaction.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import type { MapCoordinates, DrawingType } from 'fuesim-digital-shared';
import { Observable, Subject } from 'rxjs';

export interface DrawRequest {
drawingType: DrawingType;
strokeColor: string;
fillColor?: string;
endEvent: Observable<boolean | null>;
}

export interface DrawingResult {
points: MapCoordinates[];
}

@Injectable({
providedIn: 'root',
})
export class DrawingInteractionService {
private readonly drawRequest = new Subject<DrawRequest>();
private pendingResolve: ((result: DrawingResult | null) => void) | null =
null;

public get onDrawRequest$(): Observable<DrawRequest> {
return this.drawRequest.asObservable();
}

public async requestDrawing(
request: DrawRequest
): Promise<DrawingResult | null> {
return new Promise<DrawingResult | null>((resolve) => {
if (this.pendingResolve !== null) this.pendingResolve(null);
this.pendingResolve = resolve;
this.drawRequest.next(request);
});
}

public completeDrawing(result: DrawingResult | null): void {
if (this.pendingResolve) {
this.pendingResolve(result);
this.pendingResolve = null;
}
}
}
263 changes: 263 additions & 0 deletions frontend/src/app/core/measure.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { inject, Injectable, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
MeasureProperty,
MeasurePropertyInstance,
MeasureTemplate,
newDrawing,
newMeasure,
} from 'fuesim-digital-shared';
import { filter, firstValueFrom, map, Subject, race } from 'rxjs';

import { AppState } from '../state/app.state';
import { selectLastClientName } from '../state/application/selectors/application.selectors';
import {
openAlarmModal,
openEocLogModal,
} from '../pages/exercises/exercise/shared/measure-modals/open-measure-modals';
import { selectStateSnapshot } from '../state/get-state-snapshot';
import {
createSelectAlarmGroup,
selectCurrentTime,
} from '../state/application/selectors/exercise.selectors';
import { getVehicleParameters } from '../shared/functions/vehicle-parameters';
import { ConfirmationModalService } from './confirmation-modal/confirmation-modal.service';
import { ExerciseService } from './exercise.service';
import { DrawingInteractionService } from './drawing-interaction.service';
import { MessageService } from './messages/message.service';

Comment thread
Jogius marked this conversation as resolved.
@Injectable({
providedIn: 'root',
})
export class MeasureService {
private readonly store = inject<Store<AppState>>(Store);
private readonly exerciseService = inject(ExerciseService);
private readonly messageService = inject(MessageService);
private readonly ngbModalService = inject(NgbModal);
private readonly confirmationModalService = inject(
ConfirmationModalService
);
private readonly drawingInteractionService = inject(
DrawingInteractionService
);

private readonly clientName = this.store.selectSignal(selectLastClientName);

public readonly activeMeasure = signal<MeasureTemplate | null>(null);
public readonly activeProperty = signal<MeasureProperty | null>(null);
public endEvent?: Subject<boolean | null>;

/**
* Handles the execution of a single property for a measure.
* @param template The template this property is being executed for.
* @param property The property being executed.
* @returns - false if user canceled the measure
* - true if property was executed successfully but returns no instance
* - MeasurePropertyInstance if property executed successfully and returns an instance
*/
private async handle(
template: MeasureTemplate,
property: MeasureProperty
): Promise<MeasurePropertyInstance | boolean> {
this.activeProperty.set(property);
const currentTime = selectStateSnapshot(selectCurrentTime, this.store);
switch (property.type) {
case 'delay':
this.endEvent = new Subject<boolean | null>();
return firstValueFrom(
race(
this.endEvent.pipe(filter((e) => e !== null)),
this.store
.select(selectCurrentTime)
.pipe(
filter(
(t) =>
t >= currentTime + property.delay * 1000
)
)
.pipe(map(() => true))
)
);
case 'response':
await this.confirmationModalService.confirm({
title: template.name,
description: property.response,
confirmationString: '',
});
return true;
case 'manualConfirm': {
const res = await this.confirmationModalService.confirm({
title: template.name,
description: property.prompt,
confirmationString: property.confirmationString,
});
return res ?? false;
}
case 'alarm': {
const modalRef = openAlarmModal(
this.ngbModalService,
property.alarmGroups,
property.targetTransferPointIds,
template.name
);
try {
const result = await modalRef.result;
return {
type: 'alarmInstance',
alarmGroup: result.alarmGroup,
targetTransferPointId: result.targetTransferPointId,
vehicleParameters: getVehicleParameters(
this.store,
selectStateSnapshot(
createSelectAlarmGroup(result.alarmGroup),
this.store
)
),
};
} catch {
return false;
}
}
case 'eocLog': {
let message: string;
if (property.confirm) {
const modalRef = openEocLogModal(
this.ngbModalService,
property.message,
property.editable
);
try {
const result = await modalRef.result;
message = result.message;
} catch {
return false;
}
} else {
// The zod schema enforces that the message is editable
// when it is empty, and that it has to be confirmed when
// it is editable. Thus, message must be defined here.
message = property.message!;
}

return {
type: 'eocLogInstance',
message,
};
}
case 'drawFreehand': {
this.endEvent = new Subject<boolean | null>();
const result =
await this.drawingInteractionService.requestDrawing({
drawingType: 'freehand',
strokeColor: property.strokeColor,
fillColor: property.fillColor,
endEvent: this.endEvent,
});
if (!result) return false;
const drawing = newDrawing(
'freehand',
result.points,
property.strokeColor,
property.fillColor
);
this.exerciseService.proposeAction({
type: '[Drawing] Add drawing',
drawing,
});
return {
type: 'drawingInstance',
id: drawing.id,
};
}
case 'drawLine': {
this.endEvent = new Subject<boolean | null>();
const result =
await this.drawingInteractionService.requestDrawing({
drawingType: 'line',
strokeColor: property.strokeColor,
endEvent: this.endEvent,
});
if (!result) return false;
const drawing = newDrawing(
'line',
result.points,
property.strokeColor,
undefined
);
this.exerciseService.proposeAction({
type: '[Drawing] Add drawing',
drawing,
});
return {
type: 'drawingInstance',
id: drawing.id,
};
}
}
}

private resetActive() {
this.activeMeasure.set(null);
this.activeProperty.set(null);
}

public async executeMeasure(template: MeasureTemplate) {
this.activeMeasure.set(template);

const instances = [];
for (const property of template.properties) {
// eslint-disable-next-line no-await-in-loop
const result = await this.handle(template, property);

if (result === false) {
this.resetActive();
this.abort(instances);
return;
} else if (result === true) continue;

instances.push(result);
}

this.resetActive();
this.confirm(template, instances);
}

private confirm(
template: MeasureTemplate,
instances: MeasurePropertyInstance[]
) {
this.exerciseService
.proposeAction({
type: '[Measure] Add Measure',
measure: newMeasure(
selectStateSnapshot(selectCurrentTime, this.store),
this.clientName() ?? '',
template.id,
instances
),
})
.catch((e) => {
this.messageService.postError({
title: 'Fehler beim Erstellen der Maßnahme',
error: e.message,
});
});
}

private abort(instances: MeasurePropertyInstance[]) {
for (const instance of instances) {
switch (instance.type) {
case 'drawingInstance': {
this.exerciseService.proposeAction({
type: '[Drawing] Remove drawing',
drawingId: instance.id,
});
break;
}
default:
break;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,7 @@ <h2 class="mb-0">
@if (!ownClient.isInWaitingRoom) {
@switch (ownClient.role.specificRole) {
@case ('mapOperator') {
<app-exercise-map
class="h-100 rounded overflow-hidden"
></app-exercise-map>
<app-map-operator-map />
}
@case ('trainer') {
<app-trainer-map-editor></app-trainer-map-editor>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import {
import { selectOwnClient } from '../../../../state/application/selectors/shared.selectors';
import { selectStateSnapshot } from '../../../../state/get-state-snapshot';
import { TimeTravelComponent } from '../shared/time-travel/time-travel.component';
import { ExerciseMapComponent } from '../shared/exercise-map/exercise-map.component';
import { TrainerMapEditorComponent } from '../shared/trainer-map-editor/trainer-map-editor.component';
import { EmergencyOperationsCenterFullComponent } from '../shared/emergency-operations-center/emergency-operations-center-full/emergency-operations-center-full.component';
import { FormatDurationPipe } from '../../../../shared/pipes/format-duration.pipe';
Expand All @@ -52,6 +51,7 @@ import {
openParticipantsModal,
openTrainersModal,
} from '../shared/clients-modal/open-clients-modal';
import { MapOperatorMapComponent } from '../shared/map-operator-map/map-operator-map.component';

@Component({
selector: 'app-exercise',
Expand All @@ -67,14 +67,14 @@ import {
NgbDropdownButtonItem,
NgbDropdownItem,
TimeTravelComponent,
ExerciseMapComponent,
TrainerMapEditorComponent,
EmergencyOperationsCenterFullComponent,
AsyncPipe,
FormatDurationPipe,
OperationsTabletViewComponent,
ParallelExerciseStatusBarComponent,
CopyButtonComponent,
MapOperatorMapComponent,
],
})
export class ExerciseComponent implements OnDestroy {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<div class="card text-center mx-1 my-1" [attr.data-cy]="dataCy()">
<div class="card-body-wrapper">
<div class="card-body p-2">
<span class="card-text">
{{ name() }}
</span>
</div>
</div>
<div style="top: 0; right: 0" class="position-absolute">
@if (enableEditButton()) {
<button
(mousedown)="elementEdit.emit(); $event.stopPropagation()"
class="btn btn-outline-warning btn-sm mt-1 me-1"
>
<i class="bi bi-pencil-fill"></i>
</button>
}
@if (enableDeleteButton()) {
<button
(mousedown)="elementDelete.emit(); $event.stopPropagation()"
class="btn btn-outline-danger btn-sm mt-1 me-1"
>
<i class="bi bi-trash"></i>
</button>
}
</div>
Comment thread
hansegucker marked this conversation as resolved.
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.card-body-wrapper {
// Match the height of map-editor-card (70px image + ~30px title)
height: 105px;
display: flex;
align-items: center;
justify-content: center;
}

.card-text {
display: -webkit-box;
line-clamp: 4;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
font-weight: 500;
}
Loading
Loading