implement metrics tracking

master
MrPlatnum 2025-09-14 12:17:51 +02:00
parent ca8d4e31da
commit 9446e3b2de
12 changed files with 846 additions and 467 deletions

View File

@ -2,7 +2,8 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient()]
}; };

View File

@ -1,199 +1,244 @@
<div class="min-h-screen bg-gray-50 py-8 px-4"> <div class="min-h-screen bg-gray-50 py-8 px-4">
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-8"> <div class="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-8">
<div class="text-center mb-8"> <div class="text-center mb-8">
<h2 class="text-3xl font-bold text-gray-900">Abschließender Fragebogen</h2> <h2 class="text-3xl font-bold text-gray-900">Abschließender Fragebogen</h2>
<p class="text-gray-600 mt-2">Ihre Angaben helfen uns bei der wissenschaftlichen Auswertung der Studie.</p> <p class="text-gray-600 mt-2">Ihre Angaben helfen uns bei der wissenschaftlichen Auswertung der Studie.</p>
</div> </div>
<form [formGroup]="demographicsForm" (ngSubmit)="onSubmit()"> <form [formGroup]="demographicsForm" (ngSubmit)="onSubmit()">
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">1</span>
Bewertung der Halteposition
</h3>
<p class="text-sm text-gray-600 mb-6">Diese Fragen beziehen sich auf den Test, bei dem Sie das virtuelle Objekt 20 Sekunden lang in Position halten mussten.</p>
<!-- Demografische Daten --> <div class="space-y-6">
<div class="mb-8"> <!-- Frage 1: Komfort -->
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center"> <div class="p-4 border border-gray-200 rounded-lg">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">1</span> <label id="label-comfortHold" class="block text-sm font-medium text-gray-800 mb-3">
Demografische Angaben Ich empfand es als angenehm, das Gerät für die Dauer des Tests in der Position zu halten. *
</h3> </label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="flex justify-between items-center">
<div> <span class="text-xs text-gray-500 w-1/5 text-center">Stimme überhaupt<br>nicht zu</span>
<label class="block text-sm font-medium text-gray-700 mb-2">Alter *</label> <div class="flex-grow flex justify-around">
<input type="number" formControlName="age" min="13" max="120" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Ihr Alter"> <label *ngFor="let option of [1, 2, 3, 4, 5]" class="flex flex-col items-center cursor-pointer">
<div *ngIf="demographicsForm.get('age')?.errors?.['required'] && demographicsForm.get('age')?.touched" class="text-red-500 text-sm mt-1">Die Angabe des Alters ist erforderlich.</div> <input id="comfortHold-{{option}}" type="radio" formControlName="comfortHold" [value]="option" (change)="logInteraction($event)" class="mb-1 focus:ring-blue-500 text-blue-600">
</div> <span class="text-sm">{{ option }}</span>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Geschlecht *</label>
<select formControlName="gender" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="non-binary">Divers</option>
<option value="prefer-not-to-say">Keine Angabe</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Höchster Bildungsabschluss</label>
<select formControlName="education" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="high-school">Schulabschluss</option>
<option value="some-college">Berufsausbildung</option>
<option value="bachelors">Bachelor</option>
<option value="masters">Master</option>
<option value="doctorate">Promotion</option>
<option value="other">Andere</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Berufsfeld/Studienfach</label>
<input type="text" formControlName="occupation" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="z.B. Student, Ingenieur, ...">
</div>
</div>
</div>
<!-- Vorerfahrung mit AR/VR -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">2</span>
Vorerfahrung mit AR/VR
</h3>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Erfahrung mit Augmented Reality (AR) *<span class="text-gray-500"> (1=keine, 10=Experte)</span></label>
<div class="flex items-center space-x-2"><span class="text-sm text-gray-500">1</span><input type="range" formControlName="arExperience" min="1" max="10" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"><span class="text-sm text-gray-500">10</span></div>
<div class="text-center mt-1"><span class="text-sm font-medium text-blue-600">{{ demographicsForm.get('arExperience')?.value }}/10</span></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Erfahrung mit Virtual Reality (VR) *<span class="text-gray-500"> (1=keine, 10=Experte)</span></label>
<div class="flex items-center space-x-2"><span class="text-sm text-gray-500">1</span><input type="range" formControlName="vrExperience" min="1" max="10" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"><span class="text-sm text-gray-500">10</span></div>
<div class="text-center mt-1"><span class="text-sm font-medium text-blue-600">{{ demographicsForm.get('vrExperience')?.value }}/10</span></div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Bisher genutzte AR/VR-Geräte (Mehrfachauswahl möglich)</label>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<label *ngFor="let device of arVrDevices" class="flex items-center space-x-2">
<input type="checkbox" [value]="device.value" (change)="onDeviceChange($event)" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">{{ device.label }}</span>
</label> </label>
</div> </div>
<span class="text-xs text-gray-500 w-1/5 text-center">Stimme<br>voll zu</span>
</div> </div>
<div> </div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nutzungshäufigkeit von AR/VR</label>
<select formControlName="arVrFrequency" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> <!-- Frage 2: Ausdauer (5 Minuten) -->
<option value="" disabled>Bitte auswählen</option> <div class="p-4 border border-gray-200 rounded-lg">
<option value="never">Nie</option> <label id="label-enduranceFiveMinutes" class="block text-sm font-medium text-gray-800 mb-3">
<option value="rarely">Selten (wenige Male pro Jahr)</option> Ich glaube, ich könnte diese Position auch für 5 Minuten bequem halten. *
<option value="occasionally">Gelegentlich (monatlich)</option> </label>
<option value="regularly">Regelmäßig (wöchentlich)</option> <div class="flex justify-between items-center">
<option value="frequently">Häufig (täglich)</option> <span class="text-xs text-gray-500 w-1/5 text-center">Stimme überhaupt<br>nicht zu</span>
</select> <div class="flex-grow flex justify-around">
<label *ngFor="let option of [1, 2, 3, 4, 5]" class="flex flex-col items-center cursor-pointer">
<input id="enduranceFiveMinutes-{{option}}" type="radio" formControlName="enduranceFiveMinutes" [value]="option" (change)="logInteraction($event)" class="mb-1 focus:ring-blue-500 text-blue-600">
<span class="text-sm">{{ option }}</span>
</label>
</div>
<span class="text-xs text-gray-500 w-1/5 text-center">Stimme<br>voll zu</span>
</div>
</div>
<!-- Frage 3: Physische Anstrengung -->
<div class="p-4 border border-gray-200 rounded-lg">
<label id="label-physicalStrain" class="block text-sm font-medium text-gray-800 mb-3">
Das Halten des Geräts war körperlich anstrengend. *
</label>
<div class="flex justify-between items-center">
<span class="text-xs text-gray-500 w-1/5 text-center">Stimme überhaupt<br>nicht zu</span>
<div class="flex-grow flex justify-around">
<label *ngFor="let option of [1, 2, 3, 4, 5]" class="flex flex-col items-center cursor-pointer">
<input id="physicalStrain-{{option}}" type="radio" formControlName="physicalStrain" [value]="option" (change)="logInteraction($event)" class="mb-1 focus:ring-blue-500 text-blue-600">
<span class="text-sm">{{ option }}</span>
</label>
</div>
<span class="text-xs text-gray-500 w-1/5 text-center">Stimme<br>voll zu</span>
</div>
</div>
<div class="p-4 border border-gray-200 rounded-lg">
<label id="label-physicalStrain5min" class="block text-sm font-medium text-gray-800 mb-3">
Das Halten des Geräts für 5 Minuten wäre körperlich anstrengend. *
</label>
<div class="flex justify-between items-center">
<span class="text-xs text-gray-500 w-1/5 text-center">Stimme überhaupt<br>nicht zu</span>
<div class="flex-grow flex justify-around">
<label *ngFor="let option of [1, 2, 3, 4, 5]" class="flex flex-col items-center cursor-pointer">
<input id="physicalStrain5min-{{option}}" type="radio" formControlName="physicalStrain5min" [value]="option" (change)="logInteraction($event)" class="mb-1 focus:ring-blue-500 text-blue-600">
<span class="text-sm">{{ option }}</span>
</label>
</div>
<span class="text-xs text-gray-500 w-1/5 text-center">Stimme<br>voll zu</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Physische und visuelle Voraussetzungen -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">3</span>
Visuelle & Physische Angaben
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Benötigen Sie eine Sehhilfe?</label>
<select formControlName="visionCorrection" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="none">Nein, keine</option>
<option value="glasses">Brille</option>
<option value="contacts">Kontaktlinsen</option>
<option value="both">Brille und Kontaktlinsen</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Farbsehvermögen</label>
<select formControlName="colorVision" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="normal">Normales Farbsehen</option>
<option value="red-green">Rot-Grün-Sehschwäche</option>
<option value="blue-yellow">Blau-Gelb-Sehschwäche</option>
<option value="monochrome">Vollständige Farbenblindheit</option>
<option value="unsure">Nicht sicher</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Händigkeit</label>
<select formControlName="dominantHand" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="right">Rechtshänder</option>
<option value="left">Linkshänder</option>
<option value="ambidextrous">Beidhänder</option>
</select>
</div>
</div>
</div>
<!-- Technologiekompetenz -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">4</span>
Technologiekompetenz
</h3>
<div class="space-y-6">
<div *ngFor="let tech of technologyComfort" class="flex flex-col">
<label class="text-sm font-medium text-gray-700 mb-2">{{ tech.label }} (1=wenig, 10=sehr hoch)</label>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">1</span>
<input type="range" [formControlName]="tech.formControl" min="1" max="10" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<span class="text-sm text-gray-500">10</span>
<span class="text-sm font-medium text-blue-600 w-8 text-right">{{ demographicsForm.get(tech.formControl)?.value }}</span>
</div>
</div>
</div>
</div>
<!-- Feedback -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">5</span>
Feedback zur Studie
</h3>
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Benutzerfreundlichkeit der Anwendung *</label>
<div class="flex items-center space-x-2">
<span *ngFor="let star of [1,2,3,4,5]" (click)="setRating('easeOfUse', star)" class="cursor-pointer text-2xl transition-colors duration-200" [class.text-yellow-400]="star <= (demographicsForm.get('easeOfUse')?.value || 0)" [class.text-gray-300]="star > (demographicsForm.get('easeOfUse')?.value || 0)">★</span>
<span class="text-sm text-gray-600 ml-4">{{ getEaseText(demographicsForm.get('easeOfUse')?.value) }}</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Anmerkungen und Verbesserungsvorschläge</label>
<textarea formControlName="comments" rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Ihre Gedanken, Vorschläge oder aufgetretene Probleme..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Würden Sie diese AR-Anwendung weiterempfehlen?</label>
<div class="flex space-x-6">
<label class="flex items-center space-x-2"><input type="radio" formControlName="wouldRecommend" value="yes" class="text-green-500 focus:ring-green-500"><span class="text-sm text-gray-700">Ja</span></label>
<label class="flex items-center space-x-2"><input type="radio" formControlName="wouldRecommend" value="no" class="text-red-500 focus:ring-red-500"><span class="text-sm text-gray-700">Nein</span></label>
<label class="flex items-center space-x-2"><input type="radio" formControlName="wouldRecommend" value="maybe" class="text-yellow-500 focus:ring-yellow-500"><span class="text-sm text-gray-700">Vielleicht</span></label>
</div>
</div>
</div>
</div>
<!-- Absenden-Button -->
<div class="text-center">
<button type="submit" [disabled]="!demographicsForm.valid || isSubmitting" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg text-lg transition-colors duration-200">
<span *ngIf="!isSubmitting">Fragebogen absenden</span>
<span *ngIf="isSubmitting">Wird übermittelt...</span>
</button>
</div>
<div *ngIf="!demographicsForm.valid && demographicsForm.touched" class="text-center mt-2">
<p class="text-red-500 text-sm">Bitte füllen Sie alle mit * markierten Pflichtfelder aus.</p>
</div>
</form>
<!-- Erfolgsmeldung -->
<div *ngIf="isSubmitted" class="mt-8 p-4 bg-green-100 border border-green-400 text-green-700 rounded-md text-center">
<h4 class="font-semibold">Vielen Dank!</h4>
<p>Ihre Angaben wurden erfolgreich übermittelt. Die Studie ist hiermit abgeschlossen.</p>
</div> </div>
<!-- Demografische Daten -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">2</span>
Demografische Angaben
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="age-input" class="block text-sm font-medium text-gray-700 mb-2">Alter *</label>
<input id="age-input" type="number" formControlName="age" min="13" max="120" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Ihr Alter">
<div *ngIf="demographicsForm.get('age')?.errors?.['required'] && demographicsForm.get('age')?.touched" class="text-red-500 text-sm mt-1">Die Angabe des Alters ist erforderlich.</div>
</div>
<div>
<label for="gender-select" class="block text-sm font-medium text-gray-700 mb-2">Geschlecht *</label>
<select id="gender-select" formControlName="gender" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="non-binary">Divers</option>
<option value="prefer-not-to-say">Keine Angabe</option>
</select>
</div>
<div>
<label for="education-select" class="block text-sm font-medium text-gray-700 mb-2">Höchster Bildungsabschluss</label>
<select id="education-select" formControlName="education" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="high-school">Schulabschluss</option>
<option value="some-college">Berufsausbildung</option>
<option value="bachelors">Bachelor</option>
<option value="masters">Master</option>
<option value="doctorate">Promotion</option>
<option value="other">Andere</option>
</select>
</div>
<div>
<label for="occupation-input" class="block text-sm font-medium text-gray-700 mb-2">Berufsfeld/Studienfach</label>
<input id="occupation-input" type="text" formControlName="occupation" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="z.B. Student, Ingenieur, ...">
</div>
</div>
</div>
<!-- Vorerfahrung mit AR/VR -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">3</span>
Vorerfahrung mit AR/VR
</h3>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="ar-experience-slider" class="block text-sm font-medium text-gray-700 mb-2">Erfahrung mit Augmented Reality (AR) *<span class="text-gray-500"> (1=keine, 5=Experte)</span></label>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">1</span>
<input id="ar-experience-slider" type="range" formControlName="arExperience" min="1" max="5" (change)="logInteraction($event)" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<span class="text-sm text-gray-500">5</span>
</div>
<div class="text-center mt-1"><span class="text-sm font-medium text-blue-600">{{ demographicsForm.get('arExperience')?.value }}/5</span></div>
</div>
<div>
<label for="vr-experience-slider" class="block text-sm font-medium text-gray-700 mb-2">Erfahrung mit Virtual Reality (VR) *<span class="text-gray-500"> (1=keine, 5=Experte)</span></label>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">1</span>
<input id="vr-experience-slider" type="range" formControlName="vrExperience" min="1" max="5" (change)="logInteraction($event)" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<span class="text-sm text-gray-500">5</span>
</div>
<div class="text-center mt-1"><span class="text-sm font-medium text-blue-600">{{ demographicsForm.get('vrExperience')?.value }}/5</span></div>
</div>
</div>
</div>
</div>
<!-- Physische und visuelle Voraussetzungen -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">4</span>
Visuelle & Physische Angaben
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="vision-correction-select" class="block text-sm font-medium text-gray-700 mb-2">Benötigen Sie eine Sehhilfe?</label>
<select id="vision-correction-select" formControlName="visionCorrection" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="none">Nein, keine</option>
<option value="glasses">Brille</option>
<option value="contacts">Kontaktlinsen</option>
<option value="both">Brille und Kontaktlinsen</option>
</select>
</div>
<div>
<label for="dominant-hand-select" class="block text-sm font-medium text-gray-700 mb-2">Händigkeit</label>
<select id="dominant-hand-select" formControlName="dominantHand" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" disabled>Bitte auswählen</option>
<option value="right">Rechtshänder</option>
<option value="left">Linkshänder</option>
<option value="ambidextrous">Beidhänder</option>
</select>
</div>
</div>
</div>
<!-- System Usability Scale (SUS) -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">5</span>
Bewertung der Benutzerfreundlichkeit (SUS)
</h3>
<p class="text-sm text-gray-600 mb-6">Bitte bewerten Sie die folgende Anwendung anhand der nachstehenden Aussagen auf einer Skala von 1 (stimme überhaupt nicht zu) bis 5 (stimme voll zu).</p>
<div class="space-y-4">
<div *ngFor="let susItem of susItems; let i = index" class="p-4 border border-gray-200 rounded-lg">
<label [id]="'label-sus' + (i+1)" class="block text-sm font-medium text-gray-800 mb-3">{{ i + 1 }}. {{ susItem.label }} *</label>
<div class="flex justify-between items-center text-center">
<span class="text-xs text-gray-500 w-1/5">Stimme überhaupt<br>nicht zu</span>
<div class="flex-grow flex justify-around">
<label *ngFor="let option of [1, 2, 3, 4, 5]" class="flex flex-col items-center cursor-pointer">
<input [id]="'sus' + (i+1) + '-' + option" type="radio" [formControlName]="susItem.controlName" [value]="option" (change)="logInteraction($event)" class="mb-1 focus:ring-blue-500 text-blue-600">
<span class="text-sm">{{ option }}</span>
</label>
</div>
<span class="text-xs text-gray-500 w-1/5">Stimme<br>voll zu</span>
</div>
</div>
</div>
</div>
<!-- Abschließendes Feedback -->
<div class="mb-8">
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">6</span>
Abschließendes Feedback
</h3>
<div class="space-y-6">
<div>
<label for="comments-textarea" class="block text-sm font-medium text-gray-700 mb-2">Anmerkungen und Verbesserungsvorschläge</label>
<textarea id="comments-textarea" formControlName="comments" rows="4" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Ihre Gedanken, Vorschläge oder aufgetretene Probleme..."></textarea>
</div>
</div>
</div>
<!-- Absenden-Button -->
<div class="text-center">
<button id="submit-button" type="submit" (click)="logInteraction($event)" [disabled]="!demographicsForm.valid || isSubmitting" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg text-lg transition-colors duration-200">
<span *ngIf="!isSubmitting">Fragebogen absenden</span>
<span *ngIf="isSubmitting">Wird übermittelt...</span>
</button>
</div>
<div *ngIf="!demographicsForm.valid && demographicsForm.touched" class="text-center mt-2">
<p class="text-red-500 text-sm">Bitte füllen Sie alle mit * markierten Pflichtfelder aus.</p>
</div>
</form>
<!-- Erfolgsmeldung -->
<div *ngIf="isSubmitted" class="mt-8 p-4 bg-green-100 border border-green-400 text-green-700 rounded-md text-center">
<h4 class="font-semibold">Vielen Dank!</h4>
<p>Ihre Angaben wurden erfolgreich übermittelt. Die Studie ist hiermit abgeschlossen.</p>
</div> </div>
</div> </div>
</div>

View File

@ -1,6 +1,7 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, inject, OnInit, OnDestroy, Output } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
@Component({ @Component({
selector: 'app-demographics-feedback', selector: 'app-demographics-feedback',
@ -9,14 +10,29 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
templateUrl: './demographics-feedback.component.html', templateUrl: './demographics-feedback.component.html',
styleUrls: ['./demographics-feedback.component.css'] styleUrls: ['./demographics-feedback.component.css']
}) })
export class DemographicsFeedbackComponent implements OnInit { export class DemographicsFeedbackComponent implements OnInit, OnDestroy {
private metricsService = inject(MetricsTrackerService);
@Output() testComplete = new EventEmitter<void>(); @Output() testComplete = new EventEmitter<void>();
@Output() redoTest = new EventEmitter<number>(); @Output() redoTest = new EventEmitter<number>();
demographicsForm!: FormGroup; demographicsForm!: FormGroup;
isSubmitting = false; isSubmitting = false;
isSubmitted = false; isSubmitted = false;
susItems = [
{ label: 'Ich denke, dass ich dieses System gerne regelmäßig nutzen würde.', controlName: 'sus1' },
{ label: 'Ich empfinde das System als unnötig komplex.', controlName: 'sus2' },
{ label: 'Ich empfinde das System als einfach zu nutzen.', controlName: 'sus3' },
{ label: 'Ich denke, dass ich technischen Support brauchen würde, um das System zu nutzen.', controlName: 'sus4' },
{ label: 'Ich finde, dass die verschiedenen Funktionen des Systems gut integriert sind.', controlName: 'sus5' },
{ label: 'Ich finde, dass es im System zu viele Inkonsistenzen gibt.', controlName: 'sus6' },
{ label: 'Ich kann mir vorstellen, dass die meisten Leute das System schnell zu beherrschen lernen.', controlName: 'sus7' },
{ label: 'Ich empfinde die Bedienung als sehr umständlich.', controlName: 'sus8' },
{ label: 'Ich habe mich bei der Nutzung des Systems sehr sicher gefühlt.', controlName: 'sus9' },
{ label: 'Ich musste eine Menge Dinge lernen, bevor ich mit dem System arbeiten konnte.', controlName: 'sus10' }
];
arVrDevices = [ arVrDevices = [
{ value: 'smartphone-ar', label: 'Smartphone-AR' }, { value: 'smartphone-ar', label: 'Smartphone-AR' },
{ value: 'meta-quest', label: 'Meta Quest/Oculus' }, { value: 'meta-quest', label: 'Meta Quest/Oculus' },
@ -37,41 +53,56 @@ export class DemographicsFeedbackComponent implements OnInit {
selectedDevices: string[] = []; selectedDevices: string[] = [];
constructor(private formBuilder: FormBuilder) {} constructor(private formBuilder: FormBuilder) {
this.logInteraction = this.logInteraction.bind(this);
}
ngOnInit() { ngOnInit() {
this.createForm(); this.createForm();
// Start device orientation tracking for this component
this.metricsService.startDeviceOrientationTracking();
}
logInteraction(event: Event) {
this.metricsService.logInteraction(event);
} }
createForm() { createForm() {
this.demographicsForm = this.formBuilder.group({ this.demographicsForm = this.formBuilder.group({
// Demografische Daten comfortHold: ['', Validators.required],
enduranceFiveMinutes: ['', Validators.required],
physicalStrain: ['', Validators.required],
physicalStrain5min: ['', Validators.required],
age: ['', [Validators.required, Validators.min(13), Validators.max(120)]], age: ['', [Validators.required, Validators.min(13), Validators.max(120)]],
gender: ['', Validators.required], gender: ['', Validators.required],
education: [''], education: [''],
occupation: [''], occupation: [''],
// AR/VR Vorerfahrung
arExperience: [1, Validators.required], arExperience: [1, Validators.required],
vrExperience: [1, Validators.required], vrExperience: [1, Validators.required],
arVrFrequency: [''], arVrFrequency: [''],
// Physische und visuelle Einschränkungen
visionCorrection: [''], visionCorrection: [''],
colorVision: [''],
dominantHand: [''], dominantHand: [''],
mobilityLimitations: [''],
smartphoneComfort: [3, Validators.required],
techComfort: [3, Validators.required],
gamingExperience: [3, Validators.required],
// Technologiekompetenz sus1: ['', Validators.required],
smartphoneComfort: [5], sus2: ['', Validators.required],
techComfort: [5], sus3: ['', Validators.required],
gamingExperience: [5], sus4: ['', Validators.required],
sus5: ['', Validators.required],
sus6: ['', Validators.required],
sus7: ['', Validators.required],
sus8: ['', Validators.required],
sus9: ['', Validators.required],
sus10: ['', Validators.required],
// Feedback zur Studie
overallRating: [0, [Validators.required, Validators.min(1)]],
easeOfUse: [0, [Validators.required, Validators.min(1)]],
comments: [''], comments: [''],
wouldRecommend: ['', Validators.required]
}); });
} }
@ -83,21 +114,7 @@ export class DemographicsFeedbackComponent implements OnInit {
this.selectedDevices = this.selectedDevices.filter(device => device !== value); this.selectedDevices = this.selectedDevices.filter(device => device !== value);
} }
} }
setRating(controlName: string, rating: number) {
this.demographicsForm.get(controlName)?.setValue(rating);
}
getRatingText(rating: number): string {
const ratings = ['', 'Mangelhaft', 'Ausreichend', 'Gut', 'Sehr Gut', 'Exzellent'];
return ratings[rating] || '';
}
getEaseText(rating: number): string {
const ratings = ['', 'Sehr schwierig', 'Schwierig', 'Neutral', 'Einfach', 'Sehr einfach'];
return ratings[rating] || '';
}
onSubmit() { onSubmit() {
if (this.demographicsForm.valid) { if (this.demographicsForm.valid) {
this.isSubmitting = true; this.isSubmitting = true;
@ -105,28 +122,57 @@ export class DemographicsFeedbackComponent implements OnInit {
const formData = { const formData = {
...this.demographicsForm.value, ...this.demographicsForm.value,
devicesUsed: this.selectedDevices, devicesUsed: this.selectedDevices,
susScore: this.calculateSusScore(this.demographicsForm.value),
submittedAt: new Date().toISOString() submittedAt: new Date().toISOString()
}; };
console.log('Demographics and Feedback Data:', formData); this.metricsService.sendMetricsToServer('FullTestSuite', formData).subscribe({
next: () => {
this.isSubmitting = false;
this.isSubmitted = true;
// Optionally delay before emitting final completion
setTimeout(() => this.testComplete.emit(), 2000);
},
error: (err) => {
console.error("FINAL SUBMISSION FAILED:", err);
this.isSubmitting = false;
}
});
setTimeout(() => {
this.isSubmitting = false;
this.isSubmitted = true;
this.testComplete.emit();
}, 2000);
} else { } else {
Object.keys(this.demographicsForm.controls).forEach(key => { Object.keys(this.demographicsForm.controls).forEach(key => {
this.demographicsForm.get(key)?.markAsTouched(); this.demographicsForm.get(key)?.markAsTouched();
}); });
console.error("Form is invalid. Please fill out all required fields.");
} }
} }
private calculateSusScore(formData: any): number {
let score = 0;
score += (formData.sus1 - 1);
score += (formData.sus3 - 1);
score += (formData.sus5 - 1);
score += (formData.sus7 - 1);
score += (formData.sus9 - 1);
score += (5 - formData.sus2);
score += (5 - formData.sus4);
score += (5 - formData.sus6);
score += (5 - formData.sus8);
score += (5 - formData.sus10);
return score * 2.5;
}
finishAssessment() { finishAssessment() {
this.testComplete.emit(); this.testComplete.emit();
} }
retryCurrent() { retryCurrent() {
this.redoTest.emit(); this.redoTest.emit(4);
}
ngOnDestroy() {
this.metricsService.cleanup();
} }
} }

View File

@ -7,10 +7,13 @@
ar-modes="webxr" ar-modes="webxr"
ar-placement="ceiling" ar-placement="ceiling"
reveal="manual" reveal="manual"
[scale]="scale + ' ' + scale + ' ' + scale"
camera-orbit="0deg 75deg 2m"> camera-orbit="0deg 75deg 2m">
<button <button
slot="ar-button" id="ar-button-position"
slot="ar-button"
(click)="logInteraction($event)"
class="absolute top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white py-2 px-4 rounded-lg z-10"> class="absolute top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white py-2 px-4 rounded-lg z-10">
WebXR Umgebung laden WebXR Umgebung laden
</button> </button>
@ -18,11 +21,19 @@
<div <div
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 bg-black bg-opacity-60 p-5 rounded-xl flex flex-col items-center space-y-4 text-white z-10" class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 bg-black bg-opacity-60 p-5 rounded-xl flex flex-col items-center space-y-4 text-white z-10"
[class.opacity-50]="!isModelPlaced"> [class.opacity-50]="!isModelPlaced">
<!-- Slider 1: Scale --> <!-- Slider 1: Scale -->
<div class="flex flex-col items-start w-64"> <div class="flex flex-col items-start w-64">
<label for="scale-slider" class="text-sm font-medium mb-1">Skalierung</label> <label for="scale-slider" class="text-sm font-medium mb-1">Skalierung</label>
<input id="scale-slider" type="range" min="0.5" max="2.5" step="0.01" [(ngModel)]="scale" <input
(input)="onSliderInput()" [disabled]="!isModelPlaced" id="scale-slider"
type="range"
min="0.5"
max="2.5"
step="0.01"
[(ngModel)]="scale"
(input)="onSliderInput(); logInteraction($event)"
[disabled]="!isModelPlaced"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" /> class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
<span class="text-xs mt-1">{{ scale.toFixed(2) }}×</span> <span class="text-xs mt-1">{{ scale.toFixed(2) }}×</span>
</div> </div>
@ -30,19 +41,32 @@
<!-- Slider 2: Height --> <!-- Slider 2: Height -->
<div class="flex flex-col items-start w-64"> <div class="flex flex-col items-start w-64">
<label for="offset-slider" class="text-sm font-medium mb-1">Höhe verschieben</label> <label for="offset-slider" class="text-sm font-medium mb-1">Höhe verschieben</label>
<input id="offset-slider" type="range" min="-1.5" max="3.5" step="0.01" [(ngModel)]="verticalOffset" <input
(input)="onSliderInput()" [disabled]="!isModelPlaced" id="offset-slider"
type="range"
min="-1.5"
max="3.5"
step="0.01"
[(ngModel)]="verticalOffset"
(input)="onSliderInput(); logInteraction($event)"
[disabled]="!isModelPlaced"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" /> class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
<span class="text-xs mt-1">{{ verticalOffset >= 0 ? '+' : '' }}{{ verticalOffset.toFixed(2) }}m</span> <span class="text-xs mt-1">{{ verticalOffset >= 0 ? '+' : '' }}{{ verticalOffset.toFixed(2) }}m</span>
</div> </div>
<!-- Buttons Row --> <!-- Buttons Row -->
<div class="flex space-x-4 justify-center"> <div class="flex space-x-4 justify-center">
<button (click)="resetPosition()" [disabled]="!isModelPlaced" <button
id="reset-position-btn"
(click)="resetPosition(); logInteraction($event)"
[disabled]="!isModelPlaced"
class="bg-gray-600 text-white py-2 px-4 rounded-lg disabled:opacity-50"> class="bg-gray-600 text-white py-2 px-4 rounded-lg disabled:opacity-50">
Reset Reset
</button> </button>
<button (click)="confirmPlacement()" [disabled]="!isModelPlaced" <button
id="confirm-placement-btn"
(click)="confirmPlacement(); logInteraction($event)"
[disabled]="!isModelPlaced"
class="bg-green-600 text-white py-2 px-4 rounded-lg disabled:opacity-50"> class="bg-green-600 text-white py-2 px-4 rounded-lg disabled:opacity-50">
Position bestätigen Position bestätigen
</button> </button>

View File

@ -1,16 +1,18 @@
import { import {
Component, Component,
AfterViewInit, AfterViewInit,
OnDestroy,
ViewChild, ViewChild,
ElementRef, ElementRef,
ChangeDetectorRef, ChangeDetectorRef,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
EventEmitter, EventEmitter,
Output Output,
inject
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
import '../../../../../assets/scripts/model-viewer'; import '../../../../../assets/scripts/model-viewer';
@Component({ @Component({
@ -21,21 +23,26 @@ import '../../../../../assets/scripts/model-viewer';
styleUrls: ['./spatial-position-assessment.component.css'], styleUrls: ['./spatial-position-assessment.component.css'],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class SpatialPositionAssessmentComponent implements AfterViewInit { export class SpatialPositionAssessmentComponent implements AfterViewInit, OnDestroy { // <-- Implement OnDestroy
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>; @ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
@Output() testComplete = new EventEmitter<void>(); @Output() testComplete = new EventEmitter<void>();
@Output() redoTest = new EventEmitter<number>(); @Output() redoTest = new EventEmitter<number>();
private metricsService = inject(MetricsTrackerService);
public scale = 1; public scale = 1;
public verticalOffset = 0; public verticalOffset = 0;
protected isModelPlaced = false; protected isModelPlaced = false;
private initialArAnchor: { x: number; y: number; z: number } | null = null; private initialArAnchor: { x: number; y: number; z: number } | null = null;
constructor(private cdr: ChangeDetectorRef) {} constructor(private cdr: ChangeDetectorRef) {
this.logInteraction = this.logInteraction.bind(this);
}
ngAfterViewInit() { ngAfterViewInit() {
const mv = this.modelViewerRef.nativeElement; const mv = this.modelViewerRef.nativeElement;
this.metricsService.startDeviceOrientationTracking();
this.metricsService.startArTracking(mv);
mv.addEventListener('ar-status', (e: any) => { mv.addEventListener('ar-status', (e: any) => {
if (e.detail.status === 'session-started') { if (e.detail.status === 'session-started') {
setTimeout(() => this.captureAnchor(), 500); setTimeout(() => this.captureAnchor(), 500);
@ -43,6 +50,10 @@ export class SpatialPositionAssessmentComponent implements AfterViewInit {
}); });
} }
public logInteraction(event: Event) {
this.metricsService.logInteraction(event);
}
private async captureAnchor() { private async captureAnchor() {
const mv = this.modelViewerRef.nativeElement; const mv = this.modelViewerRef.nativeElement;
const anchor = mv.getAnchor(); const anchor = mv.getAnchor();
@ -70,10 +81,31 @@ export class SpatialPositionAssessmentComponent implements AfterViewInit {
} }
confirmPlacement() { confirmPlacement() {
const finalPlacement = {
finalScale: this.scale,
finalVerticalOffset: this.verticalOffset,
initialAnchor: this.initialArAnchor,
finalAnchor: this.modelViewerRef.nativeElement.getAttribute('ar-anchor')
};
this.metricsService.logInteraction(new CustomEvent('test-results', {
detail: {
testName: 'SpatialPositionAssessment',
results: finalPlacement
}
}));
console.log('Spatial position results captured locally.');
this.testComplete.emit(); this.testComplete.emit();
} }
retryCurrent() { retryCurrent() {
this.redoTest.emit(1); this.redoTest.emit(1);
} }
ngOnDestroy() {
this.metricsService.cleanup();
}
} }

View File

@ -1,122 +1,121 @@
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<model-viewer <model-viewer
#modelViewer #modelViewer
class="absolute inset-0 w-full h-full" class="absolute inset-0 w-full h-full"
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb" src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
ar ar
ar-modes="webxr" ar-modes="webxr"
ar-placement="ceiling" ar-placement="ceiling"
reveal="manual" reveal="manual"
camera-orbit="0deg 75deg 2m"> camera-orbit="0deg 75deg 2m">
<button <button
slot="ar-button" id="ar-button-stability"
class="absolute top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white py-2 px-4 rounded-lg z-10"> slot="ar-button"
WebXR Umgebung laden (click)="logInteraction($event)"
</button> class="absolute top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white py-2 px-4 rounded-lg z-10">
WebXR Umgebung laden
</button>
<!-- Adjustment Phase Controls --> <!-- Adjustment Phase Controls -->
<div <div *ngIf="currentPhase === 1 && isModelPlaced"
*ngIf="currentPhase === 1 && isModelPlaced" class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-6 bg-black bg-opacity-70 p-6 rounded-xl">
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-6 bg-black bg-opacity-70 p-6 rounded-xl">
<div class="text-white text-center mb-4">
<div class="text-white text-center mb-4"> <h4 id="adjust-phase-title" class="text-lg font-semibold">Finden sie ihre Optionale Betrachtungsposition</h4>
<h4 class="text-lg font-semibold">Finden sie ihre Optionale Betrachtungsposition</h4> </div>
</div>
<div class="flex flex-col items-center text-white w-64">
<div class="flex flex-col items-center text-white w-64"> <label for="stability-offset-slider" class="text-sm font-medium mb-2">Höhe verschieben</label>
<label for="offset-slider" class="text-sm font-medium mb-2">Höhe verschieben</label> <input
<input id="stability-offset-slider"
id="offset-slider" type="range"
type="range" min="-1.5"
min="-1.5" max="1.5"
max="1.5" step="0.01"
step="0.01" [(ngModel)]="verticalOffset"
[(ngModel)]="verticalOffset" (input)="onSliderInput(); logInteraction($event)"
(input)="onSliderInput()" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" /> <span class="text-xs mt-1">{{ verticalOffset >= 0 ? '+' : '' }}{{ verticalOffset.toFixed(2) }}m</span>
<span class="text-xs mt-1">{{ verticalOffset >= 0 ? '+' : '' }}{{ verticalOffset.toFixed(2) }}m</span> </div>
</div>
<button
id="lock-position-btn"
(click)="lockPosition(); logInteraction($event)"
class="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg text-lg">
🔒 Diese Position fixieren.
</button>
</div>
<!-- Lock Confirmation Phase -->
<div *ngIf="currentPhase === 2"
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-yellow-600 bg-opacity-90 p-6 rounded-xl">
<div class="text-white text-center">
<h4 id="lock-confirm-title" class="text-xl font-bold">Position fixiert!</h4>
<p class="text-xs mt-2 opacity-80">Halten Sie das Modell für die gesamte Dauer in möglichst genau dieser Position.</p>
</div>
<div class="flex space-x-4">
<button <button
(click)="lockPosition()" id="adjust-more-btn"
class="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg text-lg"> (click)="adjustMore(); logInteraction($event)"
🔒 Diese Position fixieren. class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg">
Anpassen
</button>
<button
id="start-countdown-btn"
(click)="startCountdown(); logInteraction($event)"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-6 rounded-lg">
20s Timer starten
</button> </button>
</div> </div>
</div>
<!-- Lock Confirmation Phase -->
<div <!-- Countdown Phase -->
*ngIf="currentPhase === 2" <div *ngIf="currentPhase === 3"
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-yellow-600 bg-opacity-90 p-6 rounded-xl"> class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-red-600 bg-opacity-90 p-6 rounded-xl">
<div class="text-white text-center"> <div class="text-white text-center">
<h4 class="text-xl font-bold">Position fixiert!</h4> <h4 id="countdown-timer" class="text-3xl font-bold">{{ remainingTime }}</h4>
<p class="text-xs mt-2 opacity-80">Halten Sie das Modell für die gesamte Dauer in möglichst genau dieser Position.</p> <p class="text-lg">Sekunden verbleibend</p>
</div> <p class="text-sm mt-2 opacity-80">Halten Sie das Modell in Position.</p>
<div class="flex space-x-4">
<button
(click)="adjustMore()"
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg">
Anpassen
</button>
<button
(click)="startCountdown()"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-6 rounded-lg">
20s Timer starten
</button>
</div>
</div> </div>
<!-- Countdown Phase --> <div class="w-64 h-4 bg-white bg-opacity-30 rounded-full overflow-hidden">
<div <div class="h-full bg-white transition-all duration-1000 ease-linear" [style.width.%]="progressPercentage"></div>
*ngIf="currentPhase === 3" </div>
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-red-600 bg-opacity-90 p-6 rounded-xl">
<button
<div class="text-white text-center"> id="cancel-countdown-btn"
<h4 class="text-3xl font-bold">{{ remainingTime }}</h4> (click)="cancelTest(); logInteraction($event)"
<p class="text-lg">Sekunden verbleibend</p> class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg text-sm">
<p class="text-sm mt-2 opacity-80">Halten Sie das Modell in Position.</p> Cancel Test
</div> </button>
</div>
<!-- Progress bar -->
<div class="w-64 h-4 bg-white bg-opacity-30 rounded-full overflow-hidden"> <!-- Completion Phase -->
<div <div *ngIf="currentPhase === 4"
class="h-full bg-white transition-all duration-1000 ease-linear" class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-green-600 bg-opacity-90 p-6 rounded-xl">
[style.width.%]="progressPercentage">
</div> <div class="text-white text-center">
</div> <h4 id="completion-title" class="text-2xl font-bold"> Test abgeschlossen</h4>
</div>
<div class="flex space-x-4">
<button <button
(click)="cancelTest()" id="restart-test-btn"
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg text-sm"> (click)="restartTest(); logInteraction($event)"
Cancel Test class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg">
Test erneut ausführen
</button>
<button
id="finish-assessment-btn"
(click)="finishAssessment(); logInteraction($event)"
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg">
Beenden
</button> </button>
</div> </div>
</div>
<!-- Completion Phase --> </model-viewer>
<div </div>
*ngIf="currentPhase === 4"
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-green-600 bg-opacity-90 p-6 rounded-xl">
<div class="text-white text-center">
<h4 class="text-2xl font-bold"> Test abgeschlossen</h4>
</div>
<div class="flex space-x-4">
<button
(click)="restartTest()"
class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg">
Test erneut ausführen
</button>
<button
(click)="finishAssessment()"
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg">
Beenden
</button>
</div>
</div>
</model-viewer>
</div>

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RotationSpeedAssessmentComponent } from './spatial-stability-assessment.component'; import { SpatialStabilityAssessmentComponent } from './spatial-stability-assessment.component';
describe('RotationSpeedAssessmentComponent', () => { describe('RotationSpeedAssessmentComponent', () => {
let component: RotationSpeedAssessmentComponent; let component: SpatialStabilityAssessmentComponent;
let fixture: ComponentFixture<RotationSpeedAssessmentComponent>; let fixture: ComponentFixture<SpatialStabilityAssessmentComponent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [RotationSpeedAssessmentComponent] imports: [SpatialStabilityAssessmentComponent]
}) })
.compileComponents(); .compileComponents();
fixture = TestBed.createComponent(RotationSpeedAssessmentComponent); fixture = TestBed.createComponent(SpatialStabilityAssessmentComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@ -1,6 +1,18 @@
import { Component, AfterViewInit, ViewChild, ElementRef, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectorRef, EventEmitter, Output } from '@angular/core'; import {
Component,
AfterViewInit,
OnDestroy,
ViewChild,
ElementRef,
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectorRef,
EventEmitter,
Output,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
import '../../../../../assets/scripts/model-viewer'; import '../../../../../assets/scripts/model-viewer';
@ -12,44 +24,53 @@ import '../../../../../assets/scripts/model-viewer';
styleUrls: ['./spatial-stability-assessment.component.css'], styleUrls: ['./spatial-stability-assessment.component.css'],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class SpatialStabilityAssessmentComponent implements AfterViewInit { export class SpatialStabilityAssessmentComponent implements AfterViewInit, OnDestroy { // <-- Implement OnDestroy
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>; @ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
@Output() testComplete = new EventEmitter<void>(); @Output() testComplete = new EventEmitter<void>();
@Output() redoTest = new EventEmitter<number>(); @Output() redoTest = new EventEmitter<number>();
private metricsService = inject(MetricsTrackerService);
public scale = 1;
public verticalOffset = 0; public verticalOffset = 0;
public currentPhase = 0; // 0: waiting, 1: adjust, 2: lock, 3: countdown, 4: complete public currentPhase = 0; // 0: waiting, 1: adjust, 2: lock, 3: countdown, 4: complete
public remainingTime = 20; public remainingTime = 20;
public progressPercentage = 0; public progressPercentage = 0;
private initialArAnchor: { x: number, y: number, z: number } | null = null; private initialArAnchor: { x: number, y: number, z: number } | null = null;
private lockedPosition: { scale: number, verticalOffset: number } | null = null; private lockedPosition: { verticalOffset: number, anchor: string } | null = null;
private countdownInterval: any = null; private countdownInterval: any = null;
protected isModelPlaced = false; protected isModelPlaced = false;
constructor(private cdr: ChangeDetectorRef) {} constructor(private cdr: ChangeDetectorRef) {
this.logInteraction = this.logInteraction.bind(this);
}
ngAfterViewInit() { ngAfterViewInit() {
const modelViewer = this.modelViewerRef.nativeElement; const modelViewer = this.modelViewerRef.nativeElement;
this.metricsService.startDeviceOrientationTracking();
this.metricsService.startArTracking(modelViewer);
modelViewer.addEventListener('ar-status', (event: any) => { modelViewer.addEventListener('ar-status', (event: any) => {
if (event.detail.status === 'session-started') { if (event.detail.status === 'session-started' && !this.isModelPlaced) {
setTimeout(() => this.getInitialAnchor(), 1000); setTimeout(() => this.getInitialAnchor(), 1000);
} }
}); });
} }
public logInteraction(event: Event) {
this.metricsService.logInteraction(event);
}
private async getInitialAnchor() { private async getInitialAnchor() {
const modelViewer = this.modelViewerRef.nativeElement; const modelViewer = this.modelViewerRef.nativeElement;
const anchorString = modelViewer.getAnchor(); const anchorString = modelViewer.getAnchor();
if (!anchorString.includes('not placed')) { if (!anchorString.includes('not placed')) {
const coords = anchorString.split(' ').map(parseFloat); const coords = anchorString.split(' ').map(parseFloat);
this.initialArAnchor = { x: coords[0], y: coords[1], z: coords[2] }; this.initialArAnchor = { x: coords[0], y: coords[1], z: coords[2] };
this.isModelPlaced = true; this.isModelPlaced = true;
this.currentPhase = 1; this.currentPhase = 1;
console.log('Starting adjustment phase'); this.cdr.detectChanges();
} else { } else {
setTimeout(() => this.getInitialAnchor(), 500); setTimeout(() => this.getInitialAnchor(), 500);
} }
@ -65,11 +86,10 @@ export class SpatialStabilityAssessmentComponent implements AfterViewInit {
lockPosition() { lockPosition() {
this.lockedPosition = { this.lockedPosition = {
scale: this.scale, verticalOffset: this.verticalOffset,
verticalOffset: this.verticalOffset anchor: this.modelViewerRef.nativeElement.getAttribute('ar-anchor')
}; };
this.currentPhase = 2; this.currentPhase = 2;
console.log('Position locked:', this.lockedPosition);
} }
adjustMore() { adjustMore() {
@ -86,13 +106,10 @@ export class SpatialStabilityAssessmentComponent implements AfterViewInit {
this.progressPercentage = ((20 - this.remainingTime) / 20) * 100; this.progressPercentage = ((20 - this.remainingTime) / 20) * 100;
if (this.remainingTime <= 0) { if (this.remainingTime <= 0) {
this.completeTest(); this.completeTest(true);
} }
this.cdr.detectChanges(); this.cdr.detectChanges();
}, 1000); }, 1000);
console.log('Countdown started');
} }
cancelTest() { cancelTest() {
@ -101,40 +118,57 @@ export class SpatialStabilityAssessmentComponent implements AfterViewInit {
this.countdownInterval = null; this.countdownInterval = null;
} }
this.currentPhase = 2; this.currentPhase = 2;
this.metricsService.logInteraction(new CustomEvent('test-cancelled', { detail: { phase: 'countdown' }}));
} }
private completeTest() { private completeTest(wasCompleted: boolean) {
if (this.countdownInterval) { if (this.countdownInterval) {
clearInterval(this.countdownInterval); clearInterval(this.countdownInterval);
this.countdownInterval = null; this.countdownInterval = null;
} }
this.currentPhase = 4; // Complete phase
console.log('Test completed successfully!'); const finalResults = {
testCompletedSuccessfully: wasCompleted,
initialAnchor: this.initialArAnchor,
lockedPosition: this.lockedPosition,
};
this.metricsService.logInteraction(new CustomEvent('test-results', {
detail: {
testName: 'SpatialStability',
results: finalResults
}
}));
if (wasCompleted) {
this.currentPhase = 4;
}
if(wasCompleted) {
this.testComplete.emit();
}
} }
restartTest() { restartTest() {
this.currentPhase = 1; this.currentPhase = 1;
this.remainingTime = 60; this.remainingTime = 20;
this.progressPercentage = 0; this.progressPercentage = 0;
this.lockedPosition = null; this.lockedPosition = null;
if (this.countdownInterval) { if (this.countdownInterval) {
clearInterval(this.countdownInterval); clearInterval(this.countdownInterval);
this.countdownInterval = null; this.countdownInterval = null;
} }
} this.metricsService.resetMetrics();
ngOnDestroy() {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
} }
finishAssessment() { finishAssessment() {
this.testComplete.emit(); this.testComplete.emit();
} }
retryCurrent() { ngOnDestroy() {
this.redoTest.emit(1); if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
this.metricsService.cleanup();
} }
} }

View File

@ -1,71 +1,92 @@
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<model-viewer <model-viewer #modelViewer class="absolute inset-0 w-full h-full"
#modelViewer src="https://modelviewer.dev/shared-assets/models/Astronaut.glb" ar ar-modes="webxr" ar-placement="ceiling"
class="absolute inset-0 w-full h-full" reveal="manual" camera-orbit="0deg 75deg 2m">
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
ar
ar-modes="webxr"
ar-placement="ceiling"
reveal="manual"
camera-orbit="0deg 75deg 2m">
<button <button slot="ar-button" id="ar-button-legibility" (click)="logInteraction($event)"
slot="ar-button"
class="text-xl absolute top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white py-2 px-4 rounded-lg z-10"> class="text-xl absolute top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white py-2 px-4 rounded-lg z-10">
WebXR Umgebung laden WebXR Umgebung laden
</button> </button>
<button slot="hotspot-text" class="hotspot ar-hotspot" data-position="-0.1 0.93 0.1" data-normal="0 1 0"> <button slot="hotspot-text" class="hotspot ar-hotspot" data-position="-0.1 0.93 0.1" data-normal="0 1 0">
<div class="annotation" [ngStyle]="{'font-size.px': currentSize}" <div class="annotation" [ngStyle]="{'font-size.px': currentSize}"
style="background:rgba(255,255,255,0.9);padding:8px;border-radius:4px;color:black;"> style="background:rgba(255,255,255,0.9);padding:8px;border-radius:4px;color:black;">
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet...
</div> </div>
</button> </button>
<div class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 bg-black bg-opacity-70 p-4 rounded-xl space-y-4 text-white z-10">
<div
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 bg-black bg-opacity-70 p-4 rounded-xl space-y-4 text-white z-10">
<!-- Phase: Min --> <!-- Phase: Min -->
<ng-container *ngIf="phase==='min'"> <ng-container *ngIf="phase==='min'">
<div>Minimum setzen</div> <div>Minimum setzen</div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<button (click)="decrease()" class="w-10 h-10 bg-gray-600 rounded-full"></button> <button id="min-decrease-btn" (click)="decrease(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span> <span>{{ currentSize }}px</span>
<button (click)="increase()" class="w-10 h-10 bg-gray-600 rounded-full">+</button> <button id="min-increase-btn" (click)="increase(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full">+</button>
</div> </div>
<button (click)="nextPhase()" class="bg-green-600 py-2 px-6 rounded-lg">Minimum bestätigen</button> <button id="min-confirm-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-green-600 py-2 px-6 rounded-lg">Minimum bestätigen</button>
</ng-container> </ng-container>
<!-- Phase: Confirmed Min --> <!-- Phase: Confirmed Min -->
<ng-container *ngIf="phase==='confirmedMin'"> <ng-container *ngIf="phase==='confirmedMin'">
<div>Minimum bestätigt: {{ currentSize }}px</div> <div>Minimum bestätigt: {{ minSizeResult }}px</div>
<button (click)="resetToMid()" class="bg-gray-600 py-2 px-4 rounded-lg">Reset</button> <button id="confirmed-min-reset-btn" (click)="resetToMid(); logInteraction($event)"
<button (click)="nextPhase()" class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu Maximum</button> class="bg-gray-600 py-2 px-4 rounded-lg">Reset</button>
<button id="confirmed-min-continue-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu Maximum</button>
</ng-container> </ng-container>
<!-- Phase: Max --> <!-- Phase: Max -->
<ng-container *ngIf="phase==='max'"> <ng-container *ngIf="phase==='max'">
<div>Maximum setzen</div> <div>Maximum setzen</div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<button (click)="decrease()" class="w-10 h-10 bg-gray-600 rounded-full"></button> <button id="max-decrease-btn" (click)="decrease(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span> <span>{{ currentSize }}px</span>
<button (click)="increase()" class="w-10 h-10 bg-gray-600 rounded-full">+</button> <button id="max-increase-btn" (click)="increase(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full">+</button>
</div> </div>
<button (click)="nextPhase()" class="bg-green-600 py-2 px-6 rounded-lg">Maximum bestätigen</button> <button id="max-confirm-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-green-600 py-2 px-6 rounded-lg">Maximum bestätigen</button>
</ng-container> </ng-container>
<!-- Phase: Confirmed Max --> <!-- Phase: Confirmed Max -->
<ng-container *ngIf="phase==='confirmedMax'"> <ng-container *ngIf="phase==='confirmedMax'">
<div>Maximum bestätigt: {{ currentSize }}px</div> <div>Maximum bestätigt: {{ maxSizeResult }}px</div>
<button (click)="nextPhase()" class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu optiomal</button> <button id="confirmed-max-continue-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu optiomal</button>
</ng-container> </ng-container>
<!-- Phase: Comfortable --> <!-- Phase: Comfortable -->
<ng-container *ngIf="phase==='comfortable'"> <ng-container *ngIf="phase==='comfortable'">
<div>Optimale Größe setzen</div> <div>Optimale Größe setzen</div>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<button (click)="decrease()" class="w-10 h-10 bg-gray-600 rounded-full"></button> <button id="comfort-decrease-btn" (click)="decrease(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span> <span>{{ currentSize }}px</span>
<button (click)="increase()" class="w-10 h-10 bg-gray-600 rounded-full">+</button> <button id="comfort-increase-btn" (click)="increase(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full">+</button>
</div> </div>
<button (click)="finishAssessment()" class="bg-green-600 py-2 px-6 rounded-lg">Optimale Größe setzen</button> <button id="comfort-confirm-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-green-600 py-2 px-6 rounded-lg">Optimale Größe setzen</button>
</ng-container>
<!-- Phase: Confirmed Comfortable -->
<ng-container *ngIf="phase==='confirmedComfort'">
<div>Optimale Größe bestätigt: {{ comfortableSizeResult }}px</div>
<button id="finish-assessment-btn" (click)="finishAssessment(); logInteraction($event)"
class="bg-blue-600 py-2 px-6 rounded-lg">Test beenden</button>
</ng-container>
<!-- Phase: Finished -->
<ng-container *ngIf="phase==='finished'">
<div class="font-semibold text-lg">Vielen Dank!</div>
<p>Ergebnisse werden für die finale Übermittlung gespeichert...</p>
</ng-container> </ng-container>
</div> </div>

View File

@ -1,16 +1,18 @@
import { import {
Component, Component,
AfterViewInit, AfterViewInit,
OnDestroy,
ViewChild, ViewChild,
ElementRef, ElementRef,
ChangeDetectorRef, ChangeDetectorRef,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
EventEmitter, EventEmitter,
Output Output,
inject
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
import '../../../../../assets/scripts/model-viewer'; import '../../../../../assets/scripts/model-viewer';
@Component({ @Component({
@ -21,97 +23,80 @@ import '../../../../../assets/scripts/model-viewer';
styleUrls: ['./text-legibility-assessment.component.css'], styleUrls: ['./text-legibility-assessment.component.css'],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class TextLegibilityAssessmentComponent implements AfterViewInit { export class TextLegibilityAssessmentComponent implements AfterViewInit, OnDestroy {
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>; @ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
@Output() testComplete = new EventEmitter<void>(); @Output() testComplete = new EventEmitter<void>();
@Output() redoTest = new EventEmitter<number>(); @Output() redoTest = new EventEmitter<number>();
private metricsService = inject(MetricsTrackerService);
// --- Component State (Restored) ---
minSize = 2; minSize = 2;
maxSize = 64; maxSize = 64;
currentSize = 16; currentSize = 16;
comfortableSize = 16;
phase: minSizeResult: number | null = null;
| 'min' maxSizeResult: number | null = null;
| 'confirmedMin' comfortableSizeResult: number | null = null;
| 'max'
| 'confirmedMax' phase: 'min' | 'confirmedMin' | 'max' | 'confirmedMax' | 'comfortable' | 'confirmedComfort' | 'finished' = 'min';
| 'comfortable'
| 'confirmedComfort'
| 'finished' = 'min';
private offsetApplied = false; private offsetApplied = false;
constructor(private cdr: ChangeDetectorRef) {} constructor(private cdr: ChangeDetectorRef) {
this.logInteraction = this.logInteraction.bind(this);
}
ngAfterViewInit() { ngAfterViewInit() {
const mv = this.modelViewerRef.nativeElement; this.metricsService.startDeviceOrientationTracking();
mv.setAttribute('scale', '0.25 0.25 0.25'); this.metricsService.startArTracking(this.modelViewerRef.nativeElement);
}
mv.addEventListener('ar-status', async (e: any) => { public logInteraction(event: Event) {
if (e.detail.status === 'session-started' && !this.offsetApplied) { this.metricsService.logInteraction(event);
await mv.updateComplete;
const anchor = mv.getAnchor();
if (!anchor.includes('not placed')) {
const [x, y, z] = anchor.split(' ').map(parseFloat);
mv.setAttribute('ar-anchor', `${x} ${y + 3.0} ${z}`);
this.offsetApplied = true;
}
}
});
} }
decrease() { decrease() {
if (this.currentSize > this.minSize) { if (this.currentSize > this.minSize) this.currentSize--;
this.currentSize--;
this.syncComfortable();
}
} }
increase() { increase() {
if (this.currentSize < this.maxSize) { if (this.currentSize < this.maxSize) this.currentSize++;
this.currentSize++;
this.syncComfortable();
}
}
private syncComfortable() {
if (this.phase === 'comfortable') {
this.comfortableSize = this.currentSize;
}
} }
nextPhase() { nextPhase() {
switch (this.phase) { switch (this.phase) {
case 'min': case 'min':
this.minSizeResult = this.currentSize;
this.phase = 'confirmedMin'; this.phase = 'confirmedMin';
break; break;
case 'confirmedMin': case 'confirmedMin':
this.phase = 'max';
this.currentSize = this.maxSize; this.currentSize = this.maxSize;
this.phase = 'max';
break; break;
case 'max': case 'max':
this.maxSizeResult = this.currentSize;
this.phase = 'confirmedMax'; this.phase = 'confirmedMax';
break; break;
case 'confirmedMax': case 'confirmedMax':
this.currentSize = Math.floor((this.minSizeResult! + this.maxSizeResult!) / 2);
this.phase = 'comfortable'; this.phase = 'comfortable';
this.currentSize = Math.floor((this.minSize + this.maxSize) / 2);
this.comfortableSize = this.currentSize;
break; break;
case 'comfortable': case 'comfortable':
this.comfortableSizeResult = this.currentSize;
this.phase = 'confirmedComfort'; this.phase = 'confirmedComfort';
this.comfortableSize = this.currentSize;
break; break;
case 'confirmedComfort': case 'confirmedComfort':
this.phase = 'finished'; this.finishAssessment();
this.testComplete.emit();
break; break;
} }
} }
resetToMid() { resetToMid() {
this.currentSize = Math.floor((this.minSize + this.maxSize) / 2); this.currentSize = Math.floor((this.minSize + this.maxSize) / 2);
this.phase = 'min'; this.phase = 'min';
this.minSizeResult = null;
this.maxSizeResult = null;
this.comfortableSizeResult = null;
} }
retry() { retry() {
@ -119,6 +104,28 @@ export class TextLegibilityAssessmentComponent implements AfterViewInit {
} }
finishAssessment() { finishAssessment() {
this.phase = 'finished';
const finalResults = {
minReadableSize: this.minSizeResult,
maxReadableSize: this.maxSizeResult,
comfortableReadableSize: this.comfortableSizeResult
};
this.metricsService.logInteraction(new CustomEvent('test-results', {
detail: {
testName: 'TextLegibility',
results: finalResults
}
}));
console.log(finalResults);
this.testComplete.emit(); this.testComplete.emit();
} }
ngOnDestroy() {
this.metricsService.cleanup();
}
} }

View File

@ -0,0 +1,168 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { interval, Subscription, tap } from 'rxjs';
export interface InteractionEvent {
timestamp: number;
type: string;
elementId?: string;
elementTag?: string;
elementClasses?: string;
value?: any;
}
export interface DeviceOrientation {
timestamp: number;
alpha: number | null;
beta: number | null;
gamma: number | null;
}
export interface ArTrackingData {
timestamp: number;
anchor: string | null;
cameraOrbit: {
theta: number;
phi: number;
radius: number;
} | null;
cameraTarget: {
x: number;
y: number;
z: number;
} | null;
}
export interface MetricsLog {
interactions: InteractionEvent[];
deviceOrientations: DeviceOrientation[];
arData: ArTrackingData[];
}
@Injectable({
providedIn: 'root'
})
export class MetricsTrackerService {
private http = inject(HttpClient);
private serverUrl = '/api/log';
private metricsLog: MetricsLog = {
interactions: [],
deviceOrientations: [],
arData: []
};
private deviceOrientationSubscription: Subscription | null = null;
private arTrackingSubscription: Subscription | null = null;
private lastDeviceOrientation: DeviceOrientationEvent | null = null;
constructor() {
this.handleDeviceOrientation = this.handleDeviceOrientation.bind(this);
}
private handleDeviceOrientation(event: DeviceOrientationEvent): void {
this.lastDeviceOrientation = event;
}
// --- Public API ---
public logInteraction(event: Event): void {
if (event.target) {
const target = event.target as HTMLElement;
const interaction: InteractionEvent = {
timestamp: Date.now(),
type: event.type,
elementId: target.id || undefined,
elementTag: target.tagName,
elementClasses: target.className,
value: (target as any).value ?? undefined
};
this.metricsLog.interactions.push(interaction);
}
else if (event instanceof CustomEvent && event.detail) {
const interaction: InteractionEvent = {
timestamp: Date.now(),
type: event.type,
value: event.detail
};
this.metricsLog.interactions.push(interaction);
}
else {
console.warn("logInteraction called with an unknown event type:", event);
}
}
public startDeviceOrientationTracking(): void {
if (this.deviceOrientationSubscription || typeof window === 'undefined') return;
window.addEventListener('deviceorientation', this.handleDeviceOrientation);
this.deviceOrientationSubscription = interval(500).subscribe(() => {
if (this.lastDeviceOrientation) {
const orientation: DeviceOrientation = {
timestamp: this.lastDeviceOrientation.timeStamp,
alpha: this.lastDeviceOrientation.alpha,
beta: this.lastDeviceOrientation.beta,
gamma: this.lastDeviceOrientation.gamma
};
this.metricsLog.deviceOrientations.push(orientation);
}
});
}
public startArTracking(modelViewerElement: any): void {
if (this.arTrackingSubscription || !modelViewerElement) return;
this.arTrackingSubscription = interval(500).subscribe(() => {
if (!modelViewerElement.arActive) {
return;
}
const anchor = modelViewerElement.getAnchor ? modelViewerElement.getAnchor() : 'getAnchor not available';
const orbit = modelViewerElement.getCameraOrbit();
const target = modelViewerElement.getCameraTarget();
const arData: ArTrackingData = {
timestamp: Date.now(),
anchor: anchor.includes('not placed') ? null : anchor,
cameraOrbit: orbit ? { theta: orbit.theta, phi: orbit.phi, radius: orbit.radius } : null,
cameraTarget: target ? { x: target.x, y: target.y, z: target.z } : null,
};
this.metricsLog.arData.push(arData);
});
}
public sendMetricsToServer(testName: string, formData?: any) {
const payload = {
testName,
metricsLog: this.metricsLog,
...(formData && { formData })
};
console.log(payload)
return this.http.post(this.serverUrl, payload).pipe(
tap({
next: (response) => {
console.log(`Metrics for '${testName}' sent successfully:`, response);
this.resetMetrics();
},
error: (err) => console.error(`Failed to send metrics for '${testName}':`, err)
})
);
}
public cleanup(): void {
if (typeof window !== 'undefined') {
window.removeEventListener('deviceorientation', this.handleDeviceOrientation);
}
this.deviceOrientationSubscription?.unsubscribe();
this.arTrackingSubscription?.unsubscribe();
this.deviceOrientationSubscription = null;
this.arTrackingSubscription = null;
}
public resetMetrics(): void {
this.metricsLog = { interactions: [], deviceOrientations: [], arData: [] };
}
}

View File

@ -2,9 +2,11 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app/app.routes'; import { routes } from './app/app.routes';
import { provideHttpClient } from '@angular/common/http';
bootstrapApplication(AppComponent, { bootstrapApplication(AppComponent, {
providers: [ providers: [
provideRouter(routes) provideRouter(routes),
provideHttpClient()
] ]
}).catch(err => console.error(err)); }).catch(err => console.error(err));