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 +21 -0
- package/README.md +355 -0
- package/package.json +44 -0
- package/src/GeometryUtil.js +88 -0
- package/src/KanjiVGParser.js +87 -0
- package/src/KanjiWriter.js +392 -0
- package/src/StrokeRecognizer.js +79 -0
- package/src/index.js +4 -0
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
|
+
[](https://www.npmjs.com/package/kanji-recognizer)
|
|
6
|
+
[](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