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. The best in class, Depoxito provide you high-end
    experience that forward the look and environment of true VIP standarts, we present you the best enthralling to high-level experience of VIPs
    expect in any top end casino, grand stir casino royale have the funds for you the supplementary studio design element including the
    grand blackjack, offering our VIP Customer
    the best experience of a Salon privee table.
    New style table afterward feature across the room in the manner of grand roulette upgraded on our provider playtechs mini prestige roulette which delivering more fascinating and richer
    playing experience. The additional experience contains a sum of seven tables including five
    blackjack tables, one roulette table and one
    baccarat table. Grand alive casino royale has been tall hand-engineered to fit
    the needs of our customer to using it, and contains unique
    elements that is specially intended to maximize the impact value we got from our customers and diversify it to the existing network.

    Soon, Depoxito will manufacture an enlarged truth technology on stimulate casino for our VIP member, these most avant-garde technology ever seen in bring
    to life casino including this bigger reality. Which allow players to experience products upon an entire extra level which is never seen back literally
    leaping out of the game and taking the blackjack, baccarat,
    roulette and extra game into the summative entire level.

    Depoxito VIP Baccarat, we offer you the very exclusive conscious VIP Baccarat that is played behind happening to 7 players at the same table and our severely trained beautiful bring to life baccarat dealer.
    And of course our VIP member will character as if they were
    in reality sitting at one of the summit casino baccarat
    table. This immersive gaming experience creates a hugely carefree space that our VIP players will find
    hard to surpass.
    Here is the list of stir casino game that depoxito provide, we
    have the funds for the widest range of bring to life casino games upon the present
    including : blackjack unlimited, blackjack prestige, roulette, baccarat, poker, hi-lo, sic bo,
    and grand stimulate casino royale such as Grand Baccarat,
    Grand Blackjack and Grand Roulette for our VIP member.

    And of course as a enthusiast of Depoxito you can enjoy all
    the games that we allow to you, all you obsession to complete is just visit
    our site depoxito and register it only takes going on to 3 minutes and after that youre adequate to sham
    any game that you want.
    Be our VIP, physical our VIP aficionado of course decided you the best help you can get from us every you craving to be a
    VIP advocate is very easy. all you craving is just keep playing on our
    site, deposit and exploit with a VIP taking into account the amount that our company had written, keep playing and our customer facilitate will get into you
    that you are promoted to become a VIP aficionado on our site.

Leave a Reply

Your email address will not be published.

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