feat: add vocabulary browser with filters, audio playback, and example sentences
Release Build / build-docker (push) Successful in 2m44s
Release Build / build-android-and-release (push) Failing after 1m27s

- 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
This commit is contained in:
2026-04-28 21:57:34 +02:00
parent c1651550d1
commit b1d4f9f71b
21 changed files with 2403 additions and 222 deletions
+26 -185
View File
@@ -1,4 +1,6 @@
import { SoundManager } from './SoundManager.js';
import { evaluateStroke } from './StrokeEvaluator.js';
import { createConfig } from './StrokeConfig.js';
export const KANJI_CONSTANTS = {
BASE_SIZE: 109,
@@ -12,10 +14,6 @@ export const KANJI_CONSTANTS = {
ANIMATION_DURATION: 300,
SAMPLE_POINTS: 60,
VALIDATION: {
SAMPLES: 20,
},
COLORS: {
USER: { r: 255, g: 118, b: 117 },
FINAL: { r: 0, g: 206, b: 201 },
@@ -27,10 +25,13 @@ export const KANJI_CONSTANTS = {
export class KanjiController {
constructor(options = {}) {
this.size = options.size || 300;
this.accuracy = options.accuracy || 10;
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;
@@ -101,8 +102,12 @@ export class KanjiController {
this.resize(this.size);
}
setAccuracy(val) {
this.accuracy = val;
setConfig(config) {
this.config = config;
}
getLastEvaluation() {
return this.lastEvaluation;
}
resize(newSize) {
@@ -306,12 +311,19 @@ export class KanjiController {
validateStroke() {
const targetD = this.paths[this.currentStrokeIdx];
const userNormalized = this.userPath.map((p) => ({
x: p.x / this.scale,
y: p.y / this.scale,
}));
const pathEl = KanjiController.createPathElement(targetD);
if (this.checkMatch(userNormalized, 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;
@@ -345,179 +357,8 @@ export class KanjiController {
}
}
checkMatch(userPts, targetD) {
if (userPts.length < 3) return false;
const pathEl = KanjiController.createPathElement(targetD);
const len = pathEl.getTotalLength();
const { SAMPLES } = KANJI_CONSTANTS.VALIDATION;
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
// ── 1. Start / end point check ──────────────────
const targetStart = pathEl.getPointAtLength(0);
const targetEnd = pathEl.getPointAtLength(len);
const userStart = userPts[0];
const userEnd = userPts[userPts.length - 1];
const isFirstStroke = this.currentStrokeIdx === 0;
const startThreshold = this.accuracy * (isFirstStroke ? 3.5 : 2.5);
// End tolerance scales with stroke length — long strokes get more forgiveness
const lengthBonus = Math.min(len / 30, 1.5);
const endThreshold = this.accuracy * (2.5 + lengthBonus);
if (dist(userStart, targetStart) > startThreshold) return false;
if (dist(userEnd, targetEnd) > endThreshold) return false;
// ── 2. Overall direction check ──────────────────
const userAngle = Math.atan2(userEnd.y - userStart.y, userEnd.x - userStart.x);
const targetAngle = Math.atan2(targetEnd.y - targetStart.y, targetEnd.x - targetStart.x);
const angleDiff = KanjiController.normalizeAngle(userAngle - targetAngle);
// Reject if direction is off by more than 90 degrees
if (angleDiff > Math.PI / 2) return false;
// ── 3. Orientation classification guard ─────────
const userOrientation = KanjiController.classifyOrientation(userStart, userEnd);
const targetOrientation = KanjiController.classifyOrientation(targetStart, targetEnd);
// Only block if both strokes are clearly H or V (not diagonal or dot)
if (userOrientation !== 'other' && targetOrientation !== 'other'
&& userOrientation !== targetOrientation) {
return false;
}
// ── 4. Sequential sample comparison ─────────────
const userResampled = KanjiController.resamplePoints(userPts, SAMPLES + 1);
let totalError = 0;
let maxError = 0;
for (let i = 0; i <= SAMPLES; i++) {
const t = i / SAMPLES;
const targetPt = pathEl.getPointAtLength(t * len);
const userPt = userResampled[i];
const d = dist(targetPt, userPt);
totalError += d;
if (d > maxError) maxError = d;
}
const avgError = totalError / (SAMPLES + 1);
const avgDistThreshold = this.accuracy * 1.0;
const maxDistThreshold = this.accuracy * 3.5;
if (avgError > avgDistThreshold) return false;
// No single point should be wildly off — catches "right average, wrong shape"
if (maxError > maxDistThreshold) return false;
// ── 4b. Stroke length ratio ─────────────────────
let userLen = 0;
for (let i = 1; i < userPts.length; i++) {
userLen += dist(userPts[i - 1], userPts[i]);
}
const lengthRatio = userLen / len;
// User stroke should be between 30% and 300% of target length
// Long straight strokes especially benefit from the upper bound
if (lengthRatio < 0.3 || lengthRatio > 3.0) return false;
// ── 4c. Curvature similarity ────────────────────
const targetCurvature = KanjiController.measureCurvature(
userResampled.map((_, i) => {
const t = i / SAMPLES;
const pt = pathEl.getPointAtLength(t * len);
return { x: pt.x, y: pt.y };
}),
);
const userCurvature = KanjiController.measureCurvature(userPts);
// If target is clearly curved but user drew straight (or vice versa)
const curvatureDiff = Math.abs(targetCurvature - userCurvature);
// Scale threshold: strict = 0.2, loose = 0.5
const curvatureThreshold = 0.15 + (this.accuracy / 20) * 0.35;
if (curvatureDiff > curvatureThreshold) return false;
// ── 5. Endpoint direction check (hook detection) ─
const tailFraction = 0.2;
const targetTailStart = pathEl.getPointAtLength(len * (1 - tailFraction));
const targetTailAngle = Math.atan2(
targetEnd.y - targetTailStart.y,
targetEnd.x - targetTailStart.x,
);
// Check if target has a significant direction change at the end (hook)
const mainTargetAngle = Math.atan2(
targetEnd.y - targetStart.y,
targetEnd.x - targetStart.x,
);
const targetHookAngle = KanjiController.normalizeAngle(targetTailAngle - mainTargetAngle);
const hasHook = targetHookAngle > Math.PI / 6; // > 30 degrees = hook
if (hasHook) {
// User must also have a direction change in the last portion
const tailIdx = Math.max(0, Math.floor(userPts.length * (1 - tailFraction)));
const userTailStart = userPts[tailIdx];
const userTailAngle = Math.atan2(
userEnd.y - userTailStart.y,
userEnd.x - userTailStart.x,
);
const mainUserAngle = Math.atan2(
userEnd.y - userStart.y,
userEnd.x - userStart.x,
);
const userHookAngle = KanjiController.normalizeAngle(userTailAngle - mainUserAngle);
// User's hook should be at least half as pronounced as the target's
// but use a generous threshold scaled by accuracy
const hookThreshold = Math.max(Math.PI / 12, targetHookAngle * 0.4);
if (userHookAngle < hookThreshold) {
// Only reject on strict accuracy (< 12), forgive on loose settings
if (this.accuracy < 12) return false;
}
}
return true;
}
// ── Static helpers for stroke validation ────────────
static normalizeAngle(angle) {
let a = Math.abs(angle);
if (a > Math.PI) a = 2 * Math.PI - a;
return a;
}
static classifyOrientation(start, end) {
const dx = Math.abs(end.x - start.x);
const dy = Math.abs(end.y - start.y);
const totalDist = Math.hypot(dx, dy);
// Very short strokes (dots) — don't classify
if (totalDist < 5) return 'other';
const ratio = dx / (dy || 0.001);
if (ratio > 2.5) return 'horizontal';
if (ratio < 0.4) return 'vertical';
return 'other'; // diagonal or ambiguous
}
static measureCurvature(points) {
if (points.length < 3) return 0;
// Curvature = ratio of actual path length to straight-line distance
// A perfectly straight line = 0, a semicircle ≈ 0.57
let pathLen = 0;
for (let i = 1; i < points.length; i++) {
pathLen += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
}
const straightDist = Math.hypot(
points[points.length - 1].x - points[0].x,
points[points.length - 1].y - points[0].y,
);
if (straightDist < 1) return 0; // dot-like stroke
// Returns 0 for straight, >0 for curved (ratio - 1)
return Math.max(0, (pathLen / straightDist) - 1);
}
// Old checkMatch, normalizeAngle, classifyOrientation, measureCurvature
// have been replaced by StrokeEvaluator.evaluateStroke().
animateErrorFade(userPath, onComplete) {
this.isAnimating = true;