Skip to content

Commit

Permalink
feat: allow checking backwards strokes (#252)
Browse files Browse the repository at this point in the history
* feat: allow checking backwards strokes

* fixup: fix getMatchData types

Because it is recursive, it requires an explicit return type annotation.

* fixup: add single point test case

* fixup: avoid ternary values
matt-tingen authored Oct 2, 2021
1 parent f1a810c commit 15d37e8
Showing 6 changed files with 277 additions and 43 deletions.
33 changes: 23 additions & 10 deletions src/Quiz.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import strokeMatches from './strokeMatches';
import strokeMatches, { StrokeMatchResultMeta } from './strokeMatches';
import UserStroke from './models/UserStroke';
import Positioner from './Positioner';
import { counter, colorStringToVals } from './utils';
@@ -92,8 +92,10 @@ export default class Quiz {
return;
}

const { acceptBackwardsStrokes } = this._options!;

const currentStroke = this._getCurrentStroke();
const isMatch = strokeMatches(
const { isMatch, meta } = strokeMatches(
this._userStroke,
this._character,
this._currentStrokeIndex,
@@ -103,10 +105,12 @@ export default class Quiz {
},
);

if (isMatch) {
this._handleSuccess();
const isAccepted = isMatch || (meta.isStrokeBackwards && acceptBackwardsStrokes);

if (isAccepted) {
this._handleSuccess(meta);
} else {
this._handleFailure();
this._handleFailure(meta);

const {
showHintAfterMisses,
@@ -143,7 +147,13 @@ export default class Quiz {
}
}

_getStrokeData(isCorrect = false): StrokeData {
_getStrokeData({
isCorrect,
meta,
}: {
isCorrect: boolean;
meta: StrokeMatchResultMeta;
}): StrokeData {
return {
character: this._character.symbol,
strokeNum: this._currentStrokeIndex,
@@ -152,10 +162,11 @@ export default class Quiz {
strokesRemaining:
this._character.strokes.length - this._currentStrokeIndex - (isCorrect ? 1 : 0),
drawnPath: getDrawnPath(this._userStroke!),
isBackwards: meta.isStrokeBackwards,
};
}

_handleSuccess() {
_handleSuccess(meta: StrokeMatchResultMeta) {
if (!this._options) return;

const { strokes, symbol } = this._character;
@@ -168,7 +179,9 @@ export default class Quiz {
strokeHighlightDuration,
} = this._options;

onCorrectStroke?.(this._getStrokeData(true));
onCorrectStroke?.({
...this._getStrokeData({ isCorrect: true, meta }),
});

let animation: MutationChain = characterActions.showStroke(
'main',
@@ -200,10 +213,10 @@ export default class Quiz {
this._renderState.run(animation);
}

_handleFailure() {
_handleFailure(meta: StrokeMatchResultMeta) {
this._mistakesOnStroke += 1;
this._totalMistakes += 1;
this._options!.onMistake?.(this._getStrokeData());
this._options!.onMistake?.(this._getStrokeData({ isCorrect: false, meta }));
}

_getCurrentStroke() {
180 changes: 171 additions & 9 deletions src/__tests__/Quiz-test.ts
Original file line number Diff line number Diff line change
@@ -278,7 +278,10 @@ describe('Quiz', () => {

describe('endUserStroke', () => {
it('finishes the stroke and moves on if it was correct', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
@@ -318,6 +321,7 @@ describe('Quiz', () => {
{ x: 105, y: 205 },
],
},
isBackwards: false,
});
expect(onMistake).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
@@ -331,8 +335,141 @@ describe('Quiz', () => {
expect(renderState.state.userStrokes![currentStrokeId]).toBe(null);
});

it('accepts backwards stroke when allowed', async () => {
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: true },
}));

const renderState = createRenderState();
const quiz = new Quiz(
char,
renderState,
new Positioner({ padding: 20, width: 200, height: 200 }),
);
const onCorrectStroke = jest.fn();
const onMistake = jest.fn();
const onComplete = jest.fn();
quiz.startQuiz(
Object.assign({}, opts, {
onCorrectStroke,
onComplete,
onMistake,
acceptBackwardsStrokes: true,
}),
);
clock.tick(1000);
await resolvePromises();

quiz.startUserStroke({ x: 100, y: 200 });
quiz.continueUserStroke({ x: 10, y: 20 });

const currentStrokeId = quiz._userStroke!.id;
expect(quiz._currentStrokeIndex).toBe(0);
quiz.endUserStroke();
await resolvePromises();

expect(quiz._userStroke).toBeUndefined();
expect(quiz._isActive).toBe(true);
expect(quiz._currentStrokeIndex).toBe(1);
expect(onCorrectStroke).toHaveBeenCalledTimes(1);
expect(onCorrectStroke).toHaveBeenCalledWith({
character: '人',
mistakesOnStroke: 0,
strokeNum: 0,
strokesRemaining: 1,
totalMistakes: 0,
drawnPath: {
pathString: 'M 100 200 L 10 20',
points: [
{ x: 105, y: 205 },
{ x: 15, y: 25 },
],
},
isBackwards: true,
});
expect(onMistake).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();

clock.tick(1000);
await resolvePromises();

expect(renderState.state.character.main.strokes[0].opacity).toBe(1);
expect(renderState.state.character.main.strokes[1].opacity).toBe(0);
// should fade and disappear
expect(renderState.state.userStrokes![currentStrokeId]).toBe(null);
});

it('notes backwards stroke when checking', async () => {
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: true },
}));

const renderState = createRenderState();
const quiz = new Quiz(
char,
renderState,
new Positioner({ padding: 20, width: 200, height: 200 }),
);
const onCorrectStroke = jest.fn();
const onMistake = jest.fn();
const onComplete = jest.fn();
quiz.startQuiz(
Object.assign({}, opts, {
onCorrectStroke,
onComplete,
onMistake,
acceptBackwardsStrokes: false,
}),
);
clock.tick(1000);
await resolvePromises();

quiz.startUserStroke({ x: 100, y: 200 });
quiz.continueUserStroke({ x: 10, y: 20 });

const currentStrokeId = quiz._userStroke!.id;
expect(quiz._currentStrokeIndex).toBe(0);
quiz.endUserStroke();
await resolvePromises();

expect(quiz._userStroke).toBeUndefined();
expect(quiz._isActive).toBe(true);
expect(quiz._currentStrokeIndex).toBe(0);
expect(onMistake).toHaveBeenCalledTimes(1);
expect(onMistake).toHaveBeenCalledWith({
character: '人',
mistakesOnStroke: 1,
strokeNum: 0,
strokesRemaining: 2,
totalMistakes: 1,
drawnPath: {
pathString: 'M 100 200 L 10 20',
points: [
{ x: 105, y: 205 },
{ x: 15, y: 25 },
],
},
isBackwards: true,
});
expect(onCorrectStroke).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();

clock.tick(1000);
await resolvePromises();

expect(renderState.state.character.main.strokes[0].opacity).toBe(0);
expect(renderState.state.character.main.strokes[1].opacity).toBe(0);
// should fade and disappear
expect(renderState.state.userStrokes![currentStrokeId]).toBe(null);
});

it('ignores single point strokes', async () => {
(strokeMatches as any).mockImplementation(() => false);
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
@@ -369,7 +506,10 @@ describe('Quiz', () => {
});

it('stays on the stroke if it was incorrect', async () => {
(strokeMatches as any).mockImplementation(() => false);
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
@@ -409,6 +549,7 @@ describe('Quiz', () => {
{ x: 105, y: 205 },
],
},
isBackwards: false,
});
expect(onCorrectStroke).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
@@ -423,7 +564,10 @@ describe('Quiz', () => {
});

it('highlights the stroke if the number of mistakes exceeds showHintAfterMisses', async () => {
(strokeMatches as any).mockImplementation(() => false);
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
@@ -477,6 +621,7 @@ describe('Quiz', () => {
{ x: 16, y: 26 },
],
},
isBackwards: false,
});
expect(onCorrectStroke).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
@@ -495,7 +640,10 @@ describe('Quiz', () => {
});

it('does not highlight strokes if showHintAfterMisses is set to false', async () => {
(strokeMatches as any).mockImplementation(() => false);
(strokeMatches as any).mockImplementation(() => ({
isMatch: false,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
@@ -528,7 +676,10 @@ describe('Quiz', () => {
});

it('finishes the quiz when all strokes are successful', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
@@ -579,6 +730,7 @@ describe('Quiz', () => {
{ x: 16, y: 26 },
],
},
isBackwards: false,
});
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenLastCalledWith({
@@ -603,7 +755,10 @@ describe('Quiz', () => {
});

it('rounds drawn path data', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));

const renderState = createRenderState();
const quiz = new Quiz(
@@ -635,6 +790,7 @@ describe('Quiz', () => {
{ x: 16.9, y: 28.4 },
],
},
isBackwards: false,
});
});
});
@@ -653,7 +809,10 @@ describe('Quiz', () => {
});

it('doesnt leave strokes partially drawn if the users finishes the quiz really fast', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));
const renderState = createRenderState();
const quiz = new Quiz(
char,
@@ -686,7 +845,10 @@ describe('Quiz', () => {
});

it('sets up character opacities correctly if the users starts drawing during char fading', async () => {
(strokeMatches as any).mockImplementation(() => true);
(strokeMatches as any).mockImplementation(() => ({
isMatch: true,
meta: { isStrokeBackwards: false },
}));
const renderState = createRenderState();
const quiz = new Quiz(
char,
41 changes: 34 additions & 7 deletions src/__tests__/strokeMatches-test.ts
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ const assertMatches = (
) => {
const char = getChar(charStr);
const userStroke = { points } as any;
expect(strokeMatches(userStroke, char, strokeNum, options)).toBe(true);
expect(strokeMatches(userStroke, char, strokeNum, options).isMatch).toBe(true);
};

const assertNotMatches = (
@@ -30,10 +30,28 @@ const assertNotMatches = (
) => {
const char = getChar(charStr);
const userStroke = { points } as any;
expect(strokeMatches(userStroke, char, strokeNum, options)).toBe(false);
expect(strokeMatches(userStroke, char, strokeNum, options).isMatch).toBe(false);
};

describe('strokeMatches', () => {
it('does not match if the user stroke is a single point', () => {
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 });

expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toEqual({
isMatch: false,
meta: { isStrokeBackwards: false },
});
});

it('matches if the user stroke roughly matches the stroke medians', () => {
const stroke = new Stroke(
'',
@@ -48,7 +66,10 @@ describe('strokeMatches', () => {
userStroke.appendPoint({ x: 5, y: 25 }, { x: 9999, y: 9999 });
userStroke.appendPoint({ x: 10, y: 51 }, { x: 9999, y: 9999 });

expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toBe(true);
expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toEqual({
isMatch: true,
meta: { isStrokeBackwards: false },
});
});

it('does not match if the user stroke is in the wrong direction', () => {
@@ -65,7 +86,10 @@ describe('strokeMatches', () => {
userStroke.appendPoint({ x: 5, y: 25 }, { x: 9999, y: 9999 });
userStroke.appendPoint({ x: 2, y: 1 }, { x: 9999, y: 9999 });

expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toBe(false);
expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toEqual({
isMatch: false,
meta: { isStrokeBackwards: true },
});
});

it('does not match if the user stroke is too far away', () => {
@@ -86,7 +110,10 @@ describe('strokeMatches', () => {
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, new Character('X', [stroke]), 0)).toBe(false);
expect(strokeMatches(userStroke, new Character('X', [stroke]), 0)).toEqual({
isMatch: false,
meta: { isStrokeBackwards: false },
});
});

it('is more lenient depending on how large leniency is', () => {
@@ -109,10 +136,10 @@ describe('strokeMatches', () => {

expect(
strokeMatches(userStroke, new Character('X', [stroke]), 0, { leniency: 0.2 }),
).toBe(false);
).toEqual({ isMatch: false, meta: { isStrokeBackwards: false } });
expect(
strokeMatches(userStroke, new Character('X', [stroke]), 0, { leniency: 20 }),
).toBe(true);
).toEqual({ isMatch: true, meta: { isStrokeBackwards: false } });
});

it('matches using real data 1', () => {
1 change: 1 addition & 0 deletions src/defaultOptions.ts
Original file line number Diff line number Diff line change
@@ -38,6 +38,7 @@ const defaultOptions: HanziWriterOptions = {
showHintAfterMisses: 3,
highlightOnComplete: true,
highlightCompleteColor: null,
acceptBackwardsStrokes: false,

// undocumented obscure options

62 changes: 45 additions & 17 deletions src/strokeMatches.ts
Original file line number Diff line number Diff line change
@@ -20,6 +20,15 @@ const START_AND_END_DIST_THRESHOLD = 250; // bigger = more lenient
const FRECHET_THRESHOLD = 0.4; // bigger = more lenient
const MIN_LEN_THRESHOLD = 0.35; // smaller = more lenient

export interface StrokeMatchResultMeta {
isStrokeBackwards: boolean;
}

export interface StrokeMatchResult {
isMatch: boolean;
meta: StrokeMatchResultMeta;
}

export default function strokeMatches(
userStroke: UserStroke,
character: Character,
@@ -28,26 +37,29 @@ export default function strokeMatches(
leniency?: number;
isOutlineVisible?: boolean;
} = {},
) {
): StrokeMatchResult {
const strokes = character.strokes;
const points = stripDuplicates(userStroke.points);

if (points.length < 2) {
return null;
return { isMatch: false, meta: { isStrokeBackwards: false } };
}

const { isMatch, avgDist } = getMatchData(points, strokes[strokeNum], options);
const { isMatch, meta, avgDist } = getMatchData(points, strokes[strokeNum], options);

if (!isMatch) {
return false;
return { isMatch, meta };
}

// if there is a better match among strokes the user hasn't drawn yet, the user probably drew the wrong stroke
const laterStrokes = strokes.slice(strokeNum + 1);
let closestMatchDist = avgDist;

for (let i = 0; i < laterStrokes.length; i++) {
const { isMatch, avgDist } = getMatchData(points, laterStrokes[i], options);
const { isMatch, avgDist } = getMatchData(points, laterStrokes[i], {
...options,
checkBackwards: false,
});
if (isMatch && avgDist < closestMatchDist) {
closestMatchDist = avgDist;
}
@@ -57,13 +69,14 @@ export default function strokeMatches(
if (closestMatchDist < 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 + avgDist)) / (2 * avgDist);
const { isMatch } = getMatchData(points, strokes[strokeNum], {
const { isMatch, meta } = getMatchData(points, strokes[strokeNum], {
...options,
leniency: (options.leniency || 1) * leniencyAdjustment,
});
return isMatch;
return { isMatch, meta };
}
return true;

return { isMatch, meta };
}

const startAndEndMatches = (points: Point[], closestStroke: Stroke, leniency: number) => {
@@ -143,23 +156,38 @@ const shapeFit = (curve1: Point[], curve2: Point[], leniency: number) => {
const getMatchData = (
points: Point[],
stroke: Stroke,
options: { leniency?: number; isOutlineVisible?: boolean },
) => {
const { leniency = 1, isOutlineVisible = false } = options;
options: { leniency?: number; isOutlineVisible?: boolean; checkBackwards?: boolean },
): StrokeMatchResult & { avgDist: number } => {
const { leniency = 1, isOutlineVisible = false, checkBackwards = true } = options;
const avgDist = stroke.getAverageDistance(points);
const distMod = isOutlineVisible || stroke.strokeNum > 0 ? 0.5 : 1;
const withinDistThresh = avgDist <= AVG_DIST_THRESHOLD * distMod * leniency;
// short circuit for faster matching
if (!withinDistThresh) {
return { isMatch: false, avgDist };
return { isMatch: false, avgDist, meta: { isStrokeBackwards: false } };
}
const startAndEndMatch = startAndEndMatches(points, stroke, leniency);
const directionMatch = directionMatches(points, stroke);
const shapeMatch = shapeFit(points, stroke.points, leniency);
const lengthMatch = lengthMatches(points, stroke, leniency);
return {
isMatch:
withinDistThresh && startAndEndMatch && directionMatch && shapeMatch && lengthMatch,
avgDist,
};

const isMatch =
withinDistThresh && startAndEndMatch && directionMatch && shapeMatch && lengthMatch;

if (checkBackwards && !isMatch) {
const backwardsMatchData = getMatchData([...points].reverse(), stroke, {
...options,
checkBackwards: false,
});

if (backwardsMatchData.isMatch) {
return {
isMatch,
avgDist,
meta: { isStrokeBackwards: true },
};
}
}

return { isMatch, avgDist, meta: { isStrokeBackwards: false } };
};
3 changes: 3 additions & 0 deletions src/typings/types.ts
Original file line number Diff line number Diff line change
@@ -52,6 +52,7 @@ export type StrokeData = {
pathString: string;
points: Point[];
};
isBackwards: boolean;
strokeNum: number;
mistakesOnStroke: number;
totalMistakes: number;
@@ -65,6 +66,8 @@ export type QuizOptions = {
showHintAfterMisses: number | false;
/** After a quiz is completed successfully, the character will flash briefly. Default: true */
highlightOnComplete: boolean;
/** Whether to treat strokes which are correct besides their direction as correct. */
acceptBackwardsStrokes: boolean;
onMistake?: (strokeData: StrokeData) => void;
onCorrectStroke?: (strokeData: StrokeData) => void;
/** Callback when the quiz completes */

0 comments on commit 15d37e8

Please sign in to comment.