implement metrics tracking
parent
ca8d4e31da
commit
9446e3b2de
|
|
@ -2,7 +2,8 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
|||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
|
||||
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient()]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,199 +1,244 @@
|
|||
<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="text-center mb-8">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="demographicsForm" (ngSubmit)="onSubmit()">
|
||||
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
Demografische 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">Alter *</label>
|
||||
<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">
|
||||
<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 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>
|
||||
<div class="space-y-6">
|
||||
<!-- Frage 1: Komfort -->
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<label id="label-comfortHold" class="block text-sm font-medium text-gray-800 mb-3">
|
||||
Ich empfand es als angenehm, das Gerät für die Dauer des Tests in der Position zu halten. *
|
||||
</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="comfortHold-{{option}}" type="radio" formControlName="comfortHold" [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>
|
||||
<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">
|
||||
<option value="" disabled>Bitte auswählen</option>
|
||||
<option value="never">Nie</option>
|
||||
<option value="rarely">Selten (wenige Male pro Jahr)</option>
|
||||
<option value="occasionally">Gelegentlich (monatlich)</option>
|
||||
<option value="regularly">Regelmäßig (wöchentlich)</option>
|
||||
<option value="frequently">Häufig (täglich)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Frage 2: Ausdauer (5 Minuten) -->
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<label id="label-enduranceFiveMinutes" class="block text-sm font-medium text-gray-800 mb-3">
|
||||
Ich glaube, ich könnte diese Position auch für 5 Minuten bequem halten. *
|
||||
</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="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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -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 { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-demographics-feedback',
|
||||
|
|
@ -9,14 +10,29 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
|||
templateUrl: './demographics-feedback.component.html',
|
||||
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() redoTest = new EventEmitter<number>();
|
||||
@Output() redoTest = new EventEmitter<number>();
|
||||
|
||||
demographicsForm!: FormGroup;
|
||||
isSubmitting = 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 = [
|
||||
{ value: 'smartphone-ar', label: 'Smartphone-AR' },
|
||||
{ value: 'meta-quest', label: 'Meta Quest/Oculus' },
|
||||
|
|
@ -37,41 +53,56 @@ export class DemographicsFeedbackComponent implements OnInit {
|
|||
|
||||
selectedDevices: string[] = [];
|
||||
|
||||
constructor(private formBuilder: FormBuilder) {}
|
||||
constructor(private formBuilder: FormBuilder) {
|
||||
this.logInteraction = this.logInteraction.bind(this);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.createForm();
|
||||
// Start device orientation tracking for this component
|
||||
this.metricsService.startDeviceOrientationTracking();
|
||||
}
|
||||
|
||||
logInteraction(event: Event) {
|
||||
this.metricsService.logInteraction(event);
|
||||
}
|
||||
|
||||
createForm() {
|
||||
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)]],
|
||||
gender: ['', Validators.required],
|
||||
education: [''],
|
||||
occupation: [''],
|
||||
|
||||
// AR/VR Vorerfahrung
|
||||
arExperience: [1, Validators.required],
|
||||
vrExperience: [1, Validators.required],
|
||||
arVrFrequency: [''],
|
||||
|
||||
// Physische und visuelle Einschränkungen
|
||||
visionCorrection: [''],
|
||||
colorVision: [''],
|
||||
dominantHand: [''],
|
||||
mobilityLimitations: [''],
|
||||
|
||||
smartphoneComfort: [3, Validators.required],
|
||||
techComfort: [3, Validators.required],
|
||||
gamingExperience: [3, Validators.required],
|
||||
|
||||
// Technologiekompetenz
|
||||
smartphoneComfort: [5],
|
||||
techComfort: [5],
|
||||
gamingExperience: [5],
|
||||
sus1: ['', Validators.required],
|
||||
sus2: ['', Validators.required],
|
||||
sus3: ['', Validators.required],
|
||||
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: [''],
|
||||
wouldRecommend: ['', Validators.required]
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -83,21 +114,7 @@ export class DemographicsFeedbackComponent implements OnInit {
|
|||
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() {
|
||||
if (this.demographicsForm.valid) {
|
||||
this.isSubmitting = true;
|
||||
|
|
@ -105,28 +122,57 @@ export class DemographicsFeedbackComponent implements OnInit {
|
|||
const formData = {
|
||||
...this.demographicsForm.value,
|
||||
devicesUsed: this.selectedDevices,
|
||||
susScore: this.calculateSusScore(this.demographicsForm.value),
|
||||
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 {
|
||||
Object.keys(this.demographicsForm.controls).forEach(key => {
|
||||
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() {
|
||||
this.testComplete.emit();
|
||||
}
|
||||
|
||||
retryCurrent() {
|
||||
this.redoTest.emit();
|
||||
this.redoTest.emit(4);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.metricsService.cleanup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@
|
|||
ar-modes="webxr"
|
||||
ar-placement="ceiling"
|
||||
reveal="manual"
|
||||
[scale]="scale + ' ' + scale + ' ' + scale"
|
||||
camera-orbit="0deg 75deg 2m">
|
||||
|
||||
<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">
|
||||
WebXR Umgebung laden
|
||||
</button>
|
||||
|
|
@ -18,11 +21,19 @@
|
|||
<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.opacity-50]="!isModelPlaced">
|
||||
|
||||
<!-- Slider 1: Scale -->
|
||||
<div class="flex flex-col items-start w-64">
|
||||
<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)="onSliderInput()" [disabled]="!isModelPlaced"
|
||||
<input
|
||||
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" />
|
||||
<span class="text-xs mt-1">{{ scale.toFixed(2) }}×</span>
|
||||
</div>
|
||||
|
|
@ -30,19 +41,32 @@
|
|||
<!-- Slider 2: Height -->
|
||||
<div class="flex flex-col items-start w-64">
|
||||
<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)="onSliderInput()" [disabled]="!isModelPlaced"
|
||||
<input
|
||||
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" />
|
||||
<span class="text-xs mt-1">{{ verticalOffset >= 0 ? '+' : '' }}{{ verticalOffset.toFixed(2) }}m</span>
|
||||
</div>
|
||||
|
||||
<!-- Buttons Row -->
|
||||
<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">
|
||||
Reset
|
||||
</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">
|
||||
Position bestätigen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import {
|
||||
Component,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ChangeDetectorRef,
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
EventEmitter,
|
||||
Output
|
||||
Output,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
|
||||
import '../../../../../assets/scripts/model-viewer';
|
||||
|
||||
@Component({
|
||||
|
|
@ -21,21 +23,26 @@ import '../../../../../assets/scripts/model-viewer';
|
|||
styleUrls: ['./spatial-position-assessment.component.css'],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class SpatialPositionAssessmentComponent implements AfterViewInit {
|
||||
export class SpatialPositionAssessmentComponent implements AfterViewInit, OnDestroy { // <-- Implement OnDestroy
|
||||
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
|
||||
@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;
|
||||
|
||||
protected isModelPlaced = false;
|
||||
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() {
|
||||
const mv = this.modelViewerRef.nativeElement;
|
||||
this.metricsService.startDeviceOrientationTracking();
|
||||
this.metricsService.startArTracking(mv);
|
||||
mv.addEventListener('ar-status', (e: any) => {
|
||||
if (e.detail.status === 'session-started') {
|
||||
setTimeout(() => this.captureAnchor(), 500);
|
||||
|
|
@ -43,6 +50,10 @@ export class SpatialPositionAssessmentComponent implements AfterViewInit {
|
|||
});
|
||||
}
|
||||
|
||||
public logInteraction(event: Event) {
|
||||
this.metricsService.logInteraction(event);
|
||||
}
|
||||
|
||||
private async captureAnchor() {
|
||||
const mv = this.modelViewerRef.nativeElement;
|
||||
const anchor = mv.getAnchor();
|
||||
|
|
@ -70,10 +81,31 @@ export class SpatialPositionAssessmentComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
retryCurrent() {
|
||||
this.redoTest.emit(1);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.metricsService.cleanup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +1,121 @@
|
|||
<div class="w-full h-full relative">
|
||||
<model-viewer
|
||||
#modelViewer
|
||||
class="absolute inset-0 w-full h-full"
|
||||
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
|
||||
ar
|
||||
ar-modes="webxr"
|
||||
ar-placement="ceiling"
|
||||
reveal="manual"
|
||||
camera-orbit="0deg 75deg 2m">
|
||||
|
||||
<button
|
||||
slot="ar-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>
|
||||
<model-viewer
|
||||
#modelViewer
|
||||
class="absolute inset-0 w-full h-full"
|
||||
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
|
||||
ar
|
||||
ar-modes="webxr"
|
||||
ar-placement="ceiling"
|
||||
reveal="manual"
|
||||
camera-orbit="0deg 75deg 2m">
|
||||
|
||||
<button
|
||||
id="ar-button-stability"
|
||||
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">
|
||||
WebXR Umgebung laden
|
||||
</button>
|
||||
|
||||
<!-- Adjustment Phase Controls -->
|
||||
<div
|
||||
*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">
|
||||
|
||||
<div class="text-white text-center mb-4">
|
||||
<h4 class="text-lg font-semibold">Finden sie ihre Optionale Betrachtungsposition</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center text-white w-64">
|
||||
<label for="offset-slider" class="text-sm font-medium mb-2">Höhe verschieben</label>
|
||||
<input
|
||||
id="offset-slider"
|
||||
type="range"
|
||||
min="-1.5"
|
||||
max="1.5"
|
||||
step="0.01"
|
||||
[(ngModel)]="verticalOffset"
|
||||
(input)="onSliderInput()"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Adjustment Phase Controls -->
|
||||
<div *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">
|
||||
|
||||
<div class="text-white text-center mb-4">
|
||||
<h4 id="adjust-phase-title" class="text-lg font-semibold">Finden sie ihre Optionale Betrachtungsposition</h4>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
id="stability-offset-slider"
|
||||
type="range"
|
||||
min="-1.5"
|
||||
max="1.5"
|
||||
step="0.01"
|
||||
[(ngModel)]="verticalOffset"
|
||||
(input)="onSliderInput(); logInteraction($event)"
|
||||
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>
|
||||
</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
|
||||
(click)="lockPosition()"
|
||||
class="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg text-lg">
|
||||
🔒 Diese Position fixieren.
|
||||
id="adjust-more-btn"
|
||||
(click)="adjustMore(); logInteraction($event)"
|
||||
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>
|
||||
</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 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
|
||||
(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>
|
||||
|
||||
<!-- Countdown Phase -->
|
||||
<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-red-600 bg-opacity-90 p-6 rounded-xl">
|
||||
|
||||
<div class="text-white text-center">
|
||||
<h4 id="countdown-timer" class="text-3xl font-bold">{{ remainingTime }}</h4>
|
||||
<p class="text-lg">Sekunden verbleibend</p>
|
||||
<p class="text-sm mt-2 opacity-80">Halten Sie das Modell in Position.</p>
|
||||
</div>
|
||||
|
||||
<!-- Countdown Phase -->
|
||||
<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-red-600 bg-opacity-90 p-6 rounded-xl">
|
||||
|
||||
<div class="text-white text-center">
|
||||
<h4 class="text-3xl font-bold">{{ remainingTime }}</h4>
|
||||
<p class="text-lg">Sekunden verbleibend</p>
|
||||
<p class="text-sm mt-2 opacity-80">Halten Sie das Modell in Position.</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-64 h-4 bg-white bg-opacity-30 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-white transition-all duration-1000 ease-linear"
|
||||
[style.width.%]="progressPercentage">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-64 h-4 bg-white bg-opacity-30 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-white transition-all duration-1000 ease-linear" [style.width.%]="progressPercentage"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="cancel-countdown-btn"
|
||||
(click)="cancelTest(); logInteraction($event)"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg text-sm">
|
||||
Cancel Test
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Completion Phase -->
|
||||
<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 id="completion-title" class="text-2xl font-bold"> Test abgeschlossen</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<button
|
||||
(click)="cancelTest()"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg text-sm">
|
||||
Cancel Test
|
||||
id="restart-test-btn"
|
||||
(click)="restartTest(); logInteraction($event)"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Completion Phase -->
|
||||
<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>
|
||||
|
||||
</div>
|
||||
</model-viewer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RotationSpeedAssessmentComponent } from './spatial-stability-assessment.component';
|
||||
import { SpatialStabilityAssessmentComponent } from './spatial-stability-assessment.component';
|
||||
|
||||
describe('RotationSpeedAssessmentComponent', () => {
|
||||
let component: RotationSpeedAssessmentComponent;
|
||||
let fixture: ComponentFixture<RotationSpeedAssessmentComponent>;
|
||||
let component: SpatialStabilityAssessmentComponent;
|
||||
let fixture: ComponentFixture<SpatialStabilityAssessmentComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RotationSpeedAssessmentComponent]
|
||||
imports: [SpatialStabilityAssessmentComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RotationSpeedAssessmentComponent);
|
||||
fixture = TestBed.createComponent(SpatialStabilityAssessmentComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { FormsModule } from '@angular/forms';
|
||||
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
|
||||
|
||||
import '../../../../../assets/scripts/model-viewer';
|
||||
|
||||
|
|
@ -12,44 +24,53 @@ import '../../../../../assets/scripts/model-viewer';
|
|||
styleUrls: ['./spatial-stability-assessment.component.css'],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class SpatialStabilityAssessmentComponent implements AfterViewInit {
|
||||
export class SpatialStabilityAssessmentComponent implements AfterViewInit, OnDestroy { // <-- Implement OnDestroy
|
||||
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
|
||||
@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 currentPhase = 0; // 0: waiting, 1: adjust, 2: lock, 3: countdown, 4: complete
|
||||
public remainingTime = 20;
|
||||
public progressPercentage = 0;
|
||||
|
||||
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;
|
||||
protected isModelPlaced = false;
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
constructor(private cdr: ChangeDetectorRef) {
|
||||
this.logInteraction = this.logInteraction.bind(this);
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
const modelViewer = this.modelViewerRef.nativeElement;
|
||||
|
||||
this.metricsService.startDeviceOrientationTracking();
|
||||
this.metricsService.startArTracking(modelViewer);
|
||||
|
||||
modelViewer.addEventListener('ar-status', (event: any) => {
|
||||
if (event.detail.status === 'session-started') {
|
||||
if (event.detail.status === 'session-started' && !this.isModelPlaced) {
|
||||
setTimeout(() => this.getInitialAnchor(), 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public logInteraction(event: Event) {
|
||||
this.metricsService.logInteraction(event);
|
||||
}
|
||||
|
||||
private async getInitialAnchor() {
|
||||
const modelViewer = this.modelViewerRef.nativeElement;
|
||||
const anchorString = modelViewer.getAnchor();
|
||||
|
||||
if (!anchorString.includes('not placed')) {
|
||||
const coords = anchorString.split(' ').map(parseFloat);
|
||||
this.initialArAnchor = { x: coords[0], y: coords[1], z: coords[2] };
|
||||
this.isModelPlaced = true;
|
||||
this.currentPhase = 1;
|
||||
console.log('Starting adjustment phase');
|
||||
this.cdr.detectChanges();
|
||||
} else {
|
||||
setTimeout(() => this.getInitialAnchor(), 500);
|
||||
}
|
||||
|
|
@ -65,11 +86,10 @@ export class SpatialStabilityAssessmentComponent implements AfterViewInit {
|
|||
|
||||
lockPosition() {
|
||||
this.lockedPosition = {
|
||||
scale: this.scale,
|
||||
verticalOffset: this.verticalOffset
|
||||
verticalOffset: this.verticalOffset,
|
||||
anchor: this.modelViewerRef.nativeElement.getAttribute('ar-anchor')
|
||||
};
|
||||
this.currentPhase = 2;
|
||||
console.log('Position locked:', this.lockedPosition);
|
||||
}
|
||||
|
||||
adjustMore() {
|
||||
|
|
@ -86,13 +106,10 @@ export class SpatialStabilityAssessmentComponent implements AfterViewInit {
|
|||
this.progressPercentage = ((20 - this.remainingTime) / 20) * 100;
|
||||
|
||||
if (this.remainingTime <= 0) {
|
||||
this.completeTest();
|
||||
this.completeTest(true);
|
||||
}
|
||||
|
||||
this.cdr.detectChanges();
|
||||
}, 1000);
|
||||
|
||||
console.log('Countdown started');
|
||||
}
|
||||
|
||||
cancelTest() {
|
||||
|
|
@ -101,40 +118,57 @@ export class SpatialStabilityAssessmentComponent implements AfterViewInit {
|
|||
this.countdownInterval = null;
|
||||
}
|
||||
this.currentPhase = 2;
|
||||
this.metricsService.logInteraction(new CustomEvent('test-cancelled', { detail: { phase: 'countdown' }}));
|
||||
}
|
||||
|
||||
private completeTest() {
|
||||
private completeTest(wasCompleted: boolean) {
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
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() {
|
||||
this.currentPhase = 1;
|
||||
this.remainingTime = 60;
|
||||
this.remainingTime = 20;
|
||||
this.progressPercentage = 0;
|
||||
this.lockedPosition = null;
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
this.countdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
}
|
||||
this.metricsService.resetMetrics();
|
||||
}
|
||||
|
||||
finishAssessment() {
|
||||
this.testComplete.emit();
|
||||
}
|
||||
|
||||
retryCurrent() {
|
||||
this.redoTest.emit(1);
|
||||
ngOnDestroy() {
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
}
|
||||
this.metricsService.cleanup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,92 @@
|
|||
<div class="w-full h-full relative">
|
||||
<model-viewer
|
||||
#modelViewer
|
||||
class="absolute inset-0 w-full h-full"
|
||||
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
|
||||
ar
|
||||
ar-modes="webxr"
|
||||
ar-placement="ceiling"
|
||||
reveal="manual"
|
||||
camera-orbit="0deg 75deg 2m">
|
||||
<model-viewer #modelViewer class="absolute inset-0 w-full h-full"
|
||||
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb" ar ar-modes="webxr" ar-placement="ceiling"
|
||||
reveal="manual" camera-orbit="0deg 75deg 2m">
|
||||
|
||||
<button
|
||||
slot="ar-button"
|
||||
<button slot="ar-button" id="ar-button-legibility" (click)="logInteraction($event)"
|
||||
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
|
||||
</button>
|
||||
|
||||
<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}"
|
||||
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.
|
||||
style="background:rgba(255,255,255,0.9);padding:8px;border-radius:4px;color:black;">
|
||||
Lorem ipsum dolor sit amet...
|
||||
</div>
|
||||
</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 -->
|
||||
<ng-container *ngIf="phase==='min'">
|
||||
<div>Minimum setzen</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Phase: Confirmed Min -->
|
||||
<ng-container *ngIf="phase==='confirmedMin'">
|
||||
<div>Minimum bestätigt: {{ currentSize }}px</div>
|
||||
<button (click)="resetToMid()" class="bg-gray-600 py-2 px-4 rounded-lg">Reset</button>
|
||||
<button (click)="nextPhase()" class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu Maximum</button>
|
||||
<div>Minimum bestätigt: {{ minSizeResult }}px</div>
|
||||
<button id="confirmed-min-reset-btn" (click)="resetToMid(); logInteraction($event)"
|
||||
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>
|
||||
|
||||
<!-- Phase: Max -->
|
||||
<ng-container *ngIf="phase==='max'">
|
||||
<div>Maximum setzen</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Phase: Confirmed Max -->
|
||||
<ng-container *ngIf="phase==='confirmedMax'">
|
||||
<div>Maximum bestätigt: {{ currentSize }}px</div>
|
||||
<button (click)="nextPhase()" class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu optiomal</button>
|
||||
<div>Maximum bestätigt: {{ maxSizeResult }}px</div>
|
||||
<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>
|
||||
|
||||
<!-- Phase: Comfortable -->
|
||||
<ng-container *ngIf="phase==='comfortable'">
|
||||
<div>Optimale Größe setzen</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import {
|
||||
Component,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ChangeDetectorRef,
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
EventEmitter,
|
||||
Output
|
||||
Output,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
|
||||
import '../../../../../assets/scripts/model-viewer';
|
||||
|
||||
@Component({
|
||||
|
|
@ -21,97 +23,80 @@ import '../../../../../assets/scripts/model-viewer';
|
|||
styleUrls: ['./text-legibility-assessment.component.css'],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class TextLegibilityAssessmentComponent implements AfterViewInit {
|
||||
export class TextLegibilityAssessmentComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
|
||||
@Output() testComplete = new EventEmitter<void>();
|
||||
@Output() redoTest = new EventEmitter<number>();
|
||||
@Output() redoTest = new EventEmitter<number>();
|
||||
|
||||
private metricsService = inject(MetricsTrackerService);
|
||||
|
||||
// --- Component State (Restored) ---
|
||||
minSize = 2;
|
||||
maxSize = 64;
|
||||
currentSize = 16;
|
||||
comfortableSize = 16;
|
||||
|
||||
phase:
|
||||
| 'min'
|
||||
| 'confirmedMin'
|
||||
| 'max'
|
||||
| 'confirmedMax'
|
||||
| 'comfortable'
|
||||
| 'confirmedComfort'
|
||||
| 'finished' = 'min';
|
||||
minSizeResult: number | null = null;
|
||||
maxSizeResult: number | null = null;
|
||||
comfortableSizeResult: number | null = null;
|
||||
|
||||
phase: 'min' | 'confirmedMin' | 'max' | 'confirmedMax' | 'comfortable' | 'confirmedComfort' | 'finished' = 'min';
|
||||
|
||||
private offsetApplied = false;
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
constructor(private cdr: ChangeDetectorRef) {
|
||||
this.logInteraction = this.logInteraction.bind(this);
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
const mv = this.modelViewerRef.nativeElement;
|
||||
mv.setAttribute('scale', '0.25 0.25 0.25');
|
||||
this.metricsService.startDeviceOrientationTracking();
|
||||
this.metricsService.startArTracking(this.modelViewerRef.nativeElement);
|
||||
}
|
||||
|
||||
mv.addEventListener('ar-status', async (e: any) => {
|
||||
if (e.detail.status === 'session-started' && !this.offsetApplied) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
public logInteraction(event: Event) {
|
||||
this.metricsService.logInteraction(event);
|
||||
}
|
||||
|
||||
decrease() {
|
||||
if (this.currentSize > this.minSize) {
|
||||
this.currentSize--;
|
||||
this.syncComfortable();
|
||||
}
|
||||
if (this.currentSize > this.minSize) this.currentSize--;
|
||||
}
|
||||
|
||||
increase() {
|
||||
if (this.currentSize < this.maxSize) {
|
||||
this.currentSize++;
|
||||
this.syncComfortable();
|
||||
}
|
||||
}
|
||||
|
||||
private syncComfortable() {
|
||||
if (this.phase === 'comfortable') {
|
||||
this.comfortableSize = this.currentSize;
|
||||
}
|
||||
if (this.currentSize < this.maxSize) this.currentSize++;
|
||||
}
|
||||
|
||||
nextPhase() {
|
||||
switch (this.phase) {
|
||||
case 'min':
|
||||
this.minSizeResult = this.currentSize;
|
||||
this.phase = 'confirmedMin';
|
||||
break;
|
||||
case 'confirmedMin':
|
||||
this.phase = 'max';
|
||||
this.currentSize = this.maxSize;
|
||||
this.phase = 'max';
|
||||
break;
|
||||
case 'max':
|
||||
this.maxSizeResult = this.currentSize;
|
||||
this.phase = 'confirmedMax';
|
||||
break;
|
||||
case 'confirmedMax':
|
||||
this.currentSize = Math.floor((this.minSizeResult! + this.maxSizeResult!) / 2);
|
||||
this.phase = 'comfortable';
|
||||
this.currentSize = Math.floor((this.minSize + this.maxSize) / 2);
|
||||
this.comfortableSize = this.currentSize;
|
||||
break;
|
||||
case 'comfortable':
|
||||
this.comfortableSizeResult = this.currentSize;
|
||||
this.phase = 'confirmedComfort';
|
||||
this.comfortableSize = this.currentSize;
|
||||
break;
|
||||
case 'confirmedComfort':
|
||||
this.phase = 'finished';
|
||||
this.testComplete.emit();
|
||||
this.finishAssessment();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
resetToMid() {
|
||||
this.currentSize = Math.floor((this.minSize + this.maxSize) / 2);
|
||||
this.phase = 'min';
|
||||
this.minSizeResult = null;
|
||||
this.maxSizeResult = null;
|
||||
this.comfortableSizeResult = null;
|
||||
}
|
||||
|
||||
retry() {
|
||||
|
|
@ -119,6 +104,28 @@ export class TextLegibilityAssessmentComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
ngOnDestroy() {
|
||||
this.metricsService.cleanup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@ import { bootstrapApplication } from '@angular/platform-browser';
|
|||
import { AppComponent } from './app/app.component';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app/app.routes';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideRouter(routes)
|
||||
provideRouter(routes),
|
||||
provideHttpClient()
|
||||
]
|
||||
}).catch(err => console.error(err));
|
||||
}).catch(err => console.error(err));
|
||||
|
|
|
|||
Loading…
Reference in New Issue