b1d4f9f71b
- 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
465 lines
12 KiB
JavaScript
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 };
|
|
});
|
|
}
|
|
}
|