Practicing Chinese character stroke order in Anki: Natural tracing with JavaScript

A couple of weeks ago I found an amazing resource: makemeahanzi on Github by the excellent Shaunak Kishore. It is a repository of Chinese character stroke order information which he created by applying machine learning to the fonts Arphic PL KaitiM GB and Arphic PL UKai. There is also a natural app he is working on and you should definitely check it out: Inkstone. In the makemeahanzi repository, the individual strokes are saved in SVG format, and it is not very complicated to render them via JavaScript. The big advantage is that Anki naturally supports JavaScript, no plug-ins are necessary (edit: see below), and so the whole thing also works on Ankidroid. Have a look at this short video to see what it looks like:

I love Anki because it gives you the freedom to do nearly anything you want regarding the styling of your cards. As you can see in the video I stuff a lot of information on my cards, e.g. my mnemonics and mnemonic images. It gives you the freedom to change all the colors in the styling section and in the JavaScript, you can make the Chinese character animations faster or slower, whatever you want.

You can have a look at my personal deck to see how it works. You are welcome to post questions and suggestions here or as a comment to the Youtube video.

Have fun writing Chinese characters and happy new year!


First update, 2017-01-02: As it seems this doesn’t work on Windows because some part of Anki or Windows itself do not know “requestAnimationFrame” in this setup. More information: http://www.chinese-forums.com/index.php?/topic/53000-writing-chinese-characters-in-anki-stroke-by-stroke-with-instant-feedback-using-javascript/

I’ve included a fix, externalized the JavaScript and shared my deck again to include the changes.

Second update, 2018-02-14: Alina spotted a typo which has been corrected. Thank you Alina! I did not update the deck though. The typo did not affect the functionality, but it just feels nicer to have the math done correctly.

If you download my deck, you’ll have to install the JS Booster plug-in in order to make it work on the Desktop version!


I attach the JavaScript here for completeness:

Front side:

<canvas id="animcanvas" width="1024px" height="1024px"></canvas>
<canvas id="drawcanvas" width="1024px" height="1024px"></canvas>
<div id="front">
<div id="pinyindefinition">{{translations}}</div>
</div>
<div>
tags: {{Tags}}
</div>
<script>
var animcolor = "#000000";
var drawcolor = "#0000ff";
var animation_speed = 50.0;

// replace any character not in the unicode range
var pd = document.getElementById("front").innerHTML;
pd = pd.replace(/[^\x00-\x80]/g, "�");

// console.log(pd);

document.getElementById("front").innerHTML = pd;

// ###################################

(function() {
var data = {{text:strokeorder}};
var svg_strokes = data[0];
var medians = data[1];
var scaling = 0.3;
var animcanvas = document.getElementById("animcanvas");
animcanvas.style.width = Math.round(1024 * scaling) + "px";
animcanvas.style.height = Math.round(1024 * scaling) + "px";
var anim_context = animcanvas.getContext("2d");
var drawcanvas = document.getElementById("drawcanvas");
drawcanvas.style.width = Math.round(1024 * scaling) + "px";
drawcanvas.style.height = Math.round(1024 * scaling) + "px";
var draw_context = drawcanvas.getContext("2d");

// ##### ANIMATION CANVAS #####

anim_context.scale(1, -1);
anim_context.translate(0, -900);

var strokecounter = 0;

drawcanvas.addEventListener('mouseup', function() {
drawcanvas.removeEventListener('mousemove', onPaint, false);
var last_mouse = "";
for (var previous_stroke = 0; previous_stroke < strokecounter; previous_stroke++) {
prepareClip(svg_strokes[previous_stroke]);
var new_points = prepareStroke(previous_stroke);
drawStroke(previous_stroke, new_points);
}

if (strokecounter < svg_strokes.length) {
prepareClip(svg_strokes[strokecounter]);
animateStroke(strokecounter);
strokecounter++;
}
}, false);

function prepareClip(svg_stroke) {
// parse the stroke, don't draw it; we just need the shape to clip
anim_context.save();
anim_context.beginPath();
var elements = svg_stroke.split(" ");
while (elements.length > 0) {
switch (elements.shift()) {
case "M":
var point = [];
for (var i=0; i<2; i++) {
point.push(elements.shift());
}
anim_context.moveTo(point[0], point[1]);
break;
case "Q":
var values = [];
for (var i=0; i<4; i++) {
values.push(elements.shift());
}
anim_context.quadraticCurveTo(values[0], values[1], values[2], values[3]);
break;
case "L":
var point = [];
for (var i=0; i<2; i++) {
point.push(elements.shift());
}
anim_context.lineTo(point[0], point[1]);
break;
case "Z":
anim_context.closePath();
break;
default:
console.log("Error! Unknown path identifier!");
}
}
anim_context.clip();
}

function prepareStroke(strokecounter) {
var median_points = medians[strokecounter];
// information gathering
var previous_point = median_points[0];
var distances = [];
var stroke_distance = 0;
var deltas = [];
for (var i = 1; i < median_points.length; i++) {
var this_point = median_points[i];
var delta_x = this_point[0] - previous_point[0];
var delta_y = this_point[1] - previous_point[1];
deltas.push([delta_x, delta_y]);
var distance = Math.sqrt(delta_x * delta_x + delta_y * delta_y);
stroke_distance += distance;
distances.push(distance);
previous_point = median_points[i];
}

// interpolating linearly for the animation
var new_points = [median_points[0]];
// connect the dots
for (var i = 0; i < median_points.length - 1; i++) {
var distance = distances[i];
var current_position = median_points[i];
var samples = distance / animation_speed;
var delta = deltas[i];
var step_distance = Math.sqrt(delta[0] * delta[0] + delta[1] * delta[1]) / samples;
for (var j = 0; j < samples; j++) {
var new_x = current_position[0] + j/samples * delta[0];
var new_y = current_position[1] + j/samples * delta[1];
new_points.push([new_x, new_y]);
}
}
new_points.push([median_points[median_points.length-1]]);
return new_points;
}

function drawStroke(strokecounter, points) {
var new_points = prepareStroke(strokecounter);
anim_context.globalCompositeOperation="destination-over";
anim_context.beginPath();
anim_context.moveTo(new_points[0][0], new_points[0][1]);
for (var point_index=1; point_index < points.length; point_index++) {
anim_context.lineTo(new_points[point_index][0], new_points[point_index][1]);
}
anim_context.lineWidth = 200;
anim_context.lineCap = 'round';
anim_context.strokeStyle = animcolor;
anim_context.stroke();
anim_context.closePath();
anim_context.restore();
}


function animateStroke(strokecounter) {
var new_points = prepareStroke(strokecounter);
var point_index = 1;
var animate_stroke = function() {
if (point_index < new_points.length - 1) {
requestAnimationFrame(animate_stroke);
} else {
anim_context.restore();
return
}
anim_context.globalCompositeOperation="destination-over";
anim_context.beginPath();
anim_context.moveTo(new_points[point_index-1][0], new_points[point_index-1][1]);
anim_context.lineTo(new_points[point_index][0], new_points[point_index][1]);
anim_context.lineWidth = 200;
anim_context.lineCap = 'round';
anim_context.strokeStyle = animcolor;
anim_context.stroke();
anim_context.closePath();
point_index++;
};
animate_stroke();
}

// ###### DRAWING CANVAS #####

var mouse = {x: 0, y: 0};
var last_mouse = "";
var onPaint = function() {
if (last_mouse == "") {
last_mouse = {x: mouse.x, y: mouse.y};
} else {
draw_context.beginPath();
draw_context.moveTo(last_mouse.x, last_mouse.y);
draw_context.lineTo(mouse.x, mouse.y);
draw_context.closePath();
draw_context.stroke();
}
};

drawcanvas.addEventListener('mousemove', function(e) {
last_mouse.x = mouse.x;
last_mouse.y = mouse.y;
mouse.x = e.pageX / scaling - this.offsetLeft / scaling;
mouse.y = e.pageY / scaling - this.offsetTop / scaling;
}, false);

drawcanvas.addEventListener('mousedown', function(e) {
draw_context.lineWidth = 20;
draw_context.lineJoin = 'round';
draw_context.lineCap = 'round';
draw_context.strokeStyle = drawcolor;
drawcanvas.addEventListener('mousemove', onPaint, false);
}, false);

// Set up touch events for mobile
drawcanvas.addEventListener("touchstart", function (e) {
var touch = e.touches[0];
var mouseEvent = new MouseEvent("mousedown", {
clientX: touch.clientX,
clientY: touch.clientY
});
drawcanvas.dispatchEvent(mouseEvent);
}, false);

drawcanvas.addEventListener("touchend", function (e) {
last_mouse = "";
var mouseEvent = new MouseEvent("mouseup", {});
drawcanvas.dispatchEvent(mouseEvent);
}, false);

drawcanvas.addEventListener("touchmove", function (e) {
var touch = e.touches[0];
var mouseEvent = new MouseEvent("mousemove", {
clientX: touch.clientX,
clientY: touch.clientY
});
drawcanvas.dispatchEvent(mouseEvent);
}, false);

// Prevent scrolling when touching any canvas
document.body.addEventListener("touchstart", function (e) {
if (e.target == drawcanvas || e.target == animcanvas) {
e.preventDefault();
}
}, false);
document.body.addEventListener("touchend", function (e) {
if (e.target == drawcanvas || e.target == animcanvas) {
e.preventDefault();
}
}, false);
document.body.addEventListener("touchmove", function (e) {
if (e.target == drawcanvas || e.target == animcanvas) {
e.preventDefault();
}
}, false);

})();

</script>

Styling:

#front .mnemonics { display: none; }
#front .hanzi { display: none; }

.card {
font-size: 16px;
text-align: center;
}

.mnemonics {
margin-top: 1em;
padding-top: 1em;
border-top: 1px solid #888;
}

table {
margin-left: auto;
margin-right: auto;
}

td {
border: 1px solid #888;
padding: 5px;
}

#animcanvas, #ans_anim_canvas {
border: 2px solid #888;
}

#drawcanvas, #ans_draw_canvas {
position: absolute;
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
}

div {
margin: 5px;
}

.hanzi {
font-size: 2em;
font-family: uming;
}

a {
text-decoration: none;
color: #0000ff;
}

img.mimage {
width: 200px;
background-color: white;
border: 1px solid #888;
margin: 1px;
}

@font-face {
font-family: uming;
src: url('_UMingCN.ttf');
}

@font-face {
font-family: simfang;
src: url('_simfang.ttf');
}

.color1 {
color: #ff0000;
}

.color2 {
color: #d89000;
}

.color3 {
color: #00a000;
}

.color4 {
color:#0000ff;
}

Back side:

<canvas id="ans_anim_canvas" width="1024px" height="1024px"></canvas>
<canvas id="ans_draw_canvas" width="1024px" height="1024px"></canvas>

<div>{{translations}}</div>
<hr>
<div>{{components}}</div>
<hr>
<div>
tags: {{Tags}}
</div>

<div><a href="http://mandarinbanana.herokuapp.com/hurl/{{text:hanzi}}">more information / leave a comment / ask a question on mandarinbanana.com</a></div>

<script>
var animcolor = "#000000";
var drawcolor = "#0000ff";
var animation_speed = 20.0;
// pause between strokes in milliseconds
var pause_between_strokes = 400.0;

(function() {
var data = {{text:strokeorder}};
var svg_strokes = data[0];
var medians = data[1];
var scaling = 0.3;

// main program starts here

var ans_anim_canvas = document.getElementById("ans_anim_canvas");
ans_anim_canvas.style.width = Math.round(1024 * scaling) + "px";
ans_anim_canvas.style.height = Math.round(1024 * scaling) + "px";
var ans_anim_ctx = ans_anim_canvas.getContext("2d");
ans_anim_ctx.scale(1, -1);
ans_anim_ctx.translate(0, -900);
var ans_draw_canvas = document.getElementById("ans_draw_canvas");
ans_draw_canvas.style.width = Math.round(1024 * scaling) + "px";
ans_draw_canvas.style.height = Math.round(1024 * scaling) + "px";
var ans_draw_ctx = ans_draw_canvas.getContext("2d");

grayCharacter();
animateStroke(0);

function animateStroke(strokecounter) {
var svg_stroke = svg_strokes[strokecounter];
ans_anim_ctx.save();
// parse the stroke, don't draw it; we just need the shape to clip
ans_anim_ctx.beginPath();
var elements = svg_stroke.split(" ");
while (elements.length > 0) {
switch (elements.shift()) {
case "M":
var point = [];
for (var i=0; i<2; i++) {
point.push(elements.shift());
}
ans_anim_ctx.moveTo(point[0], point[1]);
break;
case "Q":
var values = [];
for (var i=0; i<4; i++) {
values.push(elements.shift());
}
ans_anim_ctx.quadraticCurveTo(values[0], values[1], values[2], values[3]);
break;
case "L":
var point = [];
for (var i=0; i<2; i++) {
point.push(elements.shift());
}
ans_anim_ctx.lineTo(point[0], point[1]);
break;
case "Z":
ans_anim_ctx.closePath();
break;
default:
console.log("Error! Unknown path identifier!");
}
}

ans_anim_ctx.clip();

var median_points = medians[strokecounter];

// information gathering

var previous_point = median_points[0];
var distances = [];
var stroke_distance = 0;
var deltas = [];
for (var i = 1; i < median_points.length; i++) {
var this_point = median_points[i];
var delta_x = this_point[0] - previous_point[0];
var delta_y = this_point[1] - previous_point[1];
deltas.push([delta_x, delta_y]);
var distance = Math.sqrt(delta_x * delta_x + delta_y * delta_y);
stroke_distance += distance;
distances.push(distance);
previous_point = median_points[i];
}

// interpolating linearly for the animation

var new_points = [median_points[0]];

// connect the dots

for (var i = 0; i < median_points.length - 1; i++) {
var distance = distances[i];
var current_position = median_points[i];
var samples = distance / animation_speed;
var delta = deltas[i];
var step_distance = Math.sqrt(delta[0] * delta[0] + delta[1] * delta[1]) / samples;
for (var j = 0; j < samples; j++) {
var new_x = current_position[0] + j/samples * delta[0];
var new_y = current_position[1] + j/samples * delta[1];
new_points.push([new_x, new_y]);
}
}
new_points.push([median_points[median_points.length-1]]);

var point_index = 1;

var animate_stroke = function() {
if (point_index < new_points.length - 1) {
requestAnimationFrame(animate_stroke);
} else {
ans_anim_ctx.restore();
strokecounter++;
if (strokecounter == svg_strokes.length) {
strokecounter = 0;
setTimeout(function(){
ans_anim_ctx.clearRect(-1000,-1000,3000,3000);
grayCharacter();
animateStroke(strokecounter);
ans_draw_ctx.clearRect(-1000,-1000,3000,3000);
}, 1000);
} else {
setTimeout(function() {
animateStroke(strokecounter);
}, pause_between_strokes);
return
}
}
ans_anim_ctx.beginPath();
ans_anim_ctx.moveTo(new_points[point_index-1][0], new_points[point_index-1][1]);
ans_anim_ctx.lineTo(new_points[point_index][0], new_points[point_index][1]);
ans_anim_ctx.lineWidth = 200;
ans_anim_ctx.lineCap = 'round';
ans_anim_ctx.strokeStyle = animcolor;
ans_anim_ctx.stroke();
ans_anim_ctx.closePath();
point_index++;
}
animate_stroke(strokecounter);
}

function grayCharacter() {
// parse the stroke, don't draw it; we just need the shape to clip
for (var strokecounter=0; strokecounter < svg_strokes.length; strokecounter++) {
ans_anim_ctx.save();
ans_anim_ctx.beginPath();
var svg_stroke = svg_strokes[strokecounter];
var elements = svg_stroke.split(" ");
while (elements.length > 0) {
switch (elements.shift()) {
case "M":
var point = [];
for (var i=0; i<2; i++) point.push(elements.shift());
ans_anim_ctx.moveTo(point[0], point[1]);
break;
case "Q":
var values = [];
for (var i=0; i<4; i++) values.push(elements.shift());
ans_anim_ctx.quadraticCurveTo(values[0], values[1], values[2], values[3]);
break;
case "L":
var point = [];
for (var i=0; i<2; i++) point.push(elements.shift());
ans_anim_ctx.lineTo(point[0], point[1]);
break;
case "Z":
ans_anim_ctx.closePath();
break;
default:
console.log("Error! Unknown path identifier!");
}
}
ans_anim_ctx.clip();
ans_anim_ctx.beginPath();
ans_anim_ctx.rect(-1000,-1000,3000,3000);
ans_anim_ctx.fillStyle = "#AAA";
ans_anim_ctx.fill();
ans_anim_ctx.stroke();
ans_anim_ctx.restore();
}
}

// DRAWING CANVAS

ans_draw_canvas.addEventListener('mouseup', function() {
ans_draw_canvas.removeEventListener('mousemove', onPaint, false);
var last_mouse = "";
}, false);

var mouse = {x: 0, y: 0};
var last_mouse = "";

var onPaint = function() {
if (last_mouse == "") {
last_mouse = {x: mouse.x, y: mouse.y};
} else {
ans_draw_ctx.beginPath();
ans_draw_ctx.moveTo(last_mouse.x, last_mouse.y);
ans_draw_ctx.lineTo(mouse.x, mouse.y);
ans_draw_ctx.closePath();
ans_draw_ctx.stroke();
}
};

ans_draw_canvas.addEventListener('mousemove', function(e) {
last_mouse.x = mouse.x;
last_mouse.y = mouse.y;
mouse.x = e.pageX / scaling - this.offsetLeft / scaling;
mouse.y = e.pageY / scaling - this.offsetTop / scaling;
}, false);

ans_draw_canvas.addEventListener('mousedown', function(e) {
ans_draw_ctx.lineWidth = 20;
ans_draw_ctx.lineJoin = 'round';
ans_draw_ctx.lineCap = 'round';
ans_draw_ctx.strokeStyle = drawcolor;
ans_draw_canvas.addEventListener('mousemove', onPaint, false);
}, false);

// Set up touch events for mobile
ans_draw_canvas.addEventListener("touchstart", function (e) {
var touch = e.touches[0];
var mouseEvent = new MouseEvent("mousedown", {
clientX: touch.clientX,
clientY: touch.clientY
});
ans_draw_canvas.dispatchEvent(mouseEvent);
}, false);

ans_draw_canvas.addEventListener("touchend", function (e) {
last_mouse = "";
var mouseEvent = new MouseEvent("mouseup", {});
ans_draw_canvas.dispatchEvent(mouseEvent);
}, false);

ans_draw_canvas.addEventListener("touchmove", function (e) {
var touch = e.touches[0];
var mouseEvent = new MouseEvent("mousemove", {
clientX: touch.clientX,
clientY: touch.clientY
});
ans_draw_canvas.dispatchEvent(mouseEvent);
}, false);

// Prevent scrolling when touching any canvas

document.body.addEventListener("touchstart", function (e) {
if (e.target == ans_draw_canvas || e.target == animcanvas) {
e.preventDefault();
}
}, false);

document.body.addEventListener("touchend", function (e) {
if (e.target == ans_draw_canvas || e.target == animcanvas) {
e.preventDefault();
}
}, false);

document.body.addEventListener("touchmove", function (e) {
if (e.target == ans_draw_canvas || e.target == animcanvas) {
e.preventDefault();
}
}, false);
})();

</script>

Here’s a Python script I used to preprocess the dictionary from makemeahanzi (to make importing in Anki easy):

#!/usr/bin/python
# coding: utf-8

import json
import csv

with open('graphics.txt', 'rb') as jsonfile:
    content = jsonfile.readlines()

for line in content:
    dictionary = json.loads(line)
    print "{}\t{}".format(dictionary["character"].encode('utf-8'), json.dumps([dictionary["strokes"], dictionary["medians"]]))

12 thoughts on “Practicing Chinese character stroke order in Anki: Natural tracing with JavaScript”

  1. Hey. That’s a marvelous plugin! Will it work it macOSX? Thank you very much 🙂

  2. Really cool project, thank you!
    P. S. I think you have mistyping in the 127-th line of front script: var step_distance = Math.sqrt(delta[0] * delta[0] + delta[1] + delta[1]), and the same in back one

    1. Wow, you must have had a really close look on the code to catch this typo. Thank you very much, I just corrected it in the blog post (and in my deck)!

  3. Is there a way to adjust your note type to work for multiple characters? I really appreciate what you have done here! I have adapted the notes from your deck to work for my own purposes but I can’t figure out how to get multiple canvasses and stroke order diagrams to play nicely on a single card. If you have time to look into this at all I would love to hear your thoughts of how to make this work. Thanks again!

    1. Hey Drew, glad to hear that, it’s always nice to hear that other people find this stuff helpful. About your question, you would have to have multiple canvases with different IDs which somehow call the JavaScript function. Then you would have to execute the JavaScript for each of the canvasses. I guess it would be possible to do this by just duplicating the code over and over again with just changing the variable names, but it would be ugly. Another possibility is to rewrite the code all over, but this time rigorously using functions and such to support multiple canvasses. In any event it would be a lot of work, and as I don’t study handwriting anymore I won’t implement this feature, sorry.

      1. Thanks so much for your reply. I did my best to adjust the coding myself. I managed to get multiple canvasses operational, but I couldn’t get them to display different sets of data. It would be too complex to summarize all the strange outcomes the resulted from my tinkering, but since I really no nothing about code I’m throwing the towel.

        I also tried to implement another version of this stroke data from: https://chanind.github.io/hanzi-writer/

        But that too proved too difficult for me. I shall continue to study Chinese writing through less nifty options and hope for something easier in the future. Thanks!!!

  4. Thanks! I got it to work on AnkiMobile (iOS) without JS Booster by doing this:


    var script = document.createElement("script");
    script.type = "text/javascript";
    script.src = "_ankitrace.front.js";
    document.getElementsByTagName("head")[0].appendChild(script);

    And the same for “_ankitrace.back.js” on the back template.

    1. Thanks for the feedback. For me it works on android without the modifications, maybe it is necessary to add this for iOS.

      1. Yeah, I’m pretty sure that’s one of the differences between the Android and iOS apps. It’s a little annoying, but at least a workaround exists! 🙂

Leave a Reply to Drew Cancel reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.