460 lines
13 KiB
TypeScript
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}`;
|
|
}
|
|
}
|