Repository: chanind/hanzi-writer (ts)

Issue:
Issue with leniency in short parallel strokes
In `strokeMatches.js`, there's this logic starting on line 113

```
// if there is a better match among strokes the user hasn't drawn yet, the user probably drew the wrong stroke
const laterStrokes = character.strokes.slice(strokeNum + 1);
for (let i = 0; i < laterStrokes.length; i++) {
  const laterMatchData = getMatchData(points, laterStrokes[i], options);
  if (laterMatchData.isMatch && laterMatchData.avgDist < strokeMatchData.avgDist) return false;
}
```

This makes perfect sense, but if I have a quiz with higher lenience, it makes it uncharacteristically strict for some characters which have two similar strokes in close proximity to each other, like 月 and 是. 

Let's say you have two short horizontal strokes right next to each other: if you draw the first one slightly too far down, it will think you drew the "wrong" stroke because it was closer to the second short horizontal stroke.

Can we have a way to disable this "future stroke" detection? Or perhaps there's a more elegant way to handle this use case.


Pull request:
More leniency matching similar strokes near each other
fixes #90 

This PR changes matching logic so that if the user draws a stroke which matches an incorrect stroke better than the desired stroke it will not automatically fail. Instead, this will take into account the stroke matching leniency setting and make it less likely for the stroke to match.

More details in #90 

Patch:
diff --git a/src/strokeMatches.js b/src/strokeMatches.js
index cd74a1bc..10393acd 100644
--- a/src/strokeMatches.js
+++ b/src/strokeMatches.js
@@ -1,4 +1,4 @@
-const {average} = require('./utils');
+const {average, assign} = require('./utils');
 const {
   cosineSimilarity,
   equals,
@@ -112,9 +112,22 @@ const strokeMatches = (userStroke, character, strokeNum, options = {}) => {
 
   // if there is a better match among strokes the user hasn't drawn yet, the user probably drew the wrong stroke
   const laterStrokes = character.strokes.slice(strokeNum + 1);
+  let closestMatchDist = strokeMatchData.avgDist;
   for (let i = 0; i < laterStrokes.length; i++) {
     const laterMatchData = getMatchData(points, laterStrokes[i], options);
-    if (laterMatchData.isMatch && laterMatchData.avgDist < strokeMatchData.avgDist) return false;
+    if (laterMatchData.isMatch && laterMatchData.avgDist < closestMatchDist) {
+      closestMatchDist = laterMatchData.avgDist;
+    }
+  }
+  // if there's a better match, rather that returning false automatically, try reducing leniency instead
+  // if leniency is already really high we can allow some similar strokes to pass
+  if (closestMatchDist < strokeMatchData.avgDist) {
+    // adjust leniency between 0.3 and 0.6 depending on how much of a better match the new match is
+    const leniencyAdjustment = 0.6 * (closestMatchDist + strokeMatchData.avgDist) / (2 * strokeMatchData.avgDist);
+    const newLeniency = (options.leniency || 1) * leniencyAdjustment;
+    const adjustedOptions = assign({}, options, { leniency: newLeniency });
+    const adjustedStrokeMatchData = getMatchData(points, character.strokes[strokeNum], adjustedOptions);
+    return adjustedStrokeMatchData.isMatch;
   }
   return true;
 };


Test patch:
diff --git a/src/__tests__/strokeMatches-test.js b/src/__tests__/strokeMatches-test.js
index 4e8351d4..1bccd735 100644
--- a/src/__tests__/strokeMatches-test.js
+++ b/src/__tests__/strokeMatches-test.js
@@ -9,16 +9,16 @@ const getChar = (charStr) => {
   return new CharDataParser().generateCharacter(charStr, charJson);
 };
 
-const assertMatches = (charStr, strokeNum, points, isOutlineVisible = false) => {
+const assertMatches = (charStr, strokeNum, points, options = {}) => {
   const char = getChar(charStr);
   const userStroke = { points };
-  expect(strokeMatches(userStroke, char, strokeNum, { isOutlineVisible })).toBe(true);
+  expect(strokeMatches(userStroke, char, strokeNum, options)).toBe(true);
 };
 
-const assertNotMatches = (charStr, strokeNum, points, isOutlineVisible = false) => {
+const assertNotMatches = (charStr, strokeNum, points, options = {}) => {
   const char = getChar(charStr);
   const userStroke = { points };
-  expect(strokeMatches(userStroke, char, strokeNum, { isOutlineVisible })).toBe(false);
+  expect(strokeMatches(userStroke, char, strokeNum, options)).toBe(false);
 };
 
 describe('strokeMatches', () => {
@@ -100,10 +100,10 @@ describe('strokeMatches', () => {
 
   it('matches using real data 6', () => {
     const points = [{x: 412.3, y: 423.4}, {x: 463.5, y: 423.4}, {x: 501.1, y: 423.4}, {x: 518.1, y: 423.4}, {x: 528.4, y: 423.4}, {x: 562.5, y: 423.4}, {x: 576.2, y: 423.4}];
-    assertNotMatches('国', 2, points, true);
-    assertMatches('国', 3, points, true);
-    assertNotMatches('国', 4, points, true);
-    assertNotMatches('国', 5, points, true);
+    assertNotMatches('国', 2, points, { isOutlineVisible: true });
+    assertMatches('国', 3, points, { isOutlineVisible: true });
+    assertNotMatches('国', 4, points, { isOutlineVisible: true });
+    assertNotMatches('国', 5, points, { isOutlineVisible: true });
   });
 
   it('matches using real data 7', () => {
@@ -208,4 +208,11 @@ describe('strokeMatches', () => {
     assertNotMatches('吗', 1, points);
     assertNotMatches('吗', 2, points);
   });
+
+  it('matches using real data 22', () => {
+    const points = [{x: 496.6, y: 555.4}, {x: 500.1, y: 551.9}, {x: 513.7, y: 558.8}, {x: 534.2, y: 569}, {x: 551.3, y: 575.8}, {x: 571.7, y: 586.1}, {x: 592.2, y: 589.5}, {x: 609.3, y: 592.9}, {x: 633.2, y: 603.1}, {x: 640, y: 603.1}, {x: 643.4, y: 603.1}];
+    assertNotMatches('得', 5, points);
+    assertMatches('得', 5, points, { leniency: 2 });
+    assertMatches('得', 6, points);
+  });
 });