Repository: chanind/hanzi-writer (ts)

Issue:
On `onCorrectStroke` and `onMistake` events, also return drawingPath's data
For scientific purpose at least, it would be interesting to return the path of the user correct and mistake drawing. Beyond the current binary `onCorrectStroke` or `onMistake`, paths would provide insight on what the learners draw, why do they fails.

### Currently
writer.quiz(options) : 
* `onCorrectStroke`: function(data). Called when the user draws a stroke correctly. The function is called with an object containing` totalMistakes` which is the total mistakes made during the quiz so far, `strokeNum` the current stroke number, `mistakesOnStroke` the number of mistakes the user made drawing this stroke, and `strokesRemaining` the number of strokes left until the quiz is complete.
* `onMistake`: function(data). Called when the user makes a mistake drawing a stroke. The function is called with an object containing `totalMistakes` which is the total mistakes made during the quiz so far, `strokeNum` the current stroke number, `mistakesOnStroke` the number of mistakes the user made drawing this stroke so far, and `strokesRemaining` the number of strokes left until the quiz is complete.

### Suggestion
On `onCorrectStroke` and `onMistake` events, the `function(data)` is called when the user draws a stroke correctly or incorectly. In `data`, add a key-value _`drawingPath` : the path' s data for the last drawing made by the user._

![screenshot from 2018-02-17 00-49-21](https://user-images.githubusercontent.com/1420189/36334502-6b94df2e-137c-11e8-815e-ecde1dad3be6.png)

### Direct use
I would like to use these data to later on plot student's nth stroke's (`strokeNum`) drawing path, correct or mistakes, via d3js. The end rendering being something such : 
> { character: '馬', strokeNum: 0,
> strokeNumColor: 'lightblue', drawingWidth: '1', drawingColor: '#B10000' }
![-monitoring](https://user-images.githubusercontent.com/1420189/36342964-133c8b9e-1406-11e8-856a-22bd15999a8c.png)

In language learning, it's the field of error analysis.



Pull request description:
Drawn path callback
This PR adds a `drawnPath` key into the `onMistake` and `onCorrectStroke` callbacks in quizzes. This contains a `points` key containing the points of the user stroke in character coordinates, and a `pathString` key containing the pathstring in external coordinates which can be used to draw the user stroke.

fixes #59

Patch:
diff --git a/src/CharDataParser.js b/src/CharDataParser.js
index a6342ed5..e09561db 100644
--- a/src/CharDataParser.js
+++ b/src/CharDataParser.js
@@ -1,4 +1,3 @@
-const Point = require('./models/Point');
 const Stroke = require('./models/Stroke');
 const Character = require('./models/Character');
 
@@ -15,7 +14,7 @@ CharDataParser.prototype.generateStrokes = function(charJson) {
   return charJson.strokes.map((path, index) => {
     const points = charJson.medians[index].map((pointData) => {
       const [x, y] = pointData;
-      return new Point(x, y);
+      return {x, y};
     });
     return new Stroke(path, points, index, isInRadical(index));
   });
diff --git a/src/HanziWriter.js b/src/HanziWriter.js
index 49c76cb8..765fe052 100644
--- a/src/HanziWriter.js
+++ b/src/HanziWriter.js
@@ -1,6 +1,5 @@
 const HanziWriterRenderer = require('./renderers/HanziWriterRenderer');
 const RenderState = require('./RenderState');
-const Point = require('./models/Point');
 const CharDataParser = require('./CharDataParser');
 const Positioner = require('./Positioner');
 const Quiz = require('./Quiz');
@@ -132,7 +131,7 @@ HanziWriter.prototype.hideOutline = function(options = {}) {
 HanziWriter.prototype.quiz = function(quizOptions = {}) {
   this._withData(() => {
     this.cancelQuiz();
-    this._quiz = new Quiz(this._character, this._renderState);
+    this._quiz = new Quiz(this._character, this._renderState, this._positioner);
     this._quiz.startQuiz(assign({}, this._options, quizOptions));
   });
 };
@@ -248,14 +247,14 @@ HanziWriter.prototype._forwardToQuiz = function(method, ...args) {
 
 HanziWriter.prototype._getMousePoint = function(evt) {
   const box = this._canvas.svg.getBoundingClientRect();
-  return this._positioner.convertExternalPoint(new Point(evt.clientX - box.left, evt.clientY - box.top));
+  return {x: evt.clientX - box.left, y: evt.clientY - box.top};
 };
 
 HanziWriter.prototype._getTouchPoint = function(evt) {
   const box = this._canvas.svg.getBoundingClientRect();
   const x = evt.touches[0].clientX - box.left;
   const y = evt.touches[0].clientY - box.top;
-  return this._positioner.convertExternalPoint(new Point(x, y));
+  return {x, y};
 };
 
 
diff --git a/src/Positioner.js b/src/Positioner.js
index 8367b334..d3540678 100644
--- a/src/Positioner.js
+++ b/src/Positioner.js
@@ -1,5 +1,3 @@
-const Point = require('./models/Point');
-
 function Positioner(character, options) {
   this._character = character;
   this._options = options;
@@ -9,7 +7,7 @@ function Positioner(character, options) {
 Positioner.prototype.convertExternalPoint = function(point) {
   const x = (point.x - this._xOffset) / this._scale;
   const y = (this.getHeight() - this._yOffset - point.y) / this._scale;
-  return new Point(x, y);
+  return {x, y};
 };
 
 Positioner.prototype.getXOffset = function() { return this._xOffset; };
diff --git a/src/Quiz.js b/src/Quiz.js
index a7f54283..db826e51 100644
--- a/src/Quiz.js
+++ b/src/Quiz.js
@@ -1,15 +1,22 @@
-const StrokeMatcher = require('./StrokeMatcher');
+const strokeMatches = require('./strokeMatches');
 const UserStroke = require('./models/UserStroke');
 const {callIfExists, counter} = require('./utils');
 const quizActions = require('./quizActions');
+const svg = require('./svg');
 const characterActions = require('./characterActions');
 
 
-function Quiz(character, renderState) {
+const getDrawnPath = (userStroke) => ({
+  pathString: svg.getPathString(userStroke.externalPoints),
+  points: userStroke.points,
+});
+
+
+function Quiz(character, renderState, positioner) {
   this._character = character;
   this._renderState = renderState;
   this._isActive = false;
-  this._strokeMatcher = new StrokeMatcher();
+  this._positioner = positioner;
 }
 
 Quiz.prototype.startQuiz = function(options) {
@@ -22,19 +29,21 @@ Quiz.prototype.startQuiz = function(options) {
   this._renderState.run(quizActions.startQuiz(this._character, options.strokeFadeDuration));
 };
 
-Quiz.prototype.startUserStroke = function(point) {
+Quiz.prototype.startUserStroke = function(externalPoint) {
+  const point = this._positioner.convertExternalPoint(externalPoint);
   if (!this._isActive) return null;
   if (this._userStroke) return this.endUserStroke();
   const strokeId = counter();
-  this._userStroke = new UserStroke(strokeId, point);
+  this._userStroke = new UserStroke(strokeId, point, externalPoint);
   this._renderState.run(
     quizActions.startUserStroke(strokeId, point),
   );
 };
 
-Quiz.prototype.continueUserStroke = function(point) {
+Quiz.prototype.continueUserStroke = function(externalPoint) {
   if (!this._userStroke) return;
-  this._userStroke.appendPoint(point);
+  const point = this._positioner.convertExternalPoint(externalPoint);
+  this._userStroke.appendPoint(point, externalPoint);
   const nextPoints = this._userStroke.points.slice(0);
   this._renderState.run(quizActions.updateUserStroke(this._userStroke.id, nextPoints));
 };
@@ -45,8 +54,7 @@ Quiz.prototype.endUserStroke = function() {
   this._renderState.run(quizActions.removeUserStroke(this._userStroke.id, this._options.drawingFadeDuration));
 
   const currentStroke = this._getCurrentStroke();
-  const isMatch = this._strokeMatcher.strokeMatches(this._userStroke, currentStroke);
-  this._userStroke = null;
+  const isMatch = strokeMatches(this._userStroke, currentStroke);
 
   if (isMatch) {
     this._handleSuccess(currentStroke);
@@ -58,6 +66,7 @@ Quiz.prototype.endUserStroke = function() {
       );
     }
   }
+  this._userStroke = null;
 };
 
 Quiz.prototype.cancel = function() {
@@ -74,6 +83,7 @@ Quiz.prototype._handleSuccess = function(stroke) {
     mistakesOnStroke: this._numRecentMistakes,
     totalMistakes: this._totalMistakes,
     strokesRemaining: this._character.strokes.length - this._currentStrokeIndex - 1,
+    drawnPath: getDrawnPath(this._userStroke),
   });
   let animation = characterActions.showStroke('main', this._currentStrokeIndex, this._options.strokeFadeDuration);
   this._currentStrokeIndex += 1;
@@ -104,6 +114,7 @@ Quiz.prototype._handleFailure = function() {
     mistakesOnStroke: this._numRecentMistakes,
     totalMistakes: this._totalMistakes,
     strokesRemaining: this._character.strokes.length - this._currentStrokeIndex,
+    drawnPath: getDrawnPath(this._userStroke),
   });
 };
 
diff --git a/src/StrokeMatcher.js b/src/StrokeMatcher.js
deleted file mode 100644
index b5bef828..00000000
--- a/src/StrokeMatcher.js
+++ /dev/null
@@ -1,70 +0,0 @@
-const Point = require('./models/Point');
-const {average} = require('./utils');
-
-const AVG_DIST_THRESHOLD = 300; // bigger = more lenient
-const COSINE_SIMILARITY_THRESHOLD = 0; // -1 to 1, smaller = more lenient
-const START_AND_END_DIST_THRESHOLD = 250; // bigger = more lenient
-
-function StrokeMatcher() {}
-
-StrokeMatcher.prototype.strokeMatches = function(userStroke, stroke) {
-  const points = this._stripDuplicates(userStroke.points);
-  if (points.length < 2) return null;
-
-  const avgDist = stroke.getAverageDistance(points);
-  const withinDistThresh = avgDist < AVG_DIST_THRESHOLD;
-  const startAndEndMatch = this._startAndEndMatches(points, stroke);
-  const directionMatches = this._directionMatches(points, stroke);
-  return withinDistThresh && startAndEndMatch && directionMatches;
-};
-
-StrokeMatcher.prototype._startAndEndMatches = function(points, closestStroke) {
-  const startingDist = Point.getDistance(closestStroke.getStartingPoint(), points[0]);
-  const endingDist = Point.getDistance(closestStroke.getEndingPoint(), points[points.length - 1]);
-  return startingDist < START_AND_END_DIST_THRESHOLD && endingDist < START_AND_END_DIST_THRESHOLD;
-};
-
-StrokeMatcher.prototype._directionMatches = function(points, stroke) {
-  const edgeVectors = this._getEdgeVectors(points);
-  const strokeVectors = stroke.getVectors();
-  const similarities = edgeVectors.map(edgeVector => {
-    const strokeSimilarities = strokeVectors.map(strokeVector => Point.cosineSimilarity(strokeVector, edgeVector));
-    return Math.max.apply(Math, strokeSimilarities);
-  });
-  const avgSimilarity = average(similarities);
-  return avgSimilarity > COSINE_SIMILARITY_THRESHOLD;
-};
-
-StrokeMatcher.prototype._stripDuplicates = function(points) {
-  if (points.length < 2) return points;
-  const dedupedPoints = [points[0]];
-  points.slice(1).forEach(point => {
-    if (!point.equals(dedupedPoints[dedupedPoints.length - 1])) {
-      dedupedPoints.push(point);
-    }
-  });
-  return dedupedPoints;
-};
-
-StrokeMatcher.prototype._getLength = function(points) {
-  let length = 0;
-  let lastPoint = points[0];
-  points.forEach(point => {
-    length += Point.getDistance(point, lastPoint);
-    lastPoint = point;
-  });
-  return length;
-};
-
-// returns a list of the direction of all segments in the line connecting the points
-StrokeMatcher.prototype._getEdgeVectors = function(points) {
-  const vectors = [];
-  let lastPoint = points[0];
-  points.slice(1).forEach(point => {
-    vectors.push(point.subtract(lastPoint));
-    lastPoint = point;
-  });
-  return vectors;
-};
-
-module.exports = StrokeMatcher;
diff --git a/src/geometry.js b/src/geometry.js
index 3be252a1..fa4981db 100644
--- a/src/geometry.js
+++ b/src/geometry.js
@@ -1,11 +1,20 @@
-const Point = require('./models/Point');
+const subtract = (p1, p2) => ({x: p1.x - p2.x, y: p1.y - p2.y});
+const magnitude = (point) => Math.sqrt(Math.pow(point.x, 2) + Math.pow(point.y, 2));
+const distance = (point1, point2) => magnitude(subtract(point1, point2));
+const equals = (point1, point2) => point1.x === point2.x && point1.y === point2.y;
+
+const cosineSimilarity = (point1, point2) => {
+  const rawDotProduct = point1.x * point2.x + point1.y * point2.y;
+  return rawDotProduct / magnitude(point1) / magnitude(point2);
+};
+
 
 // return a new point, p3, which is on the same line as p1 and p2, but distance away
 // from p2. p1, p2, p3 will always lie on the line in that order
-const extendPointOnLine = (p1, p2, distance) => {
-  const vect = p2.subtract(p1);
-  const norm = distance / vect.getMagnitude();
-  return new Point(p2.x + norm * vect.x, p2.y + norm * vect.y);
+const extendPointOnLine = (p1, p2, dist) => {
+  const vect = subtract(p2, p1);
+  const norm = dist / magnitude(vect);
+  return {x: p2.x + norm * vect.x, y: p2.y + norm * vect.y};
 };
 
 
@@ -15,8 +24,8 @@ const filterParallelPoints = (points) => {
   const filteredPoints = [points[0], points[1]];
   points.slice(2).forEach((point, i) => {
     const numFilteredPoints = filteredPoints.length;
-    const curVect = point.subtract(filteredPoints[numFilteredPoints - 1]);
-    const prevVect = filteredPoints[numFilteredPoints - 1].subtract(filteredPoints[numFilteredPoints - 2]);
+    const curVect = subtract(point, filteredPoints[numFilteredPoints - 1]);
+    const prevVect = subtract(filteredPoints[numFilteredPoints - 1], filteredPoints[numFilteredPoints - 2]);
     // this is the z coord of the cross-product. If this is 0 then they're parallel
     const isParallel = ((curVect.y * prevVect.x - curVect.x * prevVect.y) === 0);
     if (isParallel) {
@@ -28,6 +37,10 @@ const filterParallelPoints = (points) => {
 };
 
 module.exports = {
+  equals,
+  distance,
+  subtract,
+  cosineSimilarity,
   extendPointOnLine,
   filterParallelPoints,
 };
diff --git a/src/models/Character.js b/src/models/Character.js
index 2c829606..e0876308 100644
--- a/src/models/Character.js
+++ b/src/models/Character.js
@@ -1,12 +1,10 @@
-const Point = require('./Point');
-
 function Character(symbol, strokes) {
   this.symbol = symbol;
   this.strokes = strokes;
 }
 
 Character.prototype.getBounds = function() {
-  return Point.getBounds([new Point(0, 900), new Point(1024, -124)]);
+  return [{x: 0, y: -124}, {x: 1024, y: 900}];
 };
 
 module.exports = Character;
diff --git a/src/models/Point.js b/src/models/Point.js
deleted file mode 100644
index c457da9e..00000000
--- a/src/models/Point.js
+++ /dev/null
@@ -1,58 +0,0 @@
-const { arrayMin, arrayMax } = require('../utils');
-
-function Point(x, y) {
-  this.x = parseFloat(x, 10);
-  this.y = parseFloat(y, 10);
-}
-
-// return a new point subtracting point from this
-Point.prototype.subtract = function(point) {
-  return new Point(this.x - point.x, this.y - point.y);
-};
-
-// return a new point adding point from this
-Point.prototype.add = function(point) {
-  return new Point(this.x + point.x, this.y + point.y);
-};
-
-Point.prototype.getMagnitude = function() {
-  return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2));
-};
-
-Point.prototype.equals = function(point) {
-  if (!point) return false;
-  return point.x === this.x && point.y === this.y;
-};
-
-Point.getBounds = (points) => {
-  const xs = points.map((point) => point.x);
-  const ys = points.map((point) => point.y);
-  const maxX = arrayMax(xs);
-  const maxY = arrayMax(ys);
-  const minX = arrayMin(xs);
-  const minY = arrayMin(ys);
-  return [new Point(minX, minY), new Point(maxX, maxY)];
-};
-
-// boundable here refers to any object with a getBounds() method
-Point.getOverallBounds = (boundables) => {
-  const bounds = [];
-  boundables.forEach(boundable => {
-    const [lowerBound, upperBound] = boundable.getBounds();
-    bounds.push(lowerBound);
-    bounds.push(upperBound);
-  });
-  return Point.getBounds(bounds);
-};
-
-Point.getDistance = (point1, point2) => {
-  const difference = point1.subtract(point2);
-  return difference.getMagnitude();
-};
-
-Point.cosineSimilarity = (point1, point2) => {
-  const rawDotProduct = point1.x * point2.x + point1.y * point2.y;
-  return rawDotProduct / point1.getMagnitude() / point2.getMagnitude();
-};
-
-module.exports = Point;
diff --git a/src/models/Stroke.js b/src/models/Stroke.js
index 677193aa..ca885ec0 100644
--- a/src/models/Stroke.js
+++ b/src/models/Stroke.js
@@ -1,4 +1,4 @@
-const Point = require('./Point');
+const { subtract, distance } = require('../geometry');
 
 function Stroke(path, points, strokeNum, isInRadical = false) {
   this.path = path;
@@ -19,7 +19,7 @@ Stroke.prototype.getLength = function() {
   let lastPoint = this.points[0];
   const pointsSansFirst = this.points.slice(1);
   return pointsSansFirst.reduce((acc, point) => {
-    const dist = Point.getDistance(point, lastPoint);
+    const dist = distance(point, lastPoint);
     lastPoint = point;
     return acc + dist;
   }, 0);
@@ -29,14 +29,14 @@ Stroke.prototype.getVectors = function() {
   let lastPoint = this.points[0];
   const pointsSansFirst = this.points.slice(1);
   return pointsSansFirst.map((point) => {
-    const vector = point.subtract(lastPoint);
+    const vector = subtract(point, lastPoint);
     lastPoint = point;
     return vector;
   });
 };
 
 Stroke.prototype.getDistance = function(point) {
-  const distances = this.points.map(strokePoint => Point.getDistance(strokePoint, point));
+  const distances = this.points.map(strokePoint => distance(strokePoint, point));
   return Math.min.apply(Math, distances);
 };
 
diff --git a/src/models/UserStroke.js b/src/models/UserStroke.js
index bbf2503b..6a11af52 100644
--- a/src/models/UserStroke.js
+++ b/src/models/UserStroke.js
@@ -1,10 +1,12 @@
-function UserStroke(id, startingPoint) {
+function UserStroke(id, startingPoint, startingExternalPoint) {
   this.id = id;
   this.points = [startingPoint];
+  this.externalPoints = [startingExternalPoint];
 }
 
-UserStroke.prototype.appendPoint = function(point) {
+UserStroke.prototype.appendPoint = function(point, externalPoint) {
   this.points.push(point);
+  this.externalPoints.push(externalPoint);
 };
 
 module.exports = UserStroke;
diff --git a/src/strokeMatches.js b/src/strokeMatches.js
new file mode 100644
index 00000000..2038ff0d
--- /dev/null
+++ b/src/strokeMatches.js
@@ -0,0 +1,60 @@
+const {average} = require('./utils');
+const {cosineSimilarity, equals, distance, subtract} = require('./geometry');
+
+const AVG_DIST_THRESHOLD = 300; // bigger = more lenient
+const COSINE_SIMILARITY_THRESHOLD = 0; // -1 to 1, smaller = more lenient
+const START_AND_END_DIST_THRESHOLD = 250; // bigger = more lenient
+
+const startAndEndMatches = function(points, closestStroke) {
+  const startingDist = distance(closestStroke.getStartingPoint(), points[0]);
+  const endingDist = distance(closestStroke.getEndingPoint(), points[points.length - 1]);
+  return startingDist < START_AND_END_DIST_THRESHOLD && endingDist < START_AND_END_DIST_THRESHOLD;
+};
+
+// returns a list of the direction of all segments in the line connecting the points
+const getEdgeVectors = (points) => {
+  const vectors = [];
+  let lastPoint = points[0];
+  points.slice(1).forEach(point => {
+    vectors.push(subtract(point, lastPoint));
+    lastPoint = point;
+  });
+  return vectors;
+};
+
+
+const directionMatches = (points, stroke) => {
+  const edgeVectors = getEdgeVectors(points);
+  const strokeVectors = stroke.getVectors();
+  const similarities = edgeVectors.map(edgeVector => {
+    const strokeSimilarities = strokeVectors.map(strokeVector => cosineSimilarity(strokeVector, edgeVector));
+    return Math.max.apply(Math, strokeSimilarities);
+  });
+  const avgSimilarity = average(similarities);
+  return avgSimilarity > COSINE_SIMILARITY_THRESHOLD;
+};
+
+const stripDuplicates = (points) => {
+  if (points.length < 2) return points;
+  const dedupedPoints = [points[0]];
+  points.slice(1).forEach(point => {
+    if (!equals(point, dedupedPoints[dedupedPoints.length - 1])) {
+      dedupedPoints.push(point);
+    }
+  });
+  return dedupedPoints;
+};
+
+const strokeMatches = (userStroke, stroke) => {
+  const points = stripDuplicates(userStroke.points);
+  if (points.length < 2) return null;
+
+  const avgDist = stroke.getAverageDistance(points);
+  const withinDistThresh = avgDist < AVG_DIST_THRESHOLD;
+  const startAndEndMatch = startAndEndMatches(points, stroke);
+  const directionMatch = directionMatches(points, stroke);
+  return withinDistThresh && startAndEndMatch && directionMatch;
+};
+
+
+module.exports = strokeMatches;
diff --git a/src/utils.js b/src/utils.js
index 44cb62b2..c518fa9e 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -54,22 +54,6 @@ function inflate(scope, obj) {
   return final;
 }
 
-
-function arrayMax(numArray) {
-  return Math.max.apply(null, numArray);
-}
-
-function arrayMin(numArray) {
-  return Math.min.apply(null, numArray);
-}
-
-function getExtremes(numArray) {
-  const max = arrayMax(numArray);
-  const min = arrayMin(numArray);
-  const mid = (max + min) / 2;
-  return [max, mid, min];
-}
-
 function callIfExists(callback, arg) {
   if (callback) callback(arg);
   return arg;
@@ -103,8 +87,6 @@ const objRepeat = (item, times) => {
 };
 
 module.exports = {
-  arrayMax,
-  arrayMin,
   assign,
   average,
   callIfExists,
@@ -112,7 +94,6 @@ module.exports = {
   copyAndMergeDeep,
   counter,
   emptyFunc,
-  getExtremes,
   inflate,
   objRepeat,
   performanceNow,


Test patch:
diff --git a/src/__tests__/Positioner-test.js b/src/__tests__/Positioner-test.js
new file mode 100644
index 00000000..2f0fa1c1
--- /dev/null
+++ b/src/__tests__/Positioner-test.js
@@ -0,0 +1,23 @@
+const ren = require('hanzi-writer-data/人.json');
+const Positioner = require('../Positioner');
+const CharDataParser = require('../CharDataParser');
+
+
+const char = new CharDataParser().generateCharacter('人', ren);
+
+
+describe('Positioner', () => {
+  it('calculates scale and offset to transform characters to fix in the box on screen', () => {
+    const positioner = new Positioner(char, { width: 400, height: 400, padding: 20 });
+
+    expect(positioner.getXOffset()).toBe(20);
+    expect(positioner.getYOffset()).toBe(63.59375);
+    expect(positioner.getScale()).toBe(0.3515625);
+    expect(positioner.getHeight()).toBe(400);
+  });
+
+  it('converts points from the external reference frame to the character reference frame', () => {
+    const positioner = new Positioner(char, { width: 400, height: 400, padding: 20 });
+    expect(positioner.convertExternalPoint({x: 30, y: 50})).toEqual({x: 28.444444444444443, y: 814.6666666666666});
+  });
+});
diff --git a/src/__tests__/Quiz-test.js b/src/__tests__/Quiz-test.js
index dfb011d3..2a1f2f03 100644
--- a/src/__tests__/Quiz-test.js
+++ b/src/__tests__/Quiz-test.js
@@ -1,16 +1,23 @@
-jest.mock('../StrokeMatcher');
+jest.mock('../strokeMatches');
+jest.mock('../Positioner');
 
 const ren = require('hanzi-writer-data/人.json');
 const Quiz = require('../Quiz');
-const Point = require('../models/Point');
 const CharDataParser = require('../CharDataParser');
 const RenderState = require('../RenderState');
+const Positioner = require('../Positioner');
 const { resolvePromises } = require('../testUtils');
-const StrokeMatcher = require('../StrokeMatcher');
+const strokeMatches = require('../strokeMatches');
+
+
+Positioner.mockImplementation(() => ({
+  convertExternalPoint: (point) => ({x: point.x + 5, y: point.y + 5}),
+}));
 
 
 beforeEach(() => {
-  StrokeMatcher.mockClear();
+  strokeMatches.mockClear();
+  Positioner.mockClear();
 });
 
 
@@ -64,7 +71,7 @@ describe('Quiz', () => {
   describe('startQuiz', () => {
     it('resets the quiz and makes it active', async () => {
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       expect(quiz._isActive).toBe(false);
 
       quiz.startQuiz(Object.assign({}, opts));
@@ -83,7 +90,7 @@ describe('Quiz', () => {
   describe('startUserStroke', () => {
     it('begins a stroke with the provided point', async () => {
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       quiz.startQuiz(Object.assign({}, opts));
 
       await resolvePromises();
@@ -91,19 +98,19 @@ describe('Quiz', () => {
       expect(renderState.state.userStrokes).toBe(null);
       expect(quiz._userStroke).toBe(undefined);
 
-      quiz.startUserStroke(new Point(10, 20));
+      quiz.startUserStroke({x: 10, y: 20});
       await resolvePromises();
 
       const userStrokeIds = Object.keys(renderState.state.userStrokes);
       expect(userStrokeIds.length).toBe(1);
       expect(quiz._userStroke.id.toString()).toBe(userStrokeIds[0]);
-      expect(renderState.state.userStrokes[userStrokeIds[0]].points).toEqual([new Point(10, 20)]);
-      expect(quiz._userStroke.points).toEqual([new Point(10, 20)]);
+      expect(renderState.state.userStrokes[userStrokeIds[0]].points).toEqual([{x: 15, y: 25}]);
+      expect(quiz._userStroke.points).toEqual([{x: 15, y: 25}]);
     });
 
     it('ends the current user stroke if one exists', async () => {
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       quiz.startQuiz(Object.assign({}, opts));
 
       await resolvePromises();
@@ -111,17 +118,17 @@ describe('Quiz', () => {
       expect(renderState.state.userStrokes).toBe(null);
       expect(quiz._userStroke).toBe(undefined);
 
-      quiz.startUserStroke(new Point(10, 20));
+      quiz.startUserStroke({x: 10, y: 20});
       await resolvePromises();
 
       const currentStrokeId = quiz._userStroke.id;
 
-      quiz.startUserStroke(new Point(100, 200));
+      quiz.startUserStroke({x: 100, y: 200});
 
       const userStrokeIds = Object.keys(renderState.state.userStrokes);
       expect(userStrokeIds.length).toBe(1);
       expect(quiz._userStroke).toBe(null);
-      expect(renderState.state.userStrokes[currentStrokeId].points).toEqual([new Point(10, 20)]);
+      expect(renderState.state.userStrokes[currentStrokeId].points).toEqual([{x: 15, y: 25}]);
 
       clock.tick(1000);
       await resolvePromises();
@@ -134,7 +141,7 @@ describe('Quiz', () => {
   describe('continueUserStroke', () => {
     it('adds to the current user stroke', async () => {
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       quiz.startQuiz(Object.assign({}, opts));
 
       await resolvePromises();
@@ -142,29 +149,33 @@ describe('Quiz', () => {
       expect(renderState.state.userStrokes).toBe(null);
       expect(quiz._userStroke).toBe(undefined);
 
-      quiz.startUserStroke(new Point(10, 20));
+      quiz.startUserStroke({x: 10, y: 20});
       await resolvePromises();
 
       const currentStrokeId = quiz._userStroke.id;
 
-      quiz.continueUserStroke(new Point(100, 200));
+      quiz.continueUserStroke({x: 100, y: 200});
 
       const userStrokeIds = Object.keys(renderState.state.userStrokes);
       expect(userStrokeIds.length).toBe(1);
       expect(quiz._userStroke.id).toBe(currentStrokeId);
       expect(renderState.state.userStrokes[currentStrokeId].points).toEqual([
-        new Point(10, 20),
-        new Point(100, 200),
+        {x: 15, y: 25},
+        {x: 105, y: 205},
       ]);
       expect(quiz._userStroke.points).toEqual([
-        new Point(10, 20),
-        new Point(100, 200),
+        {x: 15, y: 25},
+        {x: 105, y: 205},
+      ]);
+      expect(quiz._userStroke.externalPoints).toEqual([
+        {x: 10, y: 20},
+        {x: 100, y: 200},
       ]);
     });
 
     it('does nothing if there is no current stroke', async () => {
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       quiz.startQuiz(Object.assign({}, opts));
 
       await resolvePromises();
@@ -173,7 +184,7 @@ describe('Quiz', () => {
       await resolvePromises();
 
       expect(quiz._userStroke).toBe(undefined);
-      quiz.continueUserStroke(new Point(100, 200));
+      quiz.continueUserStroke({x: 100, y: 200});
 
       await resolvePromises();
 
@@ -184,12 +195,10 @@ describe('Quiz', () => {
 
   describe('endUserStroke', () => {
     it('finishes the stroke and moves on if it was correct', async () => {
-      StrokeMatcher.mockImplementationOnce(() => ({
-        strokeMatches: () => true,
-      }));
+      strokeMatches.mockImplementation(() => true);
 
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       const onCorrectStroke = jest.fn();
       const onMistake = jest.fn();
       const onComplete = jest.fn();
@@ -197,8 +206,8 @@ describe('Quiz', () => {
       clock.tick(1000);
       await resolvePromises();
 
-      quiz.startUserStroke(new Point(10, 20));
-      quiz.continueUserStroke(new Point(100, 200));
+      quiz.startUserStroke({x: 10, y: 20});
+      quiz.continueUserStroke({x: 100, y: 200});
 
       const currentStrokeId = quiz._userStroke.id;
       expect(quiz._currentStrokeIndex).toBe(0);
@@ -215,6 +224,10 @@ describe('Quiz', () => {
         strokeNum: 0,
         strokesRemaining: 1,
         totalMistakes: 0,
+        drawnPath: {
+          pathString: 'M 10 20 L 100 200',
+          points: [{x: 15, y: 25}, {x: 105, y: 205}],
+        },
       });
       expect(onMistake).not.toHaveBeenCalled();
       expect(onComplete).not.toHaveBeenCalled();
@@ -229,12 +242,10 @@ describe('Quiz', () => {
     });
 
     it('stays on the stroke if it was incorrect', async () => {
-      StrokeMatcher.mockImplementationOnce(() => ({
-        strokeMatches: () => false,
-      }));
+      strokeMatches.mockImplementation(() => false);
 
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       const onCorrectStroke = jest.fn();
       const onMistake = jest.fn();
       const onComplete = jest.fn();
@@ -242,8 +253,8 @@ describe('Quiz', () => {
       clock.tick(1000);
       await resolvePromises();
 
-      quiz.startUserStroke(new Point(10, 20));
-      quiz.continueUserStroke(new Point(100, 200));
+      quiz.startUserStroke({x: 10, y: 20});
+      quiz.continueUserStroke({x: 100, y: 200});
 
       const currentStrokeId = quiz._userStroke.id;
       expect(quiz._currentStrokeIndex).toBe(0);
@@ -260,6 +271,10 @@ describe('Quiz', () => {
         strokeNum: 0,
         strokesRemaining: 2,
         totalMistakes: 1,
+        drawnPath: {
+          pathString: 'M 10 20 L 100 200',
+          points: [{x: 15, y: 25}, {x: 105, y: 205}],
+        },
       });
       expect(onCorrectStroke).not.toHaveBeenCalled();
       expect(onComplete).not.toHaveBeenCalled();
@@ -274,12 +289,10 @@ describe('Quiz', () => {
     });
 
     it('highlights the stroke if the number of mistakes exceeds showHintAfterMisses', async () => {
-      StrokeMatcher.mockImplementationOnce(() => ({
-        strokeMatches: () => false,
-      }));
+      strokeMatches.mockImplementation(() => false);
 
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       const onCorrectStroke = jest.fn();
       const onMistake = jest.fn();
       const onComplete = jest.fn();
@@ -287,7 +300,7 @@ describe('Quiz', () => {
       clock.tick(1000);
       await resolvePromises();
 
-      quiz.startUserStroke(new Point(10, 20));
+      quiz.startUserStroke({x: 10, y: 20});
       quiz.endUserStroke();
       await resolvePromises();
       clock.tick(1000);
@@ -296,7 +309,7 @@ describe('Quiz', () => {
       // should not highlight the stroke yet
       expect(renderState.state.character.highlight.strokes[0].opacity).toBe(0);
 
-      quiz.startUserStroke(new Point(10, 20));
+      quiz.startUserStroke({x: 10, y: 20});
       quiz.endUserStroke();
       await resolvePromises();
 
@@ -310,6 +323,10 @@ describe('Quiz', () => {
         strokeNum: 0,
         strokesRemaining: 2,
         totalMistakes: 2,
+        drawnPath: {
+          pathString: 'M 10 20',
+          points: [{x: 15, y: 25}],
+        },
       });
       expect(onCorrectStroke).not.toHaveBeenCalled();
       expect(onComplete).not.toHaveBeenCalled();
@@ -328,12 +345,10 @@ describe('Quiz', () => {
     });
 
     it('finishes the quiz when all strokes are successful', async () => {
-      StrokeMatcher.mockImplementationOnce(() => ({
-        strokeMatches: () => true,
-      }));
+      strokeMatches.mockImplementation(() => true);
 
       const renderState = createRenderState();
-      const quiz = new Quiz(char, renderState);
+      const quiz = new Quiz(char, renderState, new Positioner());
       const onCorrectStroke = jest.fn();
       const onMistake = jest.fn();
       const onComplete = jest.fn();
@@ -341,13 +356,13 @@ describe('Quiz', () => {
       clock.tick(1000);
       await resolvePromises();
 
-      quiz.startUserStroke(new Point(10, 20));
+      quiz.startUserStroke({x: 10, y: 20});
       quiz.endUserStroke();
       await resolvePromises();
       clock.tick(1000);
       await resolvePromises();
 
-      quiz.startUserStroke(new Point(10, 20));
+      quiz.startUserStroke({x: 10, y: 20});
       quiz.endUserStroke();
       await resolvePromises();
 
@@ -360,6 +375,10 @@ describe('Quiz', () => {
         strokeNum: 1,
         strokesRemaining: 0,
         totalMistakes: 0,
+        drawnPath: {
+          pathString: 'M 10 20',
+          points: [{x: 15, y: 25}],
+        },
       });
       expect(onComplete).toHaveBeenCalledTimes(1);
       expect(onComplete).toHaveBeenLastCalledWith({
diff --git a/src/__tests__/geometry-test.js b/src/__tests__/geometry-test.js
index 2150c8a4..c1c2e23a 100644
--- a/src/__tests__/geometry-test.js
+++ b/src/__tests__/geometry-test.js
@@ -1,59 +1,58 @@
 const geometry = require('../geometry');
-const Point = require('../models/Point');
 
 describe('geometry', () => {
   describe('extendPointOnLine', () => {
     it('returns a point distance away from the start point', () => {
-      const p1 = new Point(0, 0);
-      const p2 = new Point(8, 6);
-      expect(geometry.extendPointOnLine(p1, p2, 5)).toEqual(new Point(12, 9));
+      const p1 = {x: 0, y: 0};
+      const p2 = {x: 8, y: 6};
+      expect(geometry.extendPointOnLine(p1, p2, 5)).toEqual({x: 12, y: 9});
     });
 
     it('works when p2 is before p1 in the line', () => {
-      const p1 = new Point(12, 9);
-      const p2 = new Point(8, 6);
-      expect(geometry.extendPointOnLine(p1, p2, 10)).toEqual(new Point(0, 0));
+      const p1 = {x: 12, y: 9};
+      const p2 = {x: 8, y: 6};
+      expect(geometry.extendPointOnLine(p1, p2, 10)).toEqual({x: 0, y: 0});
     });
 
     it('works with vertical lines', () => {
-      const p1 = new Point(2, 4);
-      const p2 = new Point(2, 6);
-      expect(geometry.extendPointOnLine(p1, p2, 7)).toEqual(new Point(2, 13));
+      const p1 = {x: 2, y: 4};
+      const p2 = {x: 2, y: 6};
+      expect(geometry.extendPointOnLine(p1, p2, 7)).toEqual({x: 2, y: 13});
     });
 
     it('works with vertical lines where p2 is above p1', () => {
-      const p1 = new Point(2, 6);
-      const p2 = new Point(2, 4);
-      expect(geometry.extendPointOnLine(p1, p2, 7)).toEqual(new Point(2, -3));
+      const p1 = {x: 2, y: 6};
+      const p2 = {x: 2, y: 4};
+      expect(geometry.extendPointOnLine(p1, p2, 7)).toEqual({x: 2, y: -3});
     });
   });
 
   describe('filterParallelPoints', () => {
     it('removes internal points that are on the line connecting the points on either side', () => {
       const points = [
-        new Point(0, 0),
-        new Point(5, 0),
-        new Point(6, 0),
-        new Point(7, 1),
-        new Point(8, 2),
-        new Point(9, 3),
-        new Point(10, 3),
-        new Point(11, 3),
+        {x: 0, y: 0},
+        {x: 5, y: 0},
+        {x: 6, y: 0},
+        {x: 7, y: 1},
+        {x: 8, y: 2},
+        {x: 9, y: 3},
+        {x: 10, y: 3},
+        {x: 11, y: 3},
       ];
       expect(geometry.filterParallelPoints(points)).toEqual([
-        new Point(0, 0),
-        new Point(6, 0),
-        new Point(9, 3),
-        new Point(11, 3),
+        {x: 0, y: 0},
+        {x: 6, y: 0},
+        {x: 9, y: 3},
+        {x: 11, y: 3},
       ]);
     });
 
     it('returns the original points if there are no parallel points', () => {
       const points = [
-        new Point(0, 0),
-        new Point(6, 0),
-        new Point(9, 3),
-        new Point(11, 3),
+        {x: 0, y: 0},
+        {x: 6, y: 0},
+        {x: 9, y: 3},
+        {x: 11, y: 3},
       ];
       expect(geometry.filterParallelPoints(points)).toEqual(points);
     });
diff --git a/src/__tests__/strokeMatches-test.js b/src/__tests__/strokeMatches-test.js
new file mode 100644
index 00000000..1ceeed87
--- /dev/null
+++ b/src/__tests__/strokeMatches-test.js
@@ -0,0 +1,279 @@
+const ren = require('hanzi-writer-data/人.json');
+const strokeMatches = require('../strokeMatches');
+const Stroke = require('../models/Stroke');
+const UserStroke = require('../models/UserStroke');
+const CharDataParser = require('../CharDataParser');
+
+
+const char = new CharDataParser().generateCharacter('人', ren);
+
+describe('strokeMatches', () => {
+  it('matches if the user stroke roughly matches the stroke medians', () => {
+    const stroke = new Stroke('', [{x: 0, y: 0}, {x: 10, y: 50}], 0);
+
+    const userStroke = new UserStroke(1, {x: 2, y: 1}, {x: 9999, y: 9999});
+    userStroke.appendPoint({x: 5, y: 25}, {x: 9999, y: 9999});
+    userStroke.appendPoint({x: 10, y: 51}, {x: 9999, y: 9999});
+
+    expect(strokeMatches(userStroke, stroke)).toBe(true);
+  });
+
+  it('does not match if the user stroke is in the wrong direction', () => {
+    const stroke = new Stroke('', [{x: 0, y: 0}, {x: 10, y: 50}], 0);
+
+    const userStroke = new UserStroke(1, {x: 10, y: 51}, {x: 9999, y: 9999});
+    userStroke.appendPoint({x: 5, y: 25}, {x: 9999, y: 9999});
+    userStroke.appendPoint({x: 2, y: 1}, {x: 9999, y: 9999});
+
+    expect(strokeMatches(userStroke, stroke)).toBe(false);
+  });
+
+  it('does not match if the user stroke is too far away', () => {
+    const stroke = new Stroke('', [{x: 0, y: 0}, {x: 10, y: 50}], 0);
+
+    const userStroke = new UserStroke(1, {x: 2 + 200, y: 1 + 200}, {x: 9999, y: 9999});
+    userStroke.appendPoint({x: 5 + 200, y: 25 + 200}, {x: 9999, y: 9999});
+    userStroke.appendPoint({x: 10 + 200, y: 51 + 200}, {x: 9999, y: 9999});
+
+    expect(strokeMatches(userStroke, stroke)).toBe(false);
+  });
+
+  it('matches using real data 1', () => {
+    const stroke = char.strokes[0];
+    const userStroke = {
+      points: [
+        {x: 409.6, y: 746.4},
+        {x: 409.6, y: 746.4},
+        {x: 409.6, y: 746.4},
+        {x: 409.6, y: 743.5555555555555},
+        {x: 409.6, y: 735.0222222222222},
+        {x: 409.6, y: 723.6444444444444},
+        {x: 406.75555555555553, y: 709.4222222222222},
+        {x: 401.06666666666666, y: 695.2},
+        {x: 395.3777777777778, y: 675.2888888888889},
+        {x: 392.53333333333336, y: 658.2222222222222},
+        {x: 384, y: 635.4666666666667},
+        {x: 378.31111111111113, y: 615.5555555555555},
+        {x: 372.6222222222222, y: 592.8},
+        {x: 366.93333333333334, y: 570.0444444444445},
+        {x: 358.4, y: 547.2888888888889},
+        {x: 347.02222222222224, y: 516},
+        {x: 338.4888888888889, y: 498.93333333333334},
+        {x: 335.64444444444445, y: 487.55555555555554},
+        {x: 329.9555555555556, y: 476.1777777777778},
+        {x: 324.26666666666665, y: 464.8},
+        {x: 318.5777777777778, y: 450.5777777777778},
+        {x: 312.8888888888889, y: 433.5111111111111},
+        {x: 304.35555555555555, y: 422.1333333333333},
+        {x: 295.8222222222222, y: 405.06666666666666},
+        {x: 290.1333333333333, y: 393.68888888888887},
+        {x: 287.2888888888889, y: 388},
+        {x: 275.9111111111111, y: 370.93333333333334},
+        {x: 273.06666666666666, y: 362.4},
+        {x: 267.3777777777778, y: 353.8666666666667},
+        {x: 261.68888888888887, y: 348.1777777777778},
+        {x: 258.84444444444443, y: 342.4888888888889},
+        {x: 256, y: 336.8},
+        {x: 253.15555555555557, y: 333.9555555555556},
+        {x: 247.46666666666667, y: 328.26666666666665},
+        {x: 244.62222222222223, y: 325.4222222222222},
+        {x: 241.77777777777777, y: 319.73333333333335},
+        {x: 236.0888888888889, y: 314.0444444444444},
+        {x: 233.24444444444444, y: 311.2},
+        {x: 227.55555555555554, y: 305.5111111111111},
+        {x: 224.7111111111111, y: 299.8222222222222},
+        {x: 219.0222222222222, y: 294.1333333333333},
+        {x: 210.48888888888888, y: 285.6},
+        {x: 204.8, y: 279.9111111111111},
+        {x: 196.26666666666668, y: 268.53333333333336},
+        {x: 193.42222222222222, y: 265.68888888888887},
+        {x: 182.04444444444445, y: 254.3111111111111},
+        {x: 173.51111111111112, y: 245.77777777777777},
+        {x: 167.82222222222222, y: 237.24444444444444},
+        {x: 164.9777777777778, y: 234.4},
+        {x: 156.44444444444446, y: 225.86666666666667},
+        {x: 147.9111111111111, y: 220.17777777777778},
+        {x: 142.22222222222223, y: 214.48888888888888},
+        {x: 139.37777777777777, y: 211.64444444444445},
+        {x: 130.84444444444443, y: 205.95555555555555},
+        {x: 122.31111111111112, y: 200.26666666666668},
+        {x: 119.46666666666667, y: 197.42222222222222},
+        {x: 113.77777777777777, y: 194.57777777777778},
+        {x: 110.93333333333334, y: 191.73333333333332},
+        {x: 105.24444444444444, y: 188.88888888888889},
+        {x: 99.55555555555556, y: 186.04444444444445},
+        {x: 93.86666666666666, y: 180.35555555555555},
+        {x: 88.17777777777778, y: 177.51111111111112},
+        {x: 82.4888888888889, y: 171.82222222222222},
+        {x: 76.8, y: 168.9777777777778},
+        {x: 71.11111111111111, y: 166.13333333333333},
+        {x: 68.26666666666667, y: 163.2888888888889},
+        {x: 65.42222222222222, y: 157.6},
+        {x: 62.577777777777776, y: 154.75555555555556},
+        {x: 56.888888888888886, y: 154.75555555555556},
+        {x: 56.888888888888886, y: 151.9111111111111},
+        {x: 54.044444444444444, y: 151.9111111111111},
+      ],
+    };
+    expect(strokeMatches(userStroke, stroke)).toBe(true);
+  });
+
+  it('matches using real data 2', () => {
+    const stroke = char.strokes[1];
+    const userStroke = {
+      points: [
+        {x: 583.1111111111111, y: 516},
+        {x: 583.1111111111111, y: 516},
+        {x: 583.1111111111111, y: 516},
+        {x: 585.9555555555555, y: 513.1555555555556},
+        {x: 594.4888888888889, y: 501.77777777777777},
+        {x: 620.0888888888888, y: 473.3333333333333},
+        {x: 648.5333333333333, y: 436.35555555555555},
+        {x: 691.2, y: 388},
+        {x: 736.7111111111111, y: 333.9555555555556},
+        {x: 790.7555555555556, y: 279.9111111111111},
+        {x: 850.4888888888889, y: 217.33333333333334},
+        {x: 890.3111111111111, y: 180.35555555555555},
+        {x: 938.6666666666666, y: 137.6888888888889},
+        {x: 975.6444444444444, y: 109.24444444444444},
+        {x: 992.7111111111111, y: 97.86666666666666},
+        {x: 998.4, y: 95.02222222222223},
+        {x: 998.4, y: 95.02222222222223},
+      ],
+    };
+    expect(strokeMatches(userStroke, stroke)).toBe(true);
+  });
+
+  it('does not match using real data 1', () => {
+    const stroke = char.strokes[0];
+    const userStroke = {
+      points: [
+        {x: 133.6888888888889, y: 595.6444444444444},
+        {x: 133.6888888888889, y: 595.6444444444444},
+        {x: 136.53333333333333, y: 595.6444444444444},
+        {x: 150.75555555555556, y: 595.6444444444444},
+        {x: 199.11111111111111, y: 595.6444444444444},
+        {x: 281.6, y: 595.6444444444444},
+        {x: 392.53333333333336, y: 595.6444444444444},
+        {x: 475.02222222222224, y: 595.6444444444444},
+        {x: 546.1333333333333, y: 595.6444444444444},
+        {x: 588.8, y: 595.6444444444444},
+        {x: 608.7111111111111, y: 595.6444444444444},
+        {x: 614.4, y: 595.6444444444444},
+        {x: 617.2444444444444, y: 595.6444444444444},
+        {x: 620.0888888888888, y: 595.6444444444444},
+        {x: 620.0888888888888, y: 595.6444444444444},
+        {x: 620.0888888888888, y: 595.6444444444444},
+        {x: 620.0888888888888, y: 595.6444444444444},
+      ],
+    };
+    expect(strokeMatches(userStroke, stroke)).toBe(false);
+  });
+
+  it('does not match using real data 2', () => {
+    const stroke = char.strokes[0];
+    const userStroke = {
+      points: [
+        {x: 31.288888888888888, y: 285.6},
+        {x: 28.444444444444443, y: 285.6},
+        {x: 34.13333333333333, y: 288.44444444444446},
+        {x: 54.044444444444444, y: 302.6666666666667},
+        {x: 102.4, y: 333.9555555555556},
+        {x: 201.95555555555555, y: 393.68888888888887},
+        {x: 287.2888888888889, y: 450.5777777777778},
+        {x: 386.84444444444443, y: 516},
+        {x: 452.26666666666665, y: 555.8222222222222},
+        {x: 506.31111111111113, y: 584.2666666666667},
+        {x: 560.3555555555556, y: 612.7111111111111},
+        {x: 603.0222222222222, y: 635.4666666666667},
+        {x: 642.8444444444444, y: 658.2222222222222},
+        {x: 676.9777777777778, y: 678.1333333333333},
+        {x: 705.4222222222222, y: 692.3555555555556},
+        {x: 719.6444444444444, y: 698.0444444444445},
+        {x: 725.3333333333334, y: 700.8888888888889},
+        {x: 728.1777777777778, y: 703.7333333333333},
+        {x: 733.8666666666667, y: 706.5777777777778},
+        {x: 733.8666666666667, y: 706.5777777777778},
+      ],
+    };
+    expect(strokeMatches(userStroke, stroke)).toBe(false);
+  });
+
+  it('does not match using real data 3', () => {
+    const stroke = char.strokes[0];
+    const userStroke = {
+      points: [
+        {x: 395.3777777777778, y: 712.2666666666667},
+        {x: 395.3777777777778, y: 712.2666666666667},
+        {x: 395.3777777777778, y: 703.7333333333333},
+        {x: 395.3777777777778, y: 692.3555555555556},
+        {x: 395.3777777777778, y: 686.6666666666666},
+        {x: 392.53333333333336, y: 672.4444444444445},
+        {x: 386.84444444444443, y: 658.2222222222222},
+        {x: 384, y: 646.8444444444444},
+        {x: 381.15555555555557, y: 635.4666666666667},
+        {x: 378.31111111111113, y: 626.9333333333333},
+        {x: 375.46666666666664, y: 621.2444444444444},
+        {x: 372.6222222222222, y: 615.5555555555555},
+        {x: 372.6222222222222, y: 615.5555555555555},
+        {x: 372.6222222222222, y: 612.7111111111111},
+        {x: 369.77777777777777, y: 612.7111111111111},
+        {x: 369.77777777777777, y: 612.7111111111111},
+      ],
+    };
+    expect(strokeMatches(userStroke, stroke)).toBe(false);
+  });
+
+  it('does not match using real data 4', () => {
+    const stroke = char.strokes[0];
+    const userStroke = {
+      points: [
+        {x: 961.4222222222222, y: 680.9777777777778},
+        {x: 961.4222222222222, y: 680.9777777777778},
+        {x: 961.4222222222222, y: 680.9777777777778},
+        {x: 961.4222222222222, y: 678.1333333333333},
+        {x: 961.4222222222222, y: 675.2888888888889},
+        {x: 961.4222222222222, y: 669.6},
+        {x: 955.7333333333333, y: 655.3777777777777},
+        {x: 952.8888888888889, y: 638.3111111111111},
+        {x: 944.3555555555556, y: 615.5555555555555},
+        {x: 935.8222222222222, y: 587.1111111111111},
+        {x: 924.4444444444445, y: 555.8222222222222},
+        {x: 913.0666666666667, y: 524.5333333333333},
+        {x: 896, y: 473.3333333333333},
+        {x: 881.7777777777778, y: 427.8222222222222},
+        {x: 873.2444444444444, y: 393.68888888888887},
+        {x: 864.7111111111111, y: 368.0888888888889},
+        {x: 844.8, y: 305.5111111111111},
+        {x: 836.2666666666667, y: 268.53333333333336},
+        {x: 822.0444444444445, y: 231.55555555555554},
+        {x: 810.6666666666666, y: 203.11111111111111},
+        {x: 802.1333333333333, y: 177.51111111111112},
+        {x: 790.7555555555556, y: 154.75555555555556},
+        {x: 782.2222222222222, y: 134.84444444444443},
+        {x: 773.6888888888889, y: 117.77777777777777},
+        {x: 765.1555555555556, y: 103.55555555555556},
+        {x: 756.6222222222223, y: 86.4888888888889},
+        {x: 750.9333333333333, y: 77.95555555555555},
+        {x: 745.2444444444444, y: 69.42222222222222},
+        {x: 742.4, y: 63.733333333333334},
+        {x: 739.5555555555555, y: 63.733333333333334},
+        {x: 739.5555555555555, y: 60.888888888888886},
+        {x: 739.5555555555555, y: 60.888888888888886},
+        {x: 736.7111111111111, y: 60.888888888888886},
+        {x: 736.7111111111111, y: 60.888888888888886},
+        {x: 736.7111111111111, y: 60.888888888888886},
+        {x: 733.8666666666667, y: 60.888888888888886},
+        {x: 733.8666666666667, y: 60.888888888888886},
+        {x: 733.8666666666667, y: 60.888888888888886},
+        {x: 733.8666666666667, y: 60.888888888888886},
+        {x: 733.8666666666667, y: 63.733333333333334},
+        {x: 733.8666666666667, y: 63.733333333333334},
+        {x: 733.8666666666667, y: 63.733333333333334},
+        {x: 733.8666666666667, y: 66.57777777777778},
+        {x: 733.8666666666667, y: 66.57777777777778},
+      ],
+    };
+    expect(strokeMatches(userStroke, stroke)).toBe(false);
+  });
+});
diff --git a/src/__tests__/svg-test.js b/src/__tests__/svg-test.js
index 4ddc3e7f..8f4fd89a 100644
--- a/src/__tests__/svg-test.js
+++ b/src/__tests__/svg-test.js
@@ -1,22 +1,21 @@
 const svg = require('../svg');
-const Point = require('../models/Point');
 
 describe('svg', () => {
   describe('getPathString', () => {
     it('returns d path based on the points passed in', () => {
       const points = [
-        new Point(0, 0),
-        new Point(5, 0),
-        new Point(5, 2),
+        {x: 0, y: 0},
+        {x: 5, y: 0},
+        {x: 5, y: 2},
       ];
       expect(svg.getPathString(points)).toEqual('M 0 0 L 5 0 L 5 2');
     });
 
     it('closes the path if close = true', () => {
       const points = [
-        new Point(0, 0),
-        new Point(5, 0),
-        new Point(5, 2),
+        {x: 0, y: 0},
+        {x: 5, y: 0},
+        {x: 5, y: 2},
       ];
       expect(svg.getPathString(points, true)).toEqual('M 0 0 L 5 0 L 5 2Z');
     });
diff --git a/src/renderers/__tests__/HanziWriterRenderer-test.js b/src/renderers/__tests__/HanziWriterRenderer-test.js
index d9effa3e..255aa642 100644
--- a/src/renderers/__tests__/HanziWriterRenderer-test.js
+++ b/src/renderers/__tests__/HanziWriterRenderer-test.js
@@ -2,7 +2,6 @@ const ren = require('hanzi-writer-data/人.json');
 const HanziWriterRenderer = require('../HanziWriterRenderer');
 const svg = require('../../svg');
 const { copyAndMergeDeep } = require('../../utils');
-const Point = require('../../models/Point');
 const Positioner = require('../../Positioner');
 const CharDataParser = require('../../CharDataParser');
 
@@ -52,7 +51,7 @@ describe('HanziWriterRenderer', () => {
       },
       userStrokes: {
         17: {
-          points: [new Point(0, 0), new Point(1, 3)],
+          points: [{x: 0, y: 0}, {x: 1, y: 3}],
           opacity: 0.9,
         },
       },