vokabeltraining/src/app/training/training.component.ts

460 lines
13 KiB
TypeScript

import { CommonModule } from '@angular/common';
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { Box, Deck, DeckImage, DeckService } from '../services/deck.service';
import { PopoverService } from '../services/popover.service';
const LEARNING_STEPS = {
AGAIN: 1, // 1 minute
GOOD: 10, // 10 minutes
GRADUATION: 1440, // 1 day (in minutes)
};
const FACTOR_CHANGES = {
AGAIN: 0.85, // Reduce factor by 15%
EASY: 1.15, // Increase factor by 15%
MIN: 1.3, // Minimum factor allowed
MAX: 2.9, // Maximum factor allowed
};
const EASY_INTERVAL = 4 * 1440; // 4 days in minutes
@Component({
selector: 'app-training',
templateUrl: './training.component.html',
standalone: true,
imports: [CommonModule],
})
export class TrainingComponent implements OnInit {
@Input() deck!: Deck;
@Output() close = new EventEmitter<void>();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
currentImageIndex: number = 0;
currentImageData: DeckImage | null = null;
currentBoxIndex: number = 0;
boxesToReview: Box[] = [];
boxRevealed: boolean[] = [];
isShowingBox: boolean = false;
isTrainingFinished: boolean = false;
constructor(private deckService: DeckService, private popoverService: PopoverService) {}
ngOnInit(): void {
// Initialization was done in ngAfterViewInit
}
ngAfterViewInit() {
// Initialize the first image and its boxes
if (this.deck && this.deck.images.length > 0) {
this.loadImage(this.currentImageIndex);
} else {
this.popoverService.show({
title: 'Information',
message: 'No deck or images available.',
});
this.close.emit();
}
}
/**
* Loads the image based on the given index and initializes the boxes to review.
* @param imageIndex Index of the image to load in the deck
*/
loadImage(imageIndex: number): void {
if (imageIndex >= this.deck.images.length) {
this.endTraining();
return;
}
this.currentImageData = this.deck.images[imageIndex];
// Initialize the boxes for the current round
this.initializeBoxesToReview();
}
/**
* Determines all due boxes for the current round, shuffles them, and resets the current box index.
* If no boxes are left to review, it moves to the next image.
*/
initializeBoxesToReview(): void {
if (!this.currentImageData) {
this.nextImage();
return;
}
// Filter all boxes that are due (due <= today)
const today = this.getTodayInDays();
this.boxesToReview = this.currentImageData.boxes.filter(box => box.due === undefined || box.due <= today);
if (this.boxesToReview.length === 0) {
// No more boxes for this image, move to the next image
this.nextImage();
return;
}
// Shuffle the boxes randomly
this.boxesToReview = this.shuffleArray(this.boxesToReview);
// Initialize the array to track revealed boxes
this.boxRevealed = new Array(this.boxesToReview.length).fill(false);
// Reset the current box index
this.currentBoxIndex = 0;
this.isShowingBox = false;
// Redraw the canvas
this.drawCanvas();
}
/**
* Returns today's date in days since the UNIX epoch.
*/
getTodayInDays(): number {
const epoch = new Date(1970, 0, 1); // Anki uses UNIX epoch
const today = new Date();
return Math.floor((today.getTime() - epoch.getTime()) / (1000 * 60 * 60 * 24));
}
/**
* Draws the current image and boxes on the canvas.
* Boxes are displayed in red if hidden and green if revealed.
*/
drawCanvas(): void {
const canvas = this.canvasRef.nativeElement;
const ctx = canvas.getContext('2d');
if (!ctx || !this.currentImageData) return;
const img = new Image();
img.src = `/images/${this.currentImageData.bildid}/original.webp`;
img.onload = () => {
// Set the canvas size to the image size
canvas.width = img.width;
canvas.height = img.height;
// Draw the image
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Draw the boxes
this.boxesToReview.forEach((box, index) => {
ctx.beginPath();
ctx.rect(box.x1, box.y1, box.x2 - box.x1, box.y2 - box.y1);
if ((this.currentBoxIndex === index && this.isShowingBox) || (box.due && box.due - this.getTodayInDays() > 0)) {
// Box is currently revealed, no overlay
return;
} else if (this.currentBoxIndex === index && !this.isShowingBox) {
// Box is revealed
ctx.fillStyle = 'rgba(0, 255, 0, 1)'; // Opaque green overlay
} else {
// Box is hidden
ctx.fillStyle = 'rgba(255, 0, 0, 1)'; // Opaque red overlay
}
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'black';
ctx.stroke();
});
};
img.onerror = () => {
console.error('Error loading image for canvas.');
this.popoverService.show({
title: 'Error',
message: 'Error loading image for canvas.',
});
this.close.emit();
};
}
/**
* Shuffles an array randomly.
* @param array The array to shuffle
* @returns The shuffled array
*/
shuffleArray<T>(array: T[]): T[] {
let currentIndex = array.length,
randomIndex;
// While there are elements to shuffle
while (currentIndex !== 0) {
// Pick a remaining element
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// Swap it with the current element
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
}
return array;
}
/**
* Returns the current box being reviewed.
*/
get currentBox(): Box | null {
if (this.currentBoxIndex < this.boxesToReview.length) {
return this.boxesToReview[this.currentBoxIndex];
}
return null;
}
/**
* Reveals the content of the current box.
*/
showText(): void {
if (this.currentBoxIndex >= this.boxesToReview.length) return;
this.boxRevealed[this.currentBoxIndex] = true;
this.isShowingBox = true;
this.drawCanvas();
}
/**
* Marks the current box as "Again" and proceeds to the next box.
*/
async markAgain(): Promise<void> {
await this.updateCard('again');
this.coverCurrentBox();
this.nextBox();
}
/**
* Marks the current box as "Good" and proceeds to the next box.
*/
async markGood(): Promise<void> {
await this.updateCard('good');
this.coverCurrentBox();
this.nextBox();
}
/**
* Marks the current box as "Easy" and proceeds to the next box.
*/
async markEasy(): Promise<void> {
await this.updateCard('easy');
this.coverCurrentBox();
this.nextBox();
}
/**
* Updates the SRS data of the current box based on the given action.
* @param action The action performed ('again', 'good', 'easy')
*/
async updateCard(action: 'again' | 'good' | 'easy'): Promise<void> {
if (this.currentBoxIndex >= this.boxesToReview.length) return;
const box = this.boxesToReview[this.currentBoxIndex];
const today = this.getTodayInDays();
// Calculate the new interval and possibly the new factor
const { newIvl, newFactor, newReps, newLapses, newIsGraduated } = this.calculateNewInterval(box, action);
// Update the due date
const nextDue = today + Math.floor(newIvl / 1440);
// Update the box object
box.ivl = newIvl;
box.factor = newFactor;
box.reps = newReps;
box.lapses = newLapses;
box.due = nextDue;
box.isGraduated = newIsGraduated;
// Send the update to the backend
try {
await lastValueFrom(this.deckService.updateBox(box));
} catch (error) {
console.error('Error updating box:', error);
this.popoverService.show({
title: 'Error',
message: 'Error updating box.',
});
}
}
/**
* Calculates the new interval, factor, repetitions, and lapses based on the action.
* @param box The current box
* @param action The action ('again', 'good', 'easy')
* @returns An object with the new values for ivl, factor, reps, and lapses
*/
calculateNewInterval(
box: Box,
action: 'again' | 'good' | 'easy',
): {
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
newIsGraduated: boolean;
} {
const LEARNING_STEPS = {
AGAIN: 1, // 1 minute
GOOD: 10, // 10 minutes
GRADUATION: 1440, // 1 day (1440 minutes)
};
const EASY_BONUS = 1.3; // Zusätzlicher Easy-Multiplikator
/* const FACTOR_CHANGES = {
MIN: 1.3,
MAX: 2.5,
AGAIN: 0.85,
EASY: 1.15
}; */
let newIvl = box.ivl || 0;
let newFactor = box.factor || 2.5;
let newReps = box.reps || 0;
let newLapses = box.lapses || 0;
let newIsGraduated = box.isGraduated || false;
if (action === 'again') {
newLapses++;
newReps = 0;
newIvl = LEARNING_STEPS.AGAIN;
newIsGraduated = false;
newFactor = Math.max(FACTOR_CHANGES.MIN, newFactor * FACTOR_CHANGES.AGAIN);
return { newIvl, newFactor, newReps, newLapses, newIsGraduated };
}
if (action === 'easy') {
newReps++;
// Faktor zuerst aktualisieren
const updatedFactor = Math.min(FACTOR_CHANGES.MAX, newFactor * FACTOR_CHANGES.EASY);
if (!newIsGraduated) {
// Direkte Graduierung mit Easy
newIsGraduated = true;
newIvl = LEARNING_STEPS.GRADUATION; // 1 Tag (1440 Minuten)
} else {
// Anki-Formel für Easy in der Review-Phase
newIvl = Math.round((newIvl + LEARNING_STEPS.GRADUATION / 2) * updatedFactor * EASY_BONUS);
}
newFactor = updatedFactor;
return { newIvl, newFactor, newReps, newLapses, newIsGraduated };
}
// Handle 'good' action
newReps++;
if (!newIsGraduated) {
if (newReps === 1) {
newIvl = LEARNING_STEPS.GOOD; // 10 Minuten
} else {
newIsGraduated = true;
newIvl = LEARNING_STEPS.GRADUATION; // 1 Tag nach zweiter Good-Antwort
}
} else {
// Standard-SR-Formel
newIvl = Math.round(newIvl * newFactor);
}
return { newIvl, newFactor, newReps, newLapses, newIsGraduated };
}
/**
* Calculates the next interval based on the action and returns it as a string.
* @param box The current box
* @param action The action ('again', 'good', 'easy')
* @returns The next interval as a string (e.g., "10 min", "2 d")
*/
getNextInterval(box: Box | null, action: 'again' | 'good' | 'easy'): string {
if (!box) return '';
const { newIvl } = this.calculateNewInterval(box, action);
return this.formatInterval(newIvl);
}
/**
* Formats the interval as a string, either in minutes or days.
* @param ivl The interval in days
* @returns The formatted interval as a string
*/
formatInterval(minutes: number): string {
if (minutes < 60) return `${minutes}min`;
if (minutes < 1440) return `${Math.round(minutes / 60)}h`;
return `${Math.round(minutes / 1440)}d`;
}
/**
* Covers the current box again (hides it).
*/
coverCurrentBox(): void {
this.boxRevealed[this.currentBoxIndex] = false;
this.drawCanvas();
}
/**
* Moves to the next box. If all boxes in the current round have been processed,
* a new round is started.
*/
nextBox(): void {
this.isShowingBox = false;
if (this.currentBoxIndex >= this.boxesToReview.length - 1) {
// Round completed, start a new round
this.initializeBoxesToReview();
} else {
// Move to the next box
this.currentBoxIndex++;
this.drawCanvas();
}
}
/**
* Moves to the next image in the deck.
*/
nextImage(): void {
this.currentImageIndex++;
this.loadImage(this.currentImageIndex);
}
/**
* Skips to the next image in the deck.
*/
skipToNextImage(): void {
if (this.currentImageIndex < this.deck.images.length - 1) {
this.currentImageIndex++;
this.loadImage(this.currentImageIndex);
} else {
this.popoverService.show({
title: 'Information',
message: 'This is the last image in the deck.',
});
}
}
/**
* Ends the training and displays a completion message.
*/
endTraining(): void {
this.isTrainingFinished = true;
this.popoverService.show({
title: 'Information',
message: 'Training completed!',
});
this.close.emit();
}
/**
* Asks the user if they want to end the training and closes it if confirmed.
*/
closeTraining(): void {
this.popoverService.show({
title: 'End Training',
message: 'Do you really want to end the training?',
confirmText: 'End Training',
onConfirm: (inputValue?: string) => this.close.emit(),
});
}
/**
* Returns the progress of the training, e.g., "Image 2 of 5".
*/
get progress(): string {
return `Image ${this.currentImageIndex + 1} of ${this.deck.images.length}`;
}
}