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"]]))

16 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! 🙂

    1. Nice job, I saw it a couple of days ago! It surely is more elegant to implement an existing library into Anki. Keep up the good work, I think it will be useful to many students of Chinese!

  5. Not many online gambling sites in Indonesia have official thing status
    and have licenses issued by relevant authorities. In addition, the level
    of consumer relieve for online gambling sites can nevertheless be
    completely low because of the 100 straightforward online gambling sites, deserted nearly 10-15 sites are
    skillful to give professional relieve and meet standards.

    Therefore, you cannot arbitrarily choose an online gambling site
    to be a playing partner. There are many criteria and factors that must be considered to make an online gambling site a area to shelter and keep game deposits.
    However, it will believe get older and effort to locate a trusted site gone this.

    Hence, in this article we would afterward to manage to pay for important recommendations virtually trusted and
    recognized online gambling sites in Indonesia. By becoming a advocate of this gambling site, it is positive that you will be
    pardon from doubt and one step closer to success. Online gambling is no longer just a game but a event that
    can allow you to a brighter future.

    Like any new business in the world, all step you admit
    will have swing risks and benefits. No human mammal can always make a gain in all business, and no one can always
    avoid losing. all that can be ended is to minimize losses and enlargement profits
    until finally the profits make losses not felt.

Leave a Reply

Your email address will not be published.

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