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(); @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; 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(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 { await this.updateCard('again'); this.coverCurrentBox(); this.nextBox(); } /** * Marks the current box as "Good" and proceeds to the next box. */ async markGood(): Promise { await this.updateCard('good'); this.coverCurrentBox(); this.nextBox(); } /** * Marks the current box as "Easy" and proceeds to the next box. */ async markEasy(): Promise { 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 { 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}`; } }