Files
zen-kanji/client/src/utils/KanjiController.js
T
Crylia b1d4f9f71b
Release Build / build-docker (push) Successful in 2m44s
Release Build / build-android-and-release (push) Failing after 1m27s
feat: add vocabulary browser with filters, audio playback, and example sentences
- Add Vocabularies page with searchable, level-grouped grid
- Implement multi-faceted filter system (word type, verb class, transitivity, reading ending, character count)
- Add male/female voice audio playback via WaniKani pronunciation data
- Display context sentences from WaniKani API
- Collapsible mnemonics section in detail modal
- Component kanji chips navigate to Collection with pre-filled search
- Vocabulary sync uses upsert to keep data fresh (audio, sentences, normalized POS)
- Add Vocabulary model, controller, service, and API route
- Add seed data with 28 vocabulary entries including transitive/intransitive verb pairs
- Full i18n support (EN, DE, JA) for all new UI elements
2026-04-28 21:57:34 +02:00

465 lines
12 KiB
JavaScript

import { SoundManager } from './SoundManager.js';
import { evaluateStroke } from './StrokeEvaluator.js';
import { createConfig } from './StrokeConfig.js';
export const KANJI_CONSTANTS = {
BASE_SIZE: 109,
SVG_NS: 'http://www.w3.org/2000/svg',
API_URL: 'https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji',
STROKE_WIDTH_BASE: 6,
DASH_ARRAY_GRID: [5, 5],
DASH_ARRAY_HINT: [10, 15],
ANIMATION_DURATION: 300,
SAMPLE_POINTS: 60,
COLORS: {
USER: { r: 255, g: 118, b: 117 },
FINAL: { r: 0, g: 206, b: 201 },
HINT: '#3f3f46',
GRID: 'rgba(255, 255, 255, 0.08)',
},
};
export class KanjiController {
constructor(options = {}) {
this.size = options.size || 300;
this.onComplete = options.onComplete || (() => {});
this.onMistake = options.onMistake || (() => {});
// Stroke evaluation config (from StrokeConfig presets)
this.config = options.config || createConfig('medium');
this.lastEvaluation = null;
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
this.paths = [];
this.currentStrokeIdx = 0;
this.mistakes = 0;
this.userPath = [];
this.isDrawing = false;
this.isAnimating = false;
this.ctx = { bg: null, hint: null, draw: null };
this.hintAnimationFrame = null;
}
static createPathElement(d) {
const path = document.createElementNS(KANJI_CONSTANTS.SVG_NS, 'path');
path.setAttribute('d', d);
return path;
}
static setContextDefaults(ctx) {
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
}
static resamplePoints(points, count) {
if (!points || points.length === 0) return [];
let totalLen = 0;
const dists = [0];
points.slice(1).forEach((p, i) => {
const prev = points[i];
const d = Math.hypot(p.x - prev.x, p.y - prev.y);
totalLen += d;
dists.push(totalLen);
});
const step = totalLen / (count - 1);
return Array.from({ length: count }).map((_, i) => {
const targetDist = i * step;
let idx = dists.findIndex((d) => d >= targetDist);
if (idx === -1) idx = dists.length - 1;
if (idx > 0) idx -= 1;
if (idx >= points.length - 1) {
return points[points.length - 1];
}
const dStart = dists[idx];
const dEnd = dists[idx + 1];
const segmentLen = dEnd - dStart;
const t = segmentLen === 0 ? 0 : (targetDist - dStart) / segmentLen;
const p1 = points[idx];
const p2 = points[idx + 1];
return {
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t,
};
});
}
mount(canvasRefs) {
this.ctx.bg = canvasRefs.bg.getContext('2d');
this.ctx.hint = canvasRefs.hint.getContext('2d');
this.ctx.draw = canvasRefs.draw.getContext('2d');
this.resize(this.size);
}
setConfig(config) {
this.config = config;
}
getLastEvaluation() {
return this.lastEvaluation;
}
resize(newSize) {
this.size = newSize;
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
Object.values(this.ctx).forEach((ctx) => {
if (ctx && ctx.canvas) {
ctx.canvas.width = this.size;
ctx.canvas.height = this.size;
KanjiController.setContextDefaults(ctx);
}
});
this.drawGrid();
if (this.paths.length) {
this.redrawAllPerfectStrokes();
}
}
setAutoHint(val) {
this.autoHint = val;
if (!val) {
this.clearCanvas(this.ctx.hint);
}
}
async loadChar(char, autoHint = false) {
this.autoHint = autoHint;
this.paths = [];
this.reset();
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
try {
const res = await fetch(`${KANJI_CONSTANTS.API_URL}/${hex}.svg`);
const txt = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(txt, 'image/svg+xml');
this.paths = Array.from(doc.getElementsByTagName('path')).map((p) => p.getAttribute('d'));
this.drawGrid();
if (autoHint) this.showHint();
} catch (e) {
console.error('Failed to load Kanji:', e);
}
}
reset() {
if (this.hintAnimationFrame) {
cancelAnimationFrame(this.hintAnimationFrame);
this.hintAnimationFrame = null;
}
this.currentStrokeIdx = 0;
this.mistakes = 0;
this.isAnimating = false;
this.userPath = [];
this.clearCanvas(this.ctx.draw);
this.clearCanvas(this.ctx.hint);
this.drawGrid();
this.resetDrawStyle();
if (this.autoHint && this.paths.length > 0) {
this.showHint();
}
}
startStroke(point) {
if (this.currentStrokeIdx >= this.paths.length || this.isAnimating) return;
this.isDrawing = true;
this.userPath = [point];
this.ctx.draw.beginPath();
this.ctx.draw.moveTo(point.x, point.y);
this.resetDrawStyle();
}
moveStroke(point) {
if (!this.isDrawing) return;
this.userPath.push(point);
this.ctx.draw.lineTo(point.x, point.y);
this.ctx.draw.stroke();
}
endStroke() {
if (!this.isDrawing) return;
this.isDrawing = false;
this.validateStroke();
}
drawGrid() {
if (!this.ctx.bg) return;
const ctx = this.ctx.bg;
this.clearCanvas(ctx);
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.GRID;
ctx.lineWidth = 1;
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_GRID);
const s = this.size;
ctx.beginPath();
ctx.moveTo(s / 2, 0); ctx.lineTo(s / 2, s);
ctx.moveTo(0, s / 2); ctx.lineTo(s, s / 2);
ctx.stroke();
}
showHint() {
if (this.hintAnimationFrame) {
cancelAnimationFrame(this.hintAnimationFrame);
this.hintAnimationFrame = null;
}
this.clearCanvas(this.ctx.hint);
if (this.currentStrokeIdx >= this.paths.length) return;
const d = this.paths[this.currentStrokeIdx];
const pathEl = KanjiController.createPathElement(d);
const len = pathEl.getTotalLength();
const ctx = this.ctx.hint;
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.HINT;
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_HINT);
const startTime = performance.now();
const DURATION = 600;
const animate = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / DURATION, 1);
this.clearCanvas(ctx);
ctx.beginPath();
const drawLen = len * progress;
const step = 5;
let first = true;
for (let dist = 0; dist <= drawLen; dist += step) {
const pt = pathEl.getPointAtLength(dist);
if (first) {
ctx.moveTo(pt.x * this.scale, pt.y * this.scale);
first = false;
} else {
ctx.lineTo(pt.x * this.scale, pt.y * this.scale);
}
}
if (drawLen > 0) {
const pt = pathEl.getPointAtLength(drawLen);
if (first) ctx.moveTo(pt.x * this.scale, pt.y * this.scale);
else ctx.lineTo(pt.x * this.scale, pt.y * this.scale);
}
ctx.stroke();
if (progress < 1) {
this.hintAnimationFrame = requestAnimationFrame(animate);
} else {
this.hintAnimationFrame = null;
}
};
this.hintAnimationFrame = requestAnimationFrame(animate);
}
redrawAllPerfectStrokes(includeCurrent = false) {
const ctx = this.ctx.draw;
this.clearCanvas(ctx);
ctx.save();
ctx.scale(this.scale, this.scale);
const { r, g, b } = KANJI_CONSTANTS.COLORS.FINAL;
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE / this.scale;
ctx.setLineDash([]);
const limit = includeCurrent ? this.currentStrokeIdx + 1 : this.currentStrokeIdx;
this.paths.slice(0, limit).forEach((d) => {
ctx.stroke(new Path2D(d));
});
ctx.restore();
if (!this.isAnimating) this.resetDrawStyle();
}
clearCanvas(ctx) {
if (ctx) ctx.clearRect(0, 0, this.size, this.size);
}
resetDrawStyle() {
const { r, g, b } = KANJI_CONSTANTS.COLORS.USER;
if (this.ctx.draw) {
this.ctx.draw.strokeStyle = `rgb(${r}, ${g}, ${b})`;
this.ctx.draw.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
this.ctx.draw.setLineDash([]);
}
}
validateStroke() {
const targetD = this.paths[this.currentStrokeIdx];
const pathEl = KanjiController.createPathElement(targetD);
const result = evaluateStroke(
this.userPath,
pathEl,
this.scale,
this.config,
{ isFirstStroke: this.currentStrokeIdx === 0 },
);
this.lastEvaluation = result;
if (result.pass) {
if (this.hintAnimationFrame) {
cancelAnimationFrame(this.hintAnimationFrame);
this.hintAnimationFrame = null;
}
this.animateMorph(this.userPath, targetD, () => {
this.currentStrokeIdx += 1;
this.mistakes = 0;
this.redrawAllPerfectStrokes();
this.clearCanvas(this.ctx.hint);
if (this.currentStrokeIdx >= this.paths.length) {
SoundManager.playDrawingComplete();
this.onComplete();
} else {
if (this.autoHint) {
this.showHint();
}
}
});
} else {
SoundManager.playStrokeError();
this.mistakes += 1;
this.animateErrorFade(this.userPath, () => {
this.redrawAllPerfectStrokes();
const needsHint = this.mistakes >= 3;
if (needsHint) this.showHint();
this.onMistake(needsHint);
});
}
}
// Old checkMatch, normalizeAngle, classifyOrientation, measureCurvature
// have been replaced by StrokeEvaluator.evaluateStroke().
animateErrorFade(userPath, onComplete) {
this.isAnimating = true;
const startTime = performance.now();
const DURATION = 300;
const tick = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / DURATION, 1);
const opacity = 1 - progress;
this.redrawAllPerfectStrokes();
if (opacity > 0) {
const ctx = this.ctx.draw;
ctx.save();
ctx.beginPath();
const { r, g, b } = KANJI_CONSTANTS.COLORS.USER;
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (userPath.length > 0) {
ctx.moveTo(userPath[0].x, userPath[0].y);
userPath.slice(1).forEach((p) => ctx.lineTo(p.x, p.y));
}
ctx.stroke();
ctx.restore();
}
if (progress < 1) {
requestAnimationFrame(tick);
} else {
this.isAnimating = false;
this.resetDrawStyle();
onComplete();
}
};
requestAnimationFrame(tick);
}
animateMorph(userPoints, targetD, onComplete) {
this.isAnimating = true;
const targetPoints = this.getSvgPoints(targetD, KANJI_CONSTANTS.SAMPLE_POINTS);
const startPoints = KanjiController.resamplePoints(userPoints, KANJI_CONSTANTS.SAMPLE_POINTS);
const startTime = performance.now();
const { USER, FINAL } = KANJI_CONSTANTS.COLORS;
const tick = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / KANJI_CONSTANTS.ANIMATION_DURATION, 1);
const ease = 1 - (1 - progress) ** 3;
this.redrawAllPerfectStrokes(false);
const r = USER.r + (FINAL.r - USER.r) * ease;
const g = USER.g + (FINAL.g - USER.g) * ease;
const b = USER.b + (FINAL.b - USER.b) * ease;
const ctx = this.ctx.draw;
ctx.strokeStyle = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
ctx.beginPath();
Array.from({ length: KANJI_CONSTANTS.SAMPLE_POINTS }).forEach((_, i) => {
const sx = startPoints[i].x;
const sy = startPoints[i].y;
const ex = targetPoints[i].x;
const ey = targetPoints[i].y;
const cx = sx + (ex - sx) * ease;
const cy = sy + (ey - sy) * ease;
if (i === 0) ctx.moveTo(cx, cy);
else ctx.lineTo(cx, cy);
});
ctx.stroke();
if (progress < 1) {
requestAnimationFrame(tick);
} else {
this.isAnimating = false;
this.resetDrawStyle();
onComplete();
}
};
requestAnimationFrame(tick);
}
getSvgPoints(d, count) {
const path = KanjiController.createPathElement(d);
const len = path.getTotalLength();
return Array.from({ length: count }).map((_, i) => {
const pt = path.getPointAtLength((i / (count - 1)) * len);
return { x: pt.x * this.scale, y: pt.y * this.scale };
});
}
}