Repository: chanind/hanzi-writer (ts)

Issue:
Is it possible to show the character stroke by stoke?
For the character animation, is it possible to show the character stroke by stoke? 

For example, when one stroke is done, the animation will be paused, the user needs to click a button to continue to the next stroke.

Pull request description:
adding a writer.animateStroke() method
closes #91 

This PR adds a `writer.animateStroke(strokeNum, options = {})` method which can be used to animate a single stroke at a time. It works similar to `writer.animateCharacter(options = {})`, with the same options and similarly returning a promise which resolves when the stroke is finished animating.

Patch:
diff --git a/src/HanziWriter.js b/src/HanziWriter.js
index dec7baa2..e119b903 100644
--- a/src/HanziWriter.js
+++ b/src/HanziWriter.js
@@ -109,6 +109,17 @@ HanziWriter.prototype.animateCharacter = function(options = {}) {
     )).then(res => callIfExists(options.onComplete, res))
   ));
 };
+HanziWriter.prototype.animateStroke = function(strokeNum, options = {}) {
+  this.cancelQuiz();
+  return this._withData(() => (
+    this._renderState.run(characterActions.animateSingleStroke(
+      'main',
+      this._character,
+      strokeNum,
+      this._options.strokeAnimationSpeed,
+    )).then(res => callIfExists(options.onComplete, res))
+  ));
+};
 HanziWriter.prototype.loopCharacterAnimation = function(options = {}) {
   this.cancelQuiz();
   return this._withData(() => (
diff --git a/src/Mutation.js b/src/Mutation.js
index c7dc1703..5f09c886 100644
--- a/src/Mutation.js
+++ b/src/Mutation.js
@@ -41,15 +41,17 @@ const isAlreadyAtEnd = function(startValues, endValues) {
 // from https://github.com/maxwellito/vivus
 const ease = x => -Math.cos(x * Math.PI) / 2 + 0.5;
 
-function Mutation(scope, values, options = {}) {
+function Mutation(scope, valuesOrCallable, options = {}) {
   this.scope = scope;
-  this._values = inflate(scope, values);
+  this._valuesOrCallable = valuesOrCallable;
   this._duration = options.duration || 0;
   this._force = options.force;
   this._tickBound = this._tick.bind(this);
 }
 
+
 Mutation.prototype.run = function(renderState) {
+  if (!this._values) this._inflateValues(renderState);
   if (this._duration === 0) renderState.updateState(this._values);
   if (this._duration === 0 || isAlreadyAtEnd(renderState.state, this._values)) {
     return Promise.resolve();
@@ -76,12 +78,23 @@ Mutation.prototype._tick = function(timing) {
   }
 };
 
+Mutation.prototype._inflateValues = function(renderState) {
+  let values = this._valuesOrCallable;
+  if (typeof this._valuesOrCallable === 'function') {
+    values = this._valuesOrCallable(renderState.state);
+  }
+  this._values = inflate(this.scope, values);
+};
+
 Mutation.prototype.cancel = function(renderState) {
   if (this._resolve) this._resolve();
   this._resolve = null;
   if (this._frameHandle) cancelAnimationFrame(this._frameHandle);
   this._frameHandle = null;
-  if (this._force) renderState.updateState(this._values);
+  if (this._force) {
+    if (!this._values) this._inflateValues(renderState);
+    renderState.updateState(this._values);
+  }
 };
 
 // ------ Mutation.Pause Class --------
diff --git a/src/characterActions.js b/src/characterActions.js
index 498a85de..7655e6ff 100644
--- a/src/characterActions.js
+++ b/src/characterActions.js
@@ -47,6 +47,25 @@ const animateStroke = (charName, stroke, speed) => {
   ];
 };
 
+const animateSingleStroke = (charName, character, strokeNum, speed) => {
+  const mutationStateFunc = (state) => {
+    const curCharState = state.character[charName];
+    const mutationState = {
+      opacity: 1,
+      strokes: {},
+    };
+    for (let i = 0; i < character.strokes.length; i++) {
+      mutationState.strokes[i] = {
+        opacity: curCharState.opacity * curCharState.strokes[i].opacity,
+      };
+    }
+    return mutationState;
+  };
+  return [new Mutation(`character.${charName}`, mutationStateFunc)].concat(
+    animateStroke(charName, character.strokes[strokeNum], speed),
+  );
+};
+
 const showStroke = (charName, strokeNum, duration) => {
   return [
     new Mutation(`character.${charName}.strokes.${strokeNum}`, {
@@ -83,6 +102,7 @@ module.exports = {
   animateCharacter,
   animateCharacterLoop,
   animateStroke,
+  animateSingleStroke,
   showStroke,
   updateColor,
 };
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__/HanziWriter-test.js b/src/__tests__/HanziWriter-test.js
index bfcdd46c..d670b272 100644
--- a/src/__tests__/HanziWriter-test.js
+++ b/src/__tests__/HanziWriter-test.js
@@ -203,6 +203,85 @@ describe('HanziWriter', () => {
     });
   });
 
+  describe('animateStroke', () => {
+    it('animates and returns promise that resolves when animation is finished', async () => {
+      document.body.innerHTML = '<div id="target"></div>';
+      const writer = new HanziWriter('target', '人', { showCharacter: true, charDataLoader });
+      await writer._withDataPromise;
+
+      let isResolved = false;
+      let resolvedVal;
+      const onComplete = jest.fn();
+
+      writer.animateStroke(1, { onComplete }).then(result => {
+        isResolved = true;
+        resolvedVal = result;
+      });
+
+      await resolvePromises();
+
+      expect(writer._renderState.state.character.main.opacity).toBe(1);
+      expect(writer._renderState.state.character.main.strokes[0].opacity).toBe(1);
+      expect(writer._renderState.state.character.main.strokes[1].opacity).toBe(1);
+
+      expect(writer._renderState.state.character.main.strokes[0].displayPortion).toBe(1);
+      expect(writer._renderState.state.character.main.strokes[1].displayPortion).toBe(0);
+      expect(isResolved).toBe(false);
+      expect(onComplete).not.toHaveBeenCalled();
+
+      clock.tick(1000);
+      await resolvePromises();
+
+      expect(writer._renderState.state.character.main.strokes[0].displayPortion).toBe(1);
+      expect(writer._renderState.state.character.main.strokes[1].displayPortion).toBe(1);
+      expect(isResolved).toBe(true);
+      expect(resolvedVal).toEqual({ canceled: false });
+      expect(onComplete).toHaveBeenCalledTimes(1);
+      expect(onComplete).toHaveBeenCalledWith({ canceled: false });
+    });
+
+    it('keeps other stroke opacities where they were originally', async () => {
+      document.body.innerHTML = '<div id="target"></div>';
+      const writer = new HanziWriter('target', '人', { showCharacter: true, charDataLoader });
+      await writer._withDataPromise;
+
+      let isResolved = false;
+      let resolvedVal;
+      const onComplete = jest.fn();
+
+      writer.hideCharacter({ duration: 0 });
+      await resolvePromises();
+
+      expect(writer._renderState.state.character.main.opacity).toBe(0);
+      expect(writer._renderState.state.character.main.strokes[0].opacity).toBe(1);
+      expect(writer._renderState.state.character.main.strokes[1].opacity).toBe(1);
+
+      writer.animateStroke(1, { onComplete }).then(result => {
+        isResolved = true;
+        resolvedVal = result;
+      });
+
+      await resolvePromises();
+
+      expect(writer._renderState.state.character.main.opacity).toBe(1);
+      expect(writer._renderState.state.character.main.strokes[0].opacity).toBe(0);
+      expect(writer._renderState.state.character.main.strokes[1].opacity).toBe(1);
+
+      expect(isResolved).toBe(false);
+      expect(onComplete).not.toHaveBeenCalled();
+
+      clock.tick(1000);
+      await resolvePromises();
+
+      expect(writer._renderState.state.character.main.strokes[0].displayPortion).toBe(1);
+      expect(writer._renderState.state.character.main.strokes[1].displayPortion).toBe(1);
+      expect(isResolved).toBe(true);
+      expect(resolvedVal).toEqual({ canceled: false });
+      expect(onComplete).toHaveBeenCalledTimes(1);
+      expect(onComplete).toHaveBeenCalledWith({ canceled: false });
+    });
+  });
+
   describe('loopCharacterAnimation', () => {
     it('animates and then repeats until something else stops it', async () => {
       document.body.innerHTML = '<div id="target"></div>';
@@ -509,7 +588,7 @@ describe('HanziWriter', () => {
       expect(writer._renderState.state.options.radicalColor.g).toBeCloseTo(30, 0);
       expect(writer._renderState.state.options.radicalColor.b).toBeCloseTo(30, 0);
       expect(writer._renderState.state.options.radicalColor.a).toBeCloseTo(1, 0);
-      clock.tick(10);
+      clock.tick(30);
       await resolvePromises();
 
       expect(writer._renderState.state.options.radicalColor).toBeNull();
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);
+  });
 });