add new lesson mode and started code refraction

This commit is contained in:
Rene Kievits
2025-12-20 04:31:15 +01:00
parent 6438660b03
commit 4428a2b7be
101 changed files with 12255 additions and 8172 deletions

View File

@@ -0,0 +1,411 @@
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,
VALIDATION: {
SAMPLES: 10,
},
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.accuracy = options.accuracy || 10;
this.onComplete = options.onComplete || (() => {});
this.onMistake = options.onMistake || (() => {});
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 };
}
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);
}
setAccuracy(val) {
this.accuracy = val;
}
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();
}
}
async loadChar(char, autoHint = false) {
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() {
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();
}
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() {
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.beginPath();
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.HINT;
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_HINT);
const step = 5;
const count = Math.floor(len / step) + 1;
Array.from({ length: count }).forEach((_, i) => {
const dist = Math.min(i * step, len);
const pt = pathEl.getPointAtLength(dist);
if (i === 0) ctx.moveTo(pt.x * this.scale, pt.y * this.scale);
else ctx.lineTo(pt.x * this.scale, pt.y * this.scale);
});
ctx.stroke();
}
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 userNormalized = this.userPath.map((p) => ({
x: p.x / this.scale,
y: p.y / this.scale,
}));
if (this.checkMatch(userNormalized, targetD)) {
this.animateMorph(this.userPath, targetD, () => {
this.currentStrokeIdx += 1;
this.mistakes = 0;
this.redrawAllPerfectStrokes();
if (this.currentStrokeIdx >= this.paths.length) {
this.onComplete();
}
});
} else {
this.mistakes += 1;
this.animateErrorFade(this.userPath, () => {
this.redrawAllPerfectStrokes();
const needsHint = this.mistakes >= 3;
if (needsHint) this.showHint();
this.onMistake(needsHint);
});
}
}
checkMatch(userPts, targetD) {
if (userPts.length < 3) return false;
const pathEl = KanjiController.createPathElement(targetD);
const len = pathEl.getTotalLength();
const { SAMPLES } = KANJI_CONSTANTS.VALIDATION;
const avgDistThreshold = this.accuracy * 0.8;
const startEndThreshold = this.accuracy * 2.5;
const targetStart = pathEl.getPointAtLength(0);
const targetEnd = pathEl.getPointAtLength(len);
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
if (dist(userPts[0], targetStart) > startEndThreshold) return false;
if (dist(userPts[userPts.length - 1], targetEnd) > startEndThreshold) return false;
let totalError = 0;
const sampleCount = SAMPLES + 1;
totalError = Array.from({ length: sampleCount }).reduce((acc, _, i) => {
const targetPt = pathEl.getPointAtLength((i / SAMPLES) * len);
const minD = userPts.reduce((min, up) => Math.min(min, dist(targetPt, up)), Infinity);
return acc + minD;
}, 0);
return (totalError / sampleCount) < avgDistThreshold;
}
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 };
});
}
}