kanji-recognizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kanji Recognizer Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,355 @@
1
+ # Kanji Recognizer
2
+
3
+ A lightweight, dependency-free JavaScript library for Kanji stroke order recognition and validation using KanjiVG data.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/kanji-recognizer.svg)](https://www.npmjs.com/package/kanji-recognizer)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## ✨ Features
9
+
10
+ - **🎯 Accurate Recognition** - Uses geometric resampling algorithm to compare user strokes against KanjiVG data
11
+ - **📝 Stroke-by-Stroke Validation** - Enforces correct stroke order for proper kanji learning
12
+ - **🎨 Fully Customizable** - Easy to style colors, animations, and recognition sensitivity
13
+ - **⚡ Lightweight** - Zero dependencies, pure SVG-based rendering
14
+ - **📱 Mobile-Friendly** - Touch-optimized with pointer events
15
+ - **🌐 Browser Compatible** - Works in Chrome, Firefox, Safari, and Edge
16
+
17
+ ## 📦 Installation
18
+
19
+ ```bash
20
+ npm install kanji-recognizer
21
+ ```
22
+
23
+ Or use directly in browser:
24
+
25
+ ```html
26
+ <script type="module">
27
+ import { KanjiWriter, KanjiVGParser } from './kanji-recognizer/src/index.js';
28
+ </script>
29
+ ```
30
+
31
+ ## 🚀 Quick Start
32
+
33
+ ```javascript
34
+ import { KanjiWriter, KanjiVGParser } from 'kanji-recognizer';
35
+
36
+ // 1. Fetch kanji data from KanjiVG
37
+ const kanjiData = await KanjiVGParser.fetchData('日');
38
+
39
+ // 2. Create a writer instance
40
+ const writer = new KanjiWriter('container-id', kanjiData, {
41
+ width: 300,
42
+ height: 300
43
+ });
44
+
45
+ // 3. Listen for events
46
+ writer.onCorrect = () => console.log("Correct stroke!");
47
+ writer.onComplete = () => console.log("Kanji complete!");
48
+ ```
49
+
50
+ ## 📖 API Reference
51
+
52
+ ### KanjiWriter
53
+
54
+ Creates an interactive kanji writing canvas.
55
+
56
+ ```javascript
57
+ const writer = new KanjiWriter(elementId, kanjiData, options);
58
+ ```
59
+
60
+ #### Parameters
61
+
62
+ - **elementId** `string` - DOM element ID to mount the canvas
63
+ - **kanjiData** `string[]` - Array of SVG path strings from KanjiVG
64
+ - **options** `object` - Configuration options (optional)
65
+
66
+ #### Options
67
+
68
+ ```javascript
69
+ {
70
+ // Dimensions
71
+ width: 300, // Canvas width in pixels
72
+ height: 300, // Canvas height in pixels
73
+
74
+ // Colors
75
+ strokeColor: '#333', // Main stroke color
76
+ correctColor: '#4CAF50', // Success feedback color
77
+ incorrectColor: '#F44336', // Error feedback color
78
+ hintColor: 'cyan', // Hint animation color
79
+ gridColor: '#ddd', // Background grid color
80
+ ghostColor: '#ff0000', // Next stroke guide color
81
+
82
+ // Appearance
83
+ strokeWidth: 4, // Width of drawn strokes
84
+ gridWidth: 0.5, // Grid line width
85
+ ghostOpacity: '0.1', // Ghost guide opacity
86
+
87
+ // Behavior
88
+ showGhost: true, // Show red guide for next stroke
89
+ showGrid: true, // Show background grid
90
+
91
+ // Recognition (Adjustable!)
92
+ passThreshold: 15, // Lower = stricter (10-20 recommended)
93
+ startDistThreshold: 40, // Start point tolerance in pixels
94
+ lengthRatioMin: 0.5, // Minimum stroke length ratio
95
+ lengthRatioMax: 1.5, // Maximum stroke length ratio
96
+
97
+ // Animations
98
+ stepDuration: 500, // Animation speed in ms
99
+ hintDuration: 800, // Hint display duration
100
+ snapDuration: 200 // Snap-to-correct duration
101
+ }
102
+ ```
103
+
104
+ #### Methods
105
+
106
+ ##### `clear()`
107
+ Reset the canvas and start over.
108
+
109
+ ```javascript
110
+ writer.clear();
111
+ ```
112
+
113
+ ##### `hint()`
114
+ Show animated hint for the next expected stroke.
115
+
116
+ ```javascript
117
+ writer.hint();
118
+ ```
119
+
120
+ ##### `animate()`
121
+ Animate the complete kanji stroke-by-stroke.
122
+
123
+ ```javascript
124
+ await writer.animate();
125
+ ```
126
+
127
+ ##### `setOptions(newOptions)`
128
+ Update configuration options dynamically.
129
+
130
+ ```javascript
131
+ writer.setOptions({
132
+ strokeColor: '#000',
133
+ passThreshold: 20 // Make recognition more lenient
134
+ });
135
+ ```
136
+
137
+ ##### `destroy()`
138
+ Clean up resources and remove event listeners.
139
+
140
+ ```javascript
141
+ writer.destroy();
142
+ ```
143
+
144
+ #### Event Callbacks
145
+
146
+ ```javascript
147
+ writer.onCorrect = () => { /* Fired on correct stroke */ };
148
+ writer.onIncorrect = () => { /* Fired on incorrect stroke */ };
149
+ writer.onComplete = () => { /* Fired when kanji is complete */ };
150
+ writer.onClear = () => { /* Fired when canvas is cleared */ };
151
+
152
+ // Error handling
153
+ writer.container.addEventListener('kanji:error', (e) => {
154
+ console.error('Error:', e.detail);
155
+ });
156
+ ```
157
+
158
+ ### KanjiVGParser
159
+
160
+ Utilities for fetching and parsing KanjiVG SVG data.
161
+
162
+ #### `KanjiVGParser.fetchData(char)`
163
+
164
+ Fetch kanji stroke data by character.
165
+
166
+ ```javascript
167
+ const strokes = await KanjiVGParser.fetchData('日');
168
+ // Returns: ['M25,32...', 'M12,80...']
169
+ ```
170
+
171
+ #### `KanjiVGParser.parse(svgContent)`
172
+
173
+ Parse raw KanjiVG SVG into stroke paths.
174
+
175
+ ```javascript
176
+ const strokes = KanjiVGParser.parse(svgString);
177
+ ```
178
+
179
+ #### `KanjiVGParser.baseUrl`
180
+
181
+ Set custom base URL for KanjiVG files.
182
+
183
+ ```javascript
184
+ KanjiVGParser.baseUrl = 'https://example.com/kanjivg/';
185
+ ```
186
+
187
+ ## 💡 Usage Examples
188
+
189
+ ### Basic Kanji Practice
190
+
191
+ ```javascript
192
+ import { KanjiWriter, KanjiVGParser } from 'kanji-recognizer';
193
+
194
+ // Load kanji
195
+ const kanjiData = await KanjiVGParser.fetchData('愛');
196
+
197
+ // Create writer
198
+ const writer = new KanjiWriter('practice-area', kanjiData);
199
+
200
+ // Add event listeners
201
+ writer.onCorrect = () => {
202
+ document.getElementById('feedback').textContent = '正解!';
203
+ };
204
+
205
+ writer.onComplete = () => {
206
+ document.getElementById('feedback').textContent = '完成!';
207
+ confetti(); // Celebrate!
208
+ };
209
+ ```
210
+
211
+ ### Beginner Mode (Lenient Recognition)
212
+
213
+ ```javascript
214
+ const writer = new KanjiWriter('container', kanjiData, {
215
+ passThreshold: 20, // More forgiving
216
+ startDistThreshold: 50, // Allow imprecise starts
217
+ showGhost: true, // Show guides
218
+ hintColor: '#00ff00' // Bright hints
219
+ });
220
+ ```
221
+
222
+ ### Expert Mode (Strict Recognition)
223
+
224
+ ```javascript
225
+ const writer = new KanjiWriter('container', kanjiData, {
226
+ passThreshold: 8, // Very strict
227
+ startDistThreshold: 25, // Precise starts required
228
+ showGhost: false, // No guides
229
+ strokeColor: '#000' // Professional look
230
+ });
231
+ ```
232
+
233
+ ### Custom Styling
234
+
235
+ ```javascript
236
+ const writer = new KanjiWriter('container', kanjiData, {
237
+ width: 500,
238
+ height: 500,
239
+ strokeColor: '#2c3e50',
240
+ correctColor: '#27ae60',
241
+ incorrectColor: '#e74c3c',
242
+ gridColor: '#ecf0f1',
243
+ strokeWidth: 6
244
+ });
245
+ ```
246
+
247
+ ### Multiple Kanji Practice
248
+
249
+ ```javascript
250
+ const kanjis = ['日', '月', '火', '水', '木', '金', '土'];
251
+ let currentIndex = 0;
252
+
253
+ async function nextKanji() {
254
+ if (writer) writer.destroy(); // Clean up previous
255
+
256
+ const data = await KanjiVGParser.fetchData(kanjis[currentIndex]);
257
+ writer = new KanjiWriter('container', data);
258
+
259
+ writer.onComplete = () => {
260
+ currentIndex++;
261
+ if (currentIndex < kanjis.length) {
262
+ setTimeout(nextKanji, 1000);
263
+ }
264
+ };
265
+ }
266
+
267
+ nextKanji();
268
+ ```
269
+
270
+ ## 🎨 Demo
271
+
272
+ Check out the included demo:
273
+
274
+ ```bash
275
+ cd kanji-recognizer
276
+ python3 -m http.server 8000
277
+ # Open http://localhost:8000/demo/index.html
278
+ ```
279
+
280
+ ## 🔧 Requirements
281
+
282
+ - Modern browser with ES6 module support
283
+ - KanjiVG SVG files (not included in npm package)
284
+
285
+ ### Getting KanjiVG Data
286
+
287
+ Download from [KanjiVG Project](https://github.com/KanjiVG/kanjivg):
288
+
289
+ ```bash
290
+ git clone https://github.com/KanjiVG/kanjivg.git
291
+ ```
292
+
293
+ Or use a CDN:
294
+
295
+ ```javascript
296
+ KanjiVGParser.baseUrl = 'https://cdn.example.com/kanjivg/';
297
+ ```
298
+
299
+ ## 🌐 Browser Support
300
+
301
+ - Chrome/Edge: ✅ Latest
302
+ - Firefox: ✅ Latest
303
+ - Safari: ✅ 14+
304
+ - Safari iOS: ✅ 14+
305
+ - Chrome Mobile: ✅ Latest
306
+
307
+ ## 🤝 Contributing
308
+
309
+ Contributions are welcome! Please:
310
+
311
+ 1. Fork the repository
312
+ 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
313
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
314
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
315
+ 5. Open a Pull Request
316
+
317
+ See [docs/README_REVIEW.md](docs/README_REVIEW.md) for development documentation.
318
+
319
+ ## 📄 License
320
+
321
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
322
+
323
+ ## 🙏 Acknowledgments
324
+
325
+ - [KanjiVG](https://kanjivg.tagaini.net/) - Kanji stroke order data
326
+ - Inspired by Japanese language learning tools
327
+
328
+ ## 📚 Related Projects
329
+
330
+ - [KanjiVG](https://github.com/KanjiVG/kanjivg) - Kanji stroke order graphics
331
+ - [kanji-data](https://github.com/davidluzgouveia/kanji-data) - Comprehensive kanji dataset
332
+
333
+ ## 🐛 Known Issues
334
+
335
+ - None currently! Report issues on [GitHub](https://github.com/mxggle/kanji-recognizer/issues)
336
+
337
+ ## 📈 Roadmap
338
+
339
+ - [ ] TypeScript definitions
340
+ - [ ] React component wrapper
341
+ - [ ] Vue component wrapper
342
+ - [ ] Bundled common kanji data
343
+ - [ ] Offline support with Service Worker
344
+ - [ ] Haptic feedback for mobile
345
+ - [ ] Audio pronunciation integration
346
+
347
+ ## 💬 Support
348
+
349
+ - 📧 Email: your.email@example.com
350
+ - 🐛 Issues: [GitHub Issues](https://github.com/mxggle/kanji-recognizer/issues)
351
+ - 💬 Discussions: [GitHub Discussions](https://github.com/mxggle/kanji-recognizer/discussions)
352
+
353
+ ---
354
+
355
+ **Made with ❤️ for Japanese learners worldwide**
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "kanji-recognizer",
3
+ "version": "0.1.0",
4
+ "description": "A robust Kanji stroke order recognition and validation library using KanjiVG data.",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./src/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "start": "vite",
19
+ "build": "vite build",
20
+ "dev": "vite"
21
+ },
22
+ "keywords": [
23
+ "kanji",
24
+ "recognition",
25
+ "stroke-order",
26
+ "japanese",
27
+ "kanjivg",
28
+ "handwriting",
29
+ "svg"
30
+ ],
31
+ "author": "Antigravity",
32
+ "license": "MIT",
33
+ "devDependencies": {
34
+ "vite": "^5.0.0"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/mxggle/kanji-recognizer.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/mxggle/kanji-recognizer/issues"
42
+ },
43
+ "homepage": "https://github.com/mxggle/kanji-recognizer#readme"
44
+ }
@@ -0,0 +1,88 @@
1
+ export class GeometryUtil {
2
+ /**
3
+ * Calculate distance between two points
4
+ */
5
+ static distance(p1, p2) {
6
+ const dx = p1.x - p2.x;
7
+ const dy = p1.y - p2.y;
8
+ return Math.sqrt(dx * dx + dy * dy);
9
+ }
10
+
11
+ /**
12
+ * Get total length of a path of points
13
+ */
14
+ static getPathLength(points) {
15
+ let len = 0;
16
+ for (let i = 1; i < points.length; i++) {
17
+ len += this.distance(points[i - 1], points[i]);
18
+ }
19
+ return len;
20
+ }
21
+
22
+ /**
23
+ * Resample points to a fixed number of equidistant points
24
+ */
25
+ static resample(points, numPoints = 64) {
26
+ if (points.length <= 1) return points;
27
+
28
+ const pathLen = this.getPathLength(points);
29
+ const step = pathLen / (numPoints - 1);
30
+
31
+ const newPoints = [points[0]];
32
+ let currentLen = 0;
33
+ let nextStep = step;
34
+
35
+ for (let i = 1; i < points.length; i++) {
36
+ let p1 = points[i - 1];
37
+ let p2 = points[i];
38
+ let dist = this.distance(p1, p2);
39
+
40
+ while (currentLen + dist >= nextStep) {
41
+ let t = (nextStep - currentLen) / dist;
42
+ let newX = p1.x + (p2.x - p1.x) * t;
43
+ let newY = p1.y + (p2.y - p1.y) * t;
44
+ newPoints.push({ x: newX, y: newY });
45
+ nextStep += step;
46
+
47
+ // Safety break if floating point issues cause infinite loop
48
+ if (newPoints.length >= numPoints) break;
49
+ }
50
+ currentLen += dist;
51
+ }
52
+
53
+ while (newPoints.length < numPoints) {
54
+ newPoints.push(points[points.length - 1]);
55
+ }
56
+
57
+ return newPoints;
58
+ }
59
+
60
+ /**
61
+ * Compare two strokes. Returns a score (lower is better, 0 is perfect).
62
+ * Checks shape and direction.
63
+ * @param {Array} userPoints - User's drawn points
64
+ * @param {Array} targetPoints - Target stroke points
65
+ * @param {number} startDistThreshold - Maximum allowed start point distance (default: 40)
66
+ */
67
+ static compareStrokes(userPoints, targetPoints, startDistThreshold = 40) {
68
+ const resampledUser = this.resample(userPoints);
69
+ const resampledTarget = this.resample(targetPoints);
70
+
71
+ // 1. Direction Check: Check if start and end points match roughly
72
+ const startDist = this.distance(resampledUser[0], resampledTarget[0]);
73
+ const endDist = this.distance(resampledUser[resampledUser.length - 1], resampledTarget[resampledTarget.length - 1]);
74
+
75
+ // If start is far from start, it's definitely wrong (or drawn backwards)
76
+ // We penalize this heavily.
77
+ if (startDist > startDistThreshold) return Infinity; // Way off
78
+
79
+ // 2. Shape Check: Average distance between points
80
+ let totalDist = 0;
81
+ for (let i = 0; i < resampledUser.length; i++) {
82
+ totalDist += this.distance(resampledUser[i], resampledTarget[i]);
83
+ }
84
+ const avgDist = totalDist / resampledUser.length;
85
+
86
+ return avgDist;
87
+ }
88
+ }
@@ -0,0 +1,87 @@
1
+ export class KanjiVGParser {
2
+ /**
3
+ * Parse a KanjiVG SVG string and return an array of stroke paths.
4
+ * @param {string} svgContent - The raw XML content of the SVG file
5
+ * @returns {string[]} Array of path data strings (d attributes) ordered by stroke index
6
+ */
7
+ static parse(svgContent) {
8
+ const parser = new DOMParser();
9
+ const doc = parser.parseFromString(svgContent, "image/svg+xml");
10
+
11
+ // KanjiVG stores strokes as paths with ids like "kvg:XXXXX-s1", "kvg:XXXXX-s2", etc.
12
+ // We can find all paths that look like stroke paths.
13
+ // However, sometimes paths are nested in groups.
14
+ // A reliable way is to query all paths and sort them by their id number.
15
+
16
+ const paths = Array.from(doc.querySelectorAll('path[id*="-s"]'));
17
+
18
+ // Sort by the stroke number at the end of the id (e.g. "kvg:04e8c-s1" -> 1)
19
+ paths.sort((a, b) => {
20
+ const getNum = (el) => {
21
+ const match = el.id.match(/-s(\d+)$/);
22
+ return match ? parseInt(match[1], 10) : 999;
23
+ };
24
+ return getNum(a) - getNum(b);
25
+ });
26
+
27
+ return paths.map(p => p.getAttribute('d'));
28
+ }
29
+
30
+ // Default base URL for KanjiVG files (can be local or remote)
31
+ static baseUrl = "kanjivg/kanji/";
32
+
33
+ /**
34
+ * Convert a character to its unicode hex string (lowercase, 5 chars usually).
35
+ * @param {string} char
36
+ * @returns {string} e.g. "6c49"
37
+ */
38
+ static getHex(char) {
39
+ if (typeof char !== 'string' || char.length === 0) {
40
+ throw new Error('Invalid character: must be a non-empty string');
41
+ }
42
+
43
+ let code = char.charCodeAt(0).toString(16);
44
+ while (code.length < 5) code = "0" + code;
45
+ return code;
46
+ }
47
+
48
+ /**
49
+ * Fetch standard KanjiVG data for a given character using the configured baseUrl.
50
+ * @param {string} char - A single kanji character (e.g. "漢") or directly the hex code.
51
+ */
52
+ static async fetchData(char) {
53
+ if (!char || typeof char !== 'string') {
54
+ throw new Error('Invalid character: must be a non-empty string');
55
+ }
56
+
57
+ let hex = char;
58
+ // If it looks like a single character, convert to hex
59
+ if (char.length === 1 && char.charCodeAt(0) > 255) {
60
+ hex = this.getHex(char);
61
+ }
62
+
63
+ // Validate hex format (basic check)
64
+ if (!/^[0-9a-f]{5}$/i.test(hex)) {
65
+ // If not valid hex, might be a character - try converting
66
+ if (char.length === 1) {
67
+ hex = this.getHex(char);
68
+ } else {
69
+ throw new Error(`Invalid kanji hex code: ${hex}`);
70
+ }
71
+ }
72
+
73
+ const url = `${this.baseUrl}${hex}.svg`;
74
+ return this.fetchAndParse(url);
75
+ }
76
+
77
+ /**
78
+ * Fetch a KanjiVG file from a URL/Path and parse it.
79
+ * @param {string} url - URL to the .svg file
80
+ */
81
+ static async fetchAndParse(url) {
82
+ const response = await fetch(url);
83
+ if (!response.ok) throw new Error(`Failed to fetch kanji: ${response.statusText}`);
84
+ const text = await response.text();
85
+ return this.parse(text);
86
+ }
87
+ }
@@ -0,0 +1,392 @@
1
+ import { StrokeRecognizer } from './StrokeRecognizer.js';
2
+
3
+ export class KanjiWriter {
4
+ constructor(elementId, kanjiData, options = {}) {
5
+ this.container = document.getElementById(elementId);
6
+ this.kanjiData = kanjiData; // Expects array of path strings
7
+ this.options = {
8
+ width: 109,
9
+ height: 109,
10
+ // Colors
11
+ strokeColor: "#333", // Main stroke color
12
+ correctColor: "#4CAF50", // Green flash on success
13
+ incorrectColor: "#F44336", // Red stroke on error
14
+ hintColor: "cyan", // Hint animation color
15
+ gridColor: "#ddd", // Background grid color
16
+ ghostColor: "#ff0000", // Color of next stroke ghost
17
+
18
+ // Toggles
19
+ showGhost: true, // Show red guide for next stroke
20
+ showGrid: true, // Show the background grid
21
+
22
+ // Appearance
23
+ strokeWidth: 4,
24
+ gridWidth: 0.5,
25
+ ghostOpacity: "0.1",
26
+
27
+ // Animations
28
+ stepDuration: 500,
29
+ hintDuration: 800,
30
+ snapDuration: 200,
31
+
32
+ ...options
33
+ };
34
+
35
+ // Initialize recognizer with threshold options
36
+ this.recognizer = new StrokeRecognizer({
37
+ passThreshold: this.options.passThreshold || 15,
38
+ startDistThreshold: this.options.startDistThreshold || 40,
39
+ lengthRatioMin: this.options.lengthRatioMin || 0.5,
40
+ lengthRatioMax: this.options.lengthRatioMax || 1.5,
41
+ resamplingPoints: this.options.resamplingPoints || 64
42
+ });
43
+
44
+ this.currentStrokeIndex = 0;
45
+ this.isDrawing = false;
46
+ this.currentPoints = [];
47
+
48
+ // Use options.width/height in initSVG
49
+ this.width = this.options.width;
50
+ this.height = this.options.height;
51
+
52
+ console.log("Kanji data loaded:", kanjiData);
53
+
54
+ this.initSVG();
55
+ this.attachEvents();
56
+ this.drawGrid();
57
+ this.renderUpcomingStrokes(); // Show ghosts or just hint
58
+ }
59
+
60
+ initSVG() {
61
+ this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
62
+ // KanjiVG uses a standard 109x109 viewBox coordinate system
63
+ // We keep this fixed regardless of container size for correct rendering
64
+ this.svg.setAttribute("viewBox", "0 0 109 109");
65
+ this.svg.style.width = "100%";
66
+ this.svg.style.height = "100%";
67
+ this.svg.style.border = "1px solid #ccc";
68
+ this.svg.style.touchAction = "none"; // Prevent scrolling
69
+ this.svg.style.background = "#fff";
70
+
71
+ // Groups
72
+ this.gridGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
73
+ this.bgGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); // For background/ghost characters
74
+ this.drawnGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); // Completed correct strokes
75
+ this.currentGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); // Current active stroke
76
+
77
+ this.svg.appendChild(this.gridGroup);
78
+ this.svg.appendChild(this.bgGroup);
79
+ this.svg.appendChild(this.drawnGroup);
80
+ this.svg.appendChild(this.currentGroup);
81
+
82
+ this.container.appendChild(this.svg);
83
+ }
84
+
85
+ drawGrid() {
86
+ if (!this.options.showGrid) return;
87
+
88
+ const opts = { stroke: this.options.gridColor, "stroke-width": this.options.gridWidth, "stroke-dasharray": "3,3" };
89
+ // Use KanjiVG's 109x109 coordinate system
90
+ const size = 109;
91
+ // Diagonal lines
92
+ this.addLine(0, 0, size, size, this.gridGroup, opts);
93
+ this.addLine(size, 0, 0, size, this.gridGroup, opts);
94
+ // Center lines
95
+ this.addLine(size / 2, 0, size / 2, size, this.gridGroup, opts);
96
+ this.addLine(0, size / 2, size, size / 2, this.gridGroup, opts);
97
+ }
98
+
99
+ addLine(x1, y1, x2, y2, parent, attrs = {}) {
100
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
101
+ line.setAttribute("x1", x1);
102
+ line.setAttribute("y1", y1);
103
+ line.setAttribute("x2", x2);
104
+ line.setAttribute("y2", y2);
105
+ for (const [k, v] of Object.entries(attrs)) {
106
+ line.setAttribute(k, v);
107
+ }
108
+ parent.appendChild(line);
109
+ }
110
+
111
+ renderUpcomingStrokes() {
112
+ // Optional: render faint outline of the next stroke
113
+ this.bgGroup.innerHTML = '';
114
+
115
+ if (!this.options.showGhost) return;
116
+
117
+ // If we want to show the full ghost:
118
+ this.kanjiData.forEach((d, i) => {
119
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
120
+ path.setAttribute("d", d);
121
+ path.setAttribute("fill", "none");
122
+ path.setAttribute("stroke", i === this.currentStrokeIndex ? this.options.ghostColor : "#eee");
123
+ path.setAttribute("stroke-width", "2");
124
+ path.style.opacity = i === this.currentStrokeIndex ? this.options.ghostOpacity : "0.1";
125
+ this.bgGroup.appendChild(path);
126
+ });
127
+ }
128
+
129
+ getPointerPos(e) {
130
+ const pt = this.svg.createSVGPoint();
131
+ pt.x = e.clientX;
132
+ pt.y = e.clientY;
133
+ return pt.matrixTransform(this.svg.getScreenCTM().inverse());
134
+ }
135
+
136
+ attachEvents() {
137
+ // Store bound functions as instance properties for cleanup
138
+ this.boundStart = (e) => {
139
+ if (this.currentStrokeIndex >= this.kanjiData.length) return; // Finished
140
+ e.preventDefault();
141
+ this.isDrawing = true;
142
+ this.currentPoints = [];
143
+ const pos = this.getPointerPos(e);
144
+ this.currentPoints.push(pos);
145
+
146
+ // Start visual path
147
+ this.currentPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
148
+ this.currentPath.setAttribute("fill", "none");
149
+ this.currentPath.setAttribute("stroke", this.options.strokeColor);
150
+ this.currentPath.setAttribute("stroke-width", this.options.strokeWidth);
151
+ this.currentPath.setAttribute("stroke-linecap", "round");
152
+ this.currentPath.setAttribute("stroke-linejoin", "round");
153
+ this.currentPath.setAttribute("d", `M ${pos.x} ${pos.y}`);
154
+ this.currentGroup.appendChild(this.currentPath);
155
+ };
156
+
157
+ this.boundMove = (e) => {
158
+ if (!this.isDrawing) return;
159
+ e.preventDefault();
160
+ const pos = this.getPointerPos(e);
161
+ this.currentPoints.push(pos);
162
+
163
+ // Update visual path
164
+ const d = this.currentPath.getAttribute("d");
165
+ this.currentPath.setAttribute("d", `${d} L ${pos.x} ${pos.y}`);
166
+ };
167
+
168
+ this.boundEnd = (e) => {
169
+ if (!this.isDrawing) return;
170
+ this.isDrawing = false;
171
+ this.evaluateStroke();
172
+ };
173
+
174
+ this.svg.addEventListener('pointerdown', this.boundStart);
175
+ this.svg.addEventListener('pointermove', this.boundMove);
176
+ this.svg.addEventListener('pointerup', this.boundEnd);
177
+ this.svg.addEventListener('pointerleave', this.boundEnd);
178
+ }
179
+
180
+ /**
181
+ * Clean up resources and remove event listeners
182
+ * Call this before destroying the writer instance
183
+ * @public
184
+ */
185
+ destroy() {
186
+ if (this.svg && this.boundStart) {
187
+ this.svg.removeEventListener('pointerdown', this.boundStart);
188
+ this.svg.removeEventListener('pointermove', this.boundMove);
189
+ this.svg.removeEventListener('pointerup', this.boundEnd);
190
+ this.svg.removeEventListener('pointerleave', this.boundEnd);
191
+ }
192
+
193
+ // Clear all content
194
+ if (this.container) {
195
+ this.container.innerHTML = '';
196
+ }
197
+
198
+ // Clear references
199
+ this.boundStart = null;
200
+ this.boundMove = null;
201
+ this.boundEnd = null;
202
+ this.svg = null;
203
+ this.recognizer = null;
204
+ }
205
+
206
+ evaluateStroke() {
207
+ const targetD = this.kanjiData[this.currentStrokeIndex];
208
+ if (!targetD) return;
209
+
210
+ const result = this.recognizer.evaluate(this.currentPoints, targetD);
211
+ console.log("Evaluation result:", result);
212
+
213
+ if (result.success) {
214
+ this.onCorrect();
215
+ } else {
216
+ this.onIncorrect();
217
+ }
218
+ }
219
+
220
+ async onCorrect() {
221
+ // 1. Visual feedback on user stroke (Green)
222
+ this.currentPath.setAttribute("stroke", this.options.correctColor);
223
+
224
+ // 2. Fade out user stroke
225
+ const userPath = this.currentPath;
226
+ userPath.style.transition = "opacity 0.3s";
227
+ userPath.style.opacity = "0";
228
+ setTimeout(() => userPath.remove(), 300);
229
+
230
+ // 3. Animate the perfect stroke taking its place
231
+ const targetD = this.kanjiData[this.currentStrokeIndex];
232
+
233
+ // We block interaction briefly for the animation
234
+ this.isDrawing = false;
235
+
236
+ await this.animateStroke(targetD, {
237
+ duration: this.options.snapDuration, // Fast snap
238
+ color: this.options.strokeColor,
239
+ removeAfter: false,
240
+ targetGroup: this.drawnGroup
241
+ });
242
+
243
+ this.currentStrokeIndex++;
244
+ this.renderUpcomingStrokes();
245
+
246
+ if (this.currentStrokeIndex >= this.kanjiData.length) {
247
+ console.log("Kanji Complete!");
248
+ if (this.onComplete) this.onComplete();
249
+ }
250
+ }
251
+
252
+ onIncorrect() {
253
+ this.currentPath.setAttribute("stroke", this.options.incorrectColor);
254
+ // Fade out and remove
255
+ const pathRef = this.currentPath;
256
+ pathRef.style.transition = "opacity 0.5s";
257
+ setTimeout(() => { pathRef.style.opacity = "0"; }, 100);
258
+ setTimeout(() => { pathRef.remove(); }, 600);
259
+ }
260
+
261
+ /**
262
+ * Public API: Clear the canvas and reset progress
263
+ */
264
+ clear() {
265
+ this.currentStrokeIndex = 0;
266
+ this.drawnGroup.innerHTML = '';
267
+ this.currentGroup.innerHTML = '';
268
+ this.bgGroup.innerHTML = '';
269
+ this.renderUpcomingStrokes();
270
+ console.log("Canvas cleared");
271
+ if (this.onClear) this.onClear();
272
+ }
273
+
274
+ /**
275
+ * Public API: Update options
276
+ */
277
+ setOptions(newOptions) {
278
+ this.options = { ...this.options, ...newOptions };
279
+ this.renderUpcomingStrokes(); // Re-render to reflect changes (e.g. ghost visibility)
280
+ // Need to redraw grid if grid settings changed or toggled
281
+ this.gridGroup.innerHTML = '';
282
+ this.drawGrid();
283
+ }
284
+
285
+ /**
286
+ * Public API: Briefly show the next expected stroke
287
+ */
288
+ hint() {
289
+ if (this.currentStrokeIndex >= this.kanjiData.length) return;
290
+
291
+ const d = this.kanjiData[this.currentStrokeIndex];
292
+ if (!d) return;
293
+
294
+ this.animateStroke(d, {
295
+ duration: this.options.hintDuration,
296
+ color: this.options.hintColor,
297
+ strokeOpacity: "0.5",
298
+ removeAfter: true,
299
+ targetGroup: this.bgGroup
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Public API: Animate the correct strokes
305
+ */
306
+ async animate() {
307
+ this.clear(); // Start fresh
308
+
309
+ // Disable interaction during animation
310
+ const originalPointerEvents = this.svg.style.pointerEvents;
311
+ this.svg.style.pointerEvents = "none";
312
+
313
+ for (let i = 0; i < this.kanjiData.length; i++) {
314
+ await this.animateStroke(this.kanjiData[i], {
315
+ duration: this.options.stepDuration,
316
+ color: this.options.strokeColor,
317
+ removeAfter: false,
318
+ targetGroup: this.drawnGroup
319
+ });
320
+ }
321
+
322
+ // Re-enable interaction
323
+ this.svg.style.pointerEvents = originalPointerEvents;
324
+ this.currentStrokeIndex = this.kanjiData.length; // Mark as done
325
+ if (this.onComplete) this.onComplete();
326
+ }
327
+
328
+ animateStroke(d, options = {}) {
329
+ const {
330
+ duration = 500,
331
+ color = "#333",
332
+ removeAfter = true,
333
+ strokeWidth = 4,
334
+ strokeOpacity = "1",
335
+ targetGroup = this.currentGroup
336
+ } = options;
337
+
338
+ return new Promise((resolve, reject) => {
339
+ try {
340
+ // Validate path data
341
+ if (!d || typeof d !== 'string') {
342
+ throw new Error('Invalid path data: must be a non-empty string');
343
+ }
344
+
345
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
346
+ path.setAttribute("d", d);
347
+ path.setAttribute("fill", "none");
348
+ path.setAttribute("stroke", color);
349
+ path.setAttribute("stroke-width", strokeWidth);
350
+ path.setAttribute("stroke-opacity", strokeOpacity);
351
+ path.setAttribute("stroke-linecap", "round");
352
+ path.setAttribute("stroke-linejoin", "round");
353
+
354
+ targetGroup.appendChild(path);
355
+
356
+ const length = path.getTotalLength();
357
+
358
+ // Validate length
359
+ if (!length || length === 0 || isNaN(length)) {
360
+ path.remove();
361
+ throw new Error('Invalid path: unable to calculate length');
362
+ }
363
+
364
+ path.style.strokeDasharray = length;
365
+ path.style.strokeDashoffset = length; // Hidden initially
366
+
367
+ // Trigger reflow
368
+ path.getBoundingClientRect();
369
+
370
+ path.style.transition = `stroke-dashoffset ${duration}ms ease-in-out`;
371
+ path.style.strokeDashoffset = "0"; // Show
372
+
373
+ setTimeout(() => {
374
+ if (removeAfter) {
375
+ path.remove();
376
+ }
377
+ resolve(path);
378
+ }, duration + 50);
379
+
380
+ } catch (error) {
381
+ console.error('Animation error:', error);
382
+ // Dispatch error event for UI handling
383
+ if (this.container) {
384
+ this.container.dispatchEvent(
385
+ new CustomEvent('kanji:error', { detail: error })
386
+ );
387
+ }
388
+ reject(error);
389
+ }
390
+ });
391
+ }
392
+ }
@@ -0,0 +1,79 @@
1
+ import { GeometryUtil } from './GeometryUtil.js';
2
+
3
+ export class StrokeRecognizer {
4
+ constructor(options = {}) {
5
+ // Configuration options
6
+ this.options = {
7
+ passThreshold: 15, // Average pixel deviation allowed for success
8
+ startDistThreshold: 40, // Max pixels start point can be off
9
+ lengthRatioMin: 0.5, // Minimum length ratio (user/target)
10
+ lengthRatioMax: 1.5, // Maximum length ratio (user/target)
11
+ resamplingPoints: 64, // Number of points to resample to
12
+ ...options
13
+ };
14
+
15
+ // Create or reuse hidden SVG container (singleton pattern)
16
+ if (!StrokeRecognizer.hiddenSVG) {
17
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
18
+ svg.style.position = 'absolute';
19
+ svg.style.visibility = 'hidden';
20
+ svg.style.width = '0';
21
+ svg.style.height = '0';
22
+ svg.setAttribute('aria-hidden', 'true');
23
+ document.body.appendChild(svg);
24
+ StrokeRecognizer.hiddenSVG = svg;
25
+ }
26
+
27
+ // Create measurement path and attach to DOM for reliable calculations
28
+ this.measurePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
29
+ StrokeRecognizer.hiddenSVG.appendChild(this.measurePath);
30
+ }
31
+
32
+ /**
33
+ * Convert an SVG path data string (d attribute) into an array of points.
34
+ */
35
+ getPathPoints(dString, numPoints = null) {
36
+ // Use configured resampling points if not specified
37
+ const pointCount = numPoints !== null ? numPoints : this.options.resamplingPoints;
38
+
39
+ this.measurePath.setAttribute('d', dString);
40
+ const totalLength = this.measurePath.getTotalLength();
41
+ const points = [];
42
+
43
+ for (let i = 0; i < pointCount; i++) {
44
+ const point = this.measurePath.getPointAtLength((i / (pointCount - 1)) * totalLength);
45
+ points.push({ x: point.x, y: point.y });
46
+ }
47
+ return points;
48
+ }
49
+
50
+ /**
51
+ * Evaluate a user's stroke against the target kanji data.
52
+ * @param {Array<{x, y}>} userMsgPoints - Raw points from mouse/touch
53
+ * @param {string} targetD - The SVG path data of the *expected* next stroke
54
+ */
55
+ evaluate(userPoints, targetD) {
56
+ if (!userPoints || userPoints.length < 2) {
57
+ return { success: false, score: Infinity, message: "Too short" };
58
+ }
59
+
60
+ const targetPoints = this.getPathPoints(targetD);
61
+
62
+ // Check basic length ratio to prevent tiny ticks passing for long lines
63
+ const userLen = GeometryUtil.getPathLength(userPoints);
64
+ const targetLen = GeometryUtil.getPathLength(targetPoints);
65
+ const ratio = userLen / targetLen;
66
+
67
+ if (ratio < this.options.lengthRatioMin || ratio > this.options.lengthRatioMax) {
68
+ return { success: false, score: 100, message: "Length mismatch" };
69
+ }
70
+
71
+ const score = GeometryUtil.compareStrokes(userPoints, targetPoints, this.options.startDistThreshold);
72
+
73
+ return {
74
+ success: score < this.options.passThreshold,
75
+ score: score,
76
+ message: score < this.options.passThreshold ? "Good!" : "Try again"
77
+ };
78
+ }
79
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { KanjiWriter } from './KanjiWriter.js';
2
+ export { StrokeRecognizer } from './StrokeRecognizer.js';
3
+ export { GeometryUtil } from './GeometryUtil.js';
4
+ export { KanjiVGParser } from './KanjiVGParser.js';